← Engineering Notes

Note 03 · Architektur · Pattern

pgvector neben dem Django-ORM — Pattern und Benchmark.

Eine separate Vektor-Datenbank wirkt modern. Für die meisten Mittelstands-Use-Cases ist sie schlicht Overhead — eine zweite Datenkopie, zweites Backup, zweite Permission-Logik, zweite Failure-Modes. Wir legen Vektor-Suche direkt in Postgres mit pgvector und greifen über das Django-ORM zu. Diese Note erklärt, warum, wie und wo das Modell an Grenzen stößt.

~15 min Mai 2026 Aus mehreren Projekten
TL;DR

Für Korpora bis ~5 Millionen Embeddings (768d–1024d) ist pgvector mit HNSW-Index in Postgres 16 schnell genug für Sub-100ms-Recall@10. Wir verwenden einen Django-ORM-Mixin, der Embedding-Generierung, Indexierung und Cosine-Search in drei Zeilen Anwendungscode kapselt. Eine separate Pinecone- oder Weaviate-Instanz haben wir genau einmal empfohlen — bei einem Multi-Tenant-System mit > 50 Mio Embeddings und tenant-scoped Filtering, das pgvector-Indexes überfordert hat.

1 · Warum nicht Pinecone / Weaviate / Qdrant

Die separaten Vektor-DBs sind technisch hervorragend. Sie lösen ein echtes Problem — wenn man es hat. Die meisten Kunden, die uns ein RAG-System geben, haben es nicht:

  • Ihr Korpus passt locker auf eine Postgres-Instanz.
  • Sie haben bereits Postgres im Stack — für Authentifizierung, Audit-Log, Bestelldaten.
  • Sie wollen Volltext-Filter („nur Dokumente vom Mandanten X aus 2024”) gleichzeitig mit Vektor-Suche. Das ist in einer Single-Database trivial, in einer Multi-Database-Architektur eine Joining-Nightmare.
  • Sie wollen ein einziges Backup-Regime, eine einzige DSGVO-Auskunft, eine einzige Permission-Logik.

Jede zusätzliche Datenbank ist ein zusätzliches System, das ausfallen, driften und gepflegt werden muss. Wir empfehlen sie nur, wenn die Daten oder Workload-Eigenschaften sie wirklich erfordern.

2 · HNSW vs IVFFlat — was wir nehmen, und wann

pgvector unterstützt zwei Index-Typen für approximate nearest neighbor (ANN). Die Wahl ist nicht akademisch — sie hat reale Auswirkungen auf Latenz, Speicherverbrauch und Insertion-Performance.

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

Werte aus einem Test-Corpus mit 1.2 Mio Embeddings (768d, deutsche Geschäftsdokumente). HNSW ist für unsere Workloads fast immer die richtige Wahl — höhere Recall, robuste Latenz, kein Tuning-Wahnsinn. IVFFlat ist nur dann attraktiv, wenn Index-Build-Zeit kritisch ist (häufiges Re-Indexing) oder der Korpus extrem groß und query-volume sehr hoch wird.

HNSW-Tuning, das wir verwenden

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 sind die Defaults — wir haben in Benchmarks keine Konfiguration gefunden, die für unsere Korpus-Größen messbar besser wäre. ef_search ist der einzige Knopf, an dem wir pro Use-Case drehen: 20 für niedrig-Latenz-Chat, 40 für Standard-RAG, 80 für „Faithfulness ist alles” (juristische Recherche).

3 · Der ORM-Mixin, den wir wiederverwenden

Wir haben einen kleinen Django-Mixin extrahiert, der in jedem Projekt landet. Er deklariert das Embedding-Feld, kümmert sich um Auto-Generierung beim Save und liefert eine ähnlich-zu-Methode:

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
)

Der Mixin macht drei Dinge:

  • Im post_save Signal generiert er das Embedding für embedding_source, falls leer.
  • Er stellt find_similar() bereit — wendet Tenant- und Datums-Filter VOR der Vektor-Suche an (kritisch für Performance bei Multi-Tenant).
  • 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 — der häufigste Stolperstein

Naiver Code sieht so aus:

# 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 pro Tenant sind eine Investition, die sich bei großen Tenants schnell auszahlt. Wir generieren sie automatisch ab einer Tenant-Schwelle von 100k Dokumenten.

5 · Wo das Modell bricht

Bei einem Kunden in der Versicherungsbranche haben wir den klaren Fall gesehen, in dem pgvector nicht mehr genügte:

  • Korpus > 50 Millionen Embeddings, in Multi-Tenant-Tabelle.
  • Query-Volume in Spitzen > 200 req/s.
  • Strict-Sub-50ms-Latenz-Anforderung.
  • Hochfrequente Updates (Re-Indexing alle 4 Stunden).

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 · Die Faustregel

Wenn Sie unter zwei der drei folgenden Werte liegen, bleiben Sie bei pgvector:

  • Embeddings ≤ 5 Mio
  • Query-Spitzen ≤ 100 req/s
  • Latenz-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.