Run pgvector With Django in Production
Store and search embeddings with pgvector in a production Django app: enable the extension with a migration, use VectorField, build HNSW indexes without blocking writes, tune memory, batch embedding generation in Celery, and plan for index size.
Problem statement
Semantic search, recommendations, and retrieval for LLM features need vector similarity search over embeddings. Instead of adding a separate vector database, many Django apps use pgvector, a PostgreSQL extension that stores vectors in a column and indexes them. That keeps embeddings next to the rows they describe, inside the same transactions and backups. Production use brings specific concerns: enabling the extension on managed databases, building large indexes without locking the table, memory for index builds, and generating embeddings outside web requests.
- Make sure the database supports pgvector (most managed PostgreSQL services do) and enable it in a migration with
VectorExtension(). pip install pgvector; addVectorField(dimensions=1536)(match your embedding model) to the model.- Generate embeddings in Celery tasks, batched, never in the request.
- Create an HNSW index with
HnswIndex(..., opclasses=["vector_cosine_ops"]), concurrently on existing large tables. - Raise
maintenance_work_memfor index builds and sethnsw.ef_searchper query when needed. - Order queries by
CosineDistanceand filter carefully, since filters interact with approximate search.
Step-by-step solution
1. Enable the extension
# app/migrations/0010_enable_pgvector.py
from django.db import migrations
from pgvector.django import VectorExtension
class Migration(migrations.Migration):
dependencies = [("app", "0009_previous")]
operations = [VectorExtension()]
Creating extensions may need elevated privileges. On RDS, Cloud SQL, Azure, Supabase, and Neon, the app's owner role can usually run CREATE EXTENSION vector; otherwise an admin runs it once.
2. Add the field
from pgvector.django import VectorField, HnswIndex
class Document(models.Model):
title = models.CharField(max_length=300)
body = models.TextField()
embedding = VectorField(dimensions=1536, null=True)
embedded_at = models.DateTimeField(null=True)
class Meta:
indexes = [
HnswIndex(name="doc_embedding_hnsw", fields=["embedding"],
m=16, ef_construction=64, opclasses=["vector_cosine_ops"]),
]
The dimension must match the embedding model exactly; changing models later means a new column or a full re-embed. Make the column nullable so rows can exist before their embedding is computed.
3. Generate embeddings in the background
@shared_task(rate_limit="60/m", autoretry_for=(Exception,), retry_backoff=True)
def embed_documents(ids):
docs = list(Document.objects.filter(pk__in=ids))
vectors = embedding_client.embed([d.body[:8000] for d in docs]) # one batched API call
for d, v in zip(docs, vectors):
d.embedding, d.embedded_at = v, timezone.now()
Document.objects.bulk_update(docs, ["embedding", "embedded_at"])
Queue it with transaction.on_commit after saves. Batching reduces API cost and latency, and rate limits keep you within the provider's quotas. Backfill existing rows with a management command that enqueues batches.
4. Build indexes without blocking writes
On a table with existing rows, a normal CREATE INDEX locks out writes for the whole build, which can take a long time for millions of vectors. Use a concurrent build in a separate, non-atomic migration:
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
operations = [AddIndexConcurrently("document", HnswIndex(
name="doc_embedding_hnsw", fields=["embedding"], m=16, ef_construction=64,
opclasses=["vector_cosine_ops"]))]
HNSW builds are memory-hungry; raise maintenance_work_mem for the session (for example SET maintenance_work_mem = '2GB' on a large instance) and expect index size to be comparable to or larger than the vectors themselves.
5. Query
from pgvector.django import CosineDistance
def similar(query_vector, limit=10):
return (Document.objects.exclude(embedding=None)
.annotate(distance=CosineDistance("embedding", query_vector))
.order_by("distance")[:limit])
With an HNSW index, filters such as .filter(tenant=...) are applied after the approximate search, so a selective filter can return fewer than limit rows. Increase hnsw.ef_search for the query, use iterative index scans in pgvector 0.8+, or partition data so filters match index boundaries.
Verification check: EXPLAIN ANALYZE on the similarity query shows an Index Scan using doc_embedding_hnsw, results come back in milliseconds for production-sized data, and new documents receive embeddings shortly after saving.
Explanation
pgvector adds a vector type and distance operators to PostgreSQL. HNSW (hierarchical navigable small world) indexes build a layered graph of vectors so a query visits only a small fraction of rows to find approximate nearest neighbours, trading a little recall for large speed gains. Keeping vectors in PostgreSQL means embeddings are covered by the same backups, permissions, and transactions as the source rows, and Django's ORM can combine similarity with ordinary filters, at the cost of tuning index builds and query parameters yourself.
Edge cases / notes
- Version. Use a recent pgvector (0.7+ for HNSW improvements, 0.8+ for iterative scans); managed services may lag.
- halfvec halves storage for vectors where reduced precision is acceptable.
- Replicas can serve similarity queries to offload the primary.
- Re-embedding after a model change is a large batch job; plan it with a new column and a switch.
Next steps
- Connect the database properly with Connect Django to PostgreSQL in Production.
- Plan large table changes with Django Schema Changes on Large Tables.
FAQ
Can Django use pgvector?
Yes, with the pgvector Python package, which provides VectorField, distance functions, and index classes for Django.
Which pgvector index should I use?
HNSW for most production apps; IVFFlat for very large, mostly static datasets; no index for small tables.
Should embeddings be generated in the view?
No. Generate them in background tasks after commit, in batches.
Why do filtered vector queries return too few results?
Filters apply after the approximate search. Raise ef_search, use iterative scans, or partition data.