Note 03 · Architektur · Pattern
pgvector accanto al Django ORM — pattern e benchmark.
Un database vettoriale separato sembra moderno. Per la maggior parte degli use-case Mittelstand è semplicemente overhead — seconda copia dei dati, secondo backup, seconda logica di permessi, secondi failure-mode. Mettiamo la ricerca vettoriale direttamente in Postgres con pgvector e ci accediamo dal Django ORM. Questa nota spiega perché, come e dove il modello incontra i suoi limiti.
Per corpora fino a ~5 milioni di embeddings (768d–1024d) pgvector con indice HNSW su Postgres 16 è abbastanza veloce per recall@10 sub-100ms. Usiamo un mixin Django ORM che incapsula generazione embedding, indicizzazione e cosine search in tre righe di codice applicativo. Un'istanza separata di Pinecone o Weaviate l'abbiamo raccomandata esattamente una volta — su un sistema multi-tenant con > 50 Mio embeddings e filtering tenant-scoped che ha sovraccaricato gli indici pgvector.
1 · Perché non Pinecone / Weaviate / Qdrant
I database vettoriali separati sono tecnicamente eccellenti. Risolvono un problema vero — se ce l'hai. La maggior parte dei clienti che ci affida un sistema RAG non ce l'ha:
- Il loro corpus sta comodamente su una istanza Postgres.
- Hanno già Postgres nello stack — per autenticazione, audit log, dati ordini.
- Vogliono filtri full-text („solo documenti del cliente X dal 2024”) contemporaneamente alla ricerca vettoriale. In un single-database è banale, in un'architettura multi-database è un incubo di join.
- Vogliono un unico regime di backup, una sola disclosure GDPR, una sola logica di permessi.
Ogni database in più è un sistema in più che può cadere, andare in drift e va manutenuto. Lo raccomandiamo solo se i dati o le caratteristiche del carico lo richiedono davvero.
2 · HNSW vs IVFFlat — cosa prendiamo e quando
pgvector supporta due tipi di indice per approximate nearest neighbor (ANN). La scelta non è accademica — ha impatti reali su latenza, consumo di memoria e performance in inserimento.
| Indice | Tempo di build | Query P95 | Recall@10 |
|---|---|---|---|
| HNSW | ~14 min | 22 ms | 0.978 |
| IVFFlat (lists=100) | ~2 min | 38 ms | 0.943 |
| IVFFlat (lists=1000) | ~3 min | 14 ms ⚠ | 0.881 |
Valori da un corpus di test con 1.2 Mio embeddings (768d, documenti aziendali tedeschi). HNSW per i nostri carichi è quasi sempre la scelta giusta — recall più alto, latenza robusta, niente follia di tuning. IVFFlat è attraente solo se il tempo di build dell'indice è critico (re-indicizzazione frequente) o se il corpus diventa estremamente grande e il volume di query molto alto.
Tuning HNSW che usiamo
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 sono i default — nei benchmark non abbiamo trovato configurazioni misurabilmente migliori per le nostre dimensioni di corpus. ef_search è l'unica manopola che giriamo per use-case: 20 per chat a bassa latenza, 40 per RAG standard, 80 per „la faithfulness è tutto” (ricerca giuridica).
3 · Il mixin ORM che riutilizziamo
Abbiamo estratto un piccolo mixin Django che finisce in ogni progetto. Dichiara il campo embedding, si occupa della generazione automatica al save e fornisce un metodo "simile-a":
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
)
Il mixin fa tre cose:
- Nel signal post_save genera l'embedding di embedding_source, se vuoto.
- Espone find_similar() — applica filtri tenant e di data PRIMA della ricerca vettoriale (critico per le performance in multi-tenant).
- Er fügt einen Änderungs-Tracker hinzu: ändert sich embedding_source, wird das Embedding bei Save neu generiert (nicht stillschweigend stale).
4 · Filtering tenant-scoped — l'inciampo più frequente
Il codice ingenuo è così:
# 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;
I partial index per tenant sono un investimento che si ripaga in fretta sui tenant grandi. Li generiamo automaticamente sopra una soglia di 100k documenti per tenant.
5 · Dove il modello si rompe
Presso un cliente del settore assicurativo abbiamo visto il caso chiaro in cui pgvector non bastava più:
- Corpus > 50 milioni di embeddings, in tabella multi-tenant.
- Volume di query nei picchi > 200 req/s.
- Requisito stretto di latenza sub-50ms.
- Update ad alta frequenza (re-indicizzazione ogni 4 ore).
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 · La regola spannometrica
Se sta sotto due dei tre valori seguenti, resti su pgvector:
- Embeddings ≤ 5 Mio
- Picchi di query ≤ 100 req/s
- SLA di latenza ≥ 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.