← Engineering notes

Note 03 · Architektur · Pattern

pgvector next to the Django ORM — pattern and benchmark.

A separate vector database looks modern. For most mid-market use cases it's just overhead — second copy of the data, second backup, second permission model, second set of failure modes. We put vector search directly in Postgres via pgvector and access it through the Django ORM. This note explains why, how, and where the model hits its limits.

~15 min May 2026 Aus mehreren Projekten
TL;DR

For corpora up to ~5 million embeddings (768d–1024d) pgvector with an HNSW index on Postgres 16 is fast enough for sub-100ms recall@10. We use a Django ORM mixin that wraps embedding generation, indexing and cosine search in three lines of application code. We've recommended a separate Pinecone or Weaviate instance exactly once — on a multi-tenant system with > 50M embeddings and tenant-scoped filtering that overwhelmed pgvector indexes.

1 · Why not Pinecone / Weaviate / Qdrant

The dedicated vector DBs are technically excellent. They solve a real problem — if you have it. Most customers who hand us a RAG system don't:

  • Their corpus fits comfortably on a Postgres instance.
  • They already have Postgres in the stack — for auth, audit log, order data.
  • They want full-text filters (“only documents for client X from 2024”) together with vector search. Trivial in a single database; a joining nightmare across two.
  • They want one backup regime, one GDPR disclosure flow, one permission model.

Every extra database is an extra system that can fail, drift and needs to be maintained. We only recommend one when the data or workload genuinely requires it.

2 · HNSW vs IVFFlat — what we pick, and when

pgvector supports two index types for approximate nearest neighbor (ANN). The choice isn't academic — it has real consequences for latency, memory footprint and insertion performance.

Index Build time Query P95 Recall@10
HNSW~14 min22 ms0.978
IVFFlat (lists=100)~2 min38 ms0.943
IVFFlat (lists=1000)~3 min14 ms ⚠0.881

Numbers from a test corpus with 1.2M embeddings (768d, German business documents). For our workloads HNSW is almost always the right call — higher recall, robust latency, no tuning insanity. IVFFlat only becomes attractive when index build time is critical (frequent re-indexing) or when the corpus grows very large and query volume gets very high.

HNSW tuning we use

CREATE INDEX docs_emb_hnsw ON documents
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- Query-Zeit: höher = präziser, langsamer
SET hnsw.ef_search = 40;

m=16 / ef_construction=64 are the defaults — across benchmarks we haven't found a configuration measurably better for our corpus sizes. ef_search is the only knob we turn per use case: 20 for low-latency chat, 40 for standard RAG, 80 for “faithfulness is everything” (legal research).

3 · The ORM mixin we reuse

We've extracted a small Django mixin that lands in every project. It declares the embedding field, handles auto-generation on save, and exposes a similar-to method:

from django.db import models
from pgvector.django import VectorField, HnswIndex
from .embeddings import EmbeddedModelMixin

class Document(EmbeddedModelMixin, models.Model):
    title = models.CharField(max_length=400)
    body = models.TextField()
    tenant = models.ForeignKey('tenants.Tenant', on_delete=models.CASCADE)

    embedding = VectorField(dimensions=768, null=True, blank=True)
    embedding_source = "{title}\n\n{body}"  # Mixin liest das

    class Meta:
        indexes = [
            HnswIndex(name="doc_emb_hnsw",
                      fields=["embedding"],
                      m=16, ef_construction=64,
                      opclasses=["vector_cosine_ops"]),
            # Wichtig: Index auf tenant für scoped queries
            models.Index(fields=["tenant"]),
        ]

# Verwendung in einem View / Service
query = "Welche Mandanten haben offene Rechnungen aus 2024?"
results = Document.find_similar(
    query, tenant=request.user.tenant, top_k=8, ef_search=40
)

The mixin does three things:

  • In the post_save signal it generates the embedding for embedding_source, if empty.
  • It exposes find_similar() — applying tenant and date filters BEFORE the vector search (critical for multi-tenant performance).
  • Er fügt einen Änderungs-Tracker hinzu: ändert sich embedding_source, wird das Embedding bei Save neu generiert (nicht stillschweigend stale).

4 · Tenant-scoped filtering — the most common pitfall

Naive code looks like this:

# LANGSAM — wendet Filter NACH der Vektor-Suche an
Document.objects.order_by(
    L2Distance("embedding", query_emb)
).filter(tenant=current_tenant)[:8]

Das ist ein häufiger Fehler. Postgres macht erst ANN-Suche über alle Tenants, holt Top-N, filtert dann — bei einem System mit 50 Tenants und ungleicher Verteilung ist der Recall katastrophal. Korrekt ist:

# SCHNELL — Filter VOR der Vektor-Suche, mit Partial Index
Document.objects.filter(tenant=current_tenant).order_by(
    L2Distance("embedding", query_emb)
)[:8]

# Plus: Partial Index für häufige Tenants
CREATE INDEX docs_emb_tenant_acme ON documents
  USING hnsw (embedding vector_cosine_ops)
  WHERE tenant_id = 42;

Partial indexes per tenant are an investment that pays off quickly for large tenants. We generate them automatically above a per-tenant threshold of 100k documents.

5 · Where the model breaks

At a customer in the insurance industry we saw the clear case where pgvector was no longer enough:

  • Corpus > 50M embeddings, in a multi-tenant table.
  • Peak query volume > 200 req/s.
  • Strict sub-50ms latency requirement.
  • High-frequency updates (re-indexing every 4 hours).

Bei dieser Kombination wurde der HNSW-Index zum Insertion-Bottleneck und zur Query-Latency-Quelle. Wir haben dort tatsächlich eine separate Vektor-DB empfohlen (Qdrant in dem Fall) — aber als Read-Cache neben Postgres, nicht als Ersatz. Postgres bleibt source-of-truth, Qdrant wird inkrementell aus dem WAL-Stream aktualisiert.

6 · The rule of thumb

If you sit below two of the three values below, stay on pgvector:

  • Embeddings ≤ 5M
  • Query peaks ≤ 100 req/s
  • Latency SLA ≥ 100 ms P95

Das deckt nach unserer Erfahrung ungefähr 90% der RAG-Use-Cases im Mittelstand ab. Für die anderen 10% gibt es gute Gründe, die Architektur zu erweitern — aber bitte erst dann, wenn die Messung sie bestätigt.