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.

Embeddings in PostgreSQLContent savedDjango modelCelery taskcalls embedding APIVectorFieldstored in the rowHNSW indexnearest neighboursEmbeddings live beside the data, generated in the background and searched with an index.
  1. Make sure the database supports pgvector (most managed PostgreSQL services do) and enable it in a migration with VectorExtension().
  2. pip install pgvector; add VectorField(dimensions=1536) (match your embedding model) to the model.
  3. Generate embeddings in Celery tasks, batched, never in the request.
  4. Create an HNSW index with HnswIndex(..., opclasses=["vector_cosine_ops"]), concurrently on existing large tables.
  5. Raise maintenance_work_mem for index builds and set hnsw.ef_search per query when needed.
  6. Order queries by CosineDistance and 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.

choosing a vector indexSearchUse whenNo indexexact, scans every rowunder ~10k rowsIVFFlatapproximate, needs data to trainbig, mostly static setsHNSWโœ“approximate, fast, good recallโœ“most production appsHNSW is the usual default: better speed and recall, larger and slower to builddjango-deployment.com
Match the index operator class to the distance you query with (cosine, L2, inner product).

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.

pgvector in productionExtension enabled by migrationprivileges confirmedDimensions match the embedding modelnullable columnEmbeddings generated in Celery, batchednever in requestsHNSW built concurrentlymaintenance_work_mem raisedFiltered queries tested for recallef_search or iterative scansMost pgvector problems are index builds and filtered recall, not the column itselfdjango-deployment.com
Watch database size: vectors and HNSW indexes grow storage and backup times.

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

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.

Keep going