← Note tecniche

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.

~15 min Maggio 2026 Aus mehreren Projekten
TL;DR

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 min22 ms0.978
IVFFlat (lists=100)~2 min38 ms0.943
IVFFlat (lists=1000)~3 min14 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.