Similarity Search Avanzata: Algoritmi e Ottimizzazione in PostgreSQL
Quando hai un database con milioni di vettori e devi trovare i 10 più simili a una query in meno di 50 millisecondi, la ricerca di similarità diventa una sfida ingegneristica seria. La ricerca esatta (brute-force) e precisa ma non scala: confrontare ogni vettore con ogni query ha complessità O(n*d) dove n e il numero di vettori e d la dimensionalità. Con 10 milioni di vettori a 1536 dimensioni, ogni query richiederebbe 15 miliardi di operazioni floating point.
La soluzione sono gli algoritmi ANN (Approximate Nearest Neighbor): rinunciano alla garanzia di trovare il risultato esatto per ottenere risposte in O(log n) o addirittura O(1) ammortizzato, con una precisione pratica del 95-99%. PostgreSQL con pgvector implementa i due algoritmi ANN più diffusi: HNSW e IVFFlat.
In questo articolo analizziamo come funzionano questi algoritmi in profondità, quando usare ciascuno, come ottimizzare le query di similarity search in PostgreSQL, come combinare la ricerca vettoriale con i filtri sui metadati, e le tecniche avanzate come MMR per risultati diversificati.
Panoramica della Serie
| # | Articolo | Focus |
|---|---|---|
| 1 | pgvector | Installazione, operatori, indexing |
| 2 | Embeddings in Profondità | Modelli, distanze, generazione |
| 3 | RAG con PostgreSQL | Pipeline RAG end-to-end |
| 4 | Sei qui - Similarity Search | Algoritmi e ottimizzazione |
| 5 | HNSW e IVFFlat | Strategie di indicizzazione avanzate |
| 6 | RAG in Produzione | Scalabilità e performance |
Cosa Imparerai
- Differenza tra exact search e approximate nearest neighbor (ANN)
- Come funziona HNSW internamente: grafi navigabili small world
- Come funziona IVFFlat: clustering e probe search
- Quando usare HNSW vs IVFFlat vs brute force
- Ottimizzazione delle query: parametri ef_search e probes
- Filtri ibridi: combinare metadata filtering e vector search
- Operatori di distanza: cosine, L2 e inner product a confronto
- Benchmarking e misurazione della recall con codice Python
- Maximal Marginal Relevance (MMR) per risultati diversificati
- Semantic search vs keyword search: quando usare cosa
Ricerca Esatta vs Ricerca Approssimata
Brute Force (Exact Search)
La ricerca esatta confronta la query con ogni singolo vettore nel database. Garantisce il 100% di recall ma ha complessità lineare. E la scelta giusta solo per dataset piccoli o quando la precisione assoluta e un requisito non negoziabile:
-- Ricerca esatta: nessun indice utilizzato, scan completo
-- Utile per dataset piccoli (< 100K vettori) o quando la precisione e critica
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec -- scansione sequenziale su tutto il dataset
LIMIT 10;
-- Per forzare il brute force anche quando esiste un indice ANN:
SET enable_indexscan = off;
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10;
SET enable_indexscan = on; -- ripristina
-- Benchmark comparativo (dataset reale su SSD NVMe):
-- Dataset: 1M vettori, 1536 dim, PostgreSQL 16, 32GB RAM, 16 CPU
-- Brute force (seq scan): ~2000ms per query -- 2 secondi!
-- HNSW (ef_search=40): ~10ms per query -- 200x più veloce
-- HNSW (ef_search=100): ~25ms per query -- 80x più veloce
-- IVFFlat (probes=50): ~28ms per query -- 71x più veloce
-- Dataset 100K vettori (brute force accettabile):
-- Brute force: ~50ms per query -- accettabile
-- HNSW: ~3ms per query -- ancora meglio se disponibile
ANN: Il Trade-off Recall/Performance
-- Verifica se la query sta usando l'indice ANN
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;
-- Output desiderato (usa l'indice HNSW):
-- Index Scan using documents_embedding_hnsw on documents
-- Index Cond: (embedding <=> '...'::vector)
-- Limit: 10
-- Buffers: shared hit=347 (tutto da cache = ottimo!)
-- -> Execution Time: 8.3 ms
-- Output indesiderato (brute force):
-- Seq Scan on documents
-- Sort Key: (embedding <=> '...'::vector) -- ordina dopo scan completo
-- Buffers: shared hit=234 read=8921 (molti disk read = lento!)
-- -> Execution Time: 2341 ms
-- MOTIVO per cui PostgreSQL non usa l'indice:
-- 1. LIMIT troppo grande (>10% delle righe per tabelle piccole)
-- 2. Le statistiche sono vecchie: ANALYZE documents;
-- 3. L'indice non esiste: verifica con \d documents
-- 4. enable_indexscan = off accidentalmente
Operatori di Distanza in pgvector
pgvector supporta tre operatori di distanza, ognuno appropriato per diversi tipi di embedding. La scelta dell'operatore sbagliato può ridurre significativamente la qualità della ricerca.
| Operatore | Tipo | Range | Quando Usare | Supporto indice |
|---|---|---|---|---|
<=> |
Cosine distance | [0, 2] | Text embeddings (OpenAI, Sentence Transformers) - DEFAULT | HNSW, IVFFlat (vector_cosine_ops) |
<-> |
Euclidean (L2) distance | [0, inf) | Vettori con magnitudine significativa (immagini, audio) | HNSW, IVFFlat (vector_l2_ops) |
<#> |
Negative inner product | (-inf, 0] | Embedding pre-normalizzati, max inner product search | HNSW, IVFFlat (vector_ip_ops) |
-- Cosine distance (più comune per text embeddings):
SELECT id, content,
embedding <=> query_vec AS cosine_distance,
1 - (embedding <=> query_vec) AS cosine_similarity
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 5;
-- L2 distance (euclidea, per immagini/audio):
SELECT id, content,
embedding <-> query_vec AS l2_distance
FROM documents
ORDER BY embedding <-> query_vec
LIMIT 5;
-- Inner product (per embedding normalizzati):
SELECT id, content,
(embedding <#> query_vec) * -1 AS inner_product -- negato per avere più alto = più simile
FROM documents
ORDER BY embedding <#> query_vec -- ORDER BY funziona perchè <#> e negativo
LIMIT 5;
-- Crea indice con l'operatore corretto (DEVE corrispondere alla query!):
-- Per cosine distance (default per text):
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- Per L2 distance:
CREATE INDEX ON documents USING hnsw (embedding vector_l2_ops);
-- Per inner product:
CREATE INDEX ON documents USING hnsw (embedding vector_ip_ops);
-- ERRORE COMUNE: indice con vector_cosine_ops ma query con <->
-- Risultato: PostgreSQL ignora l'indice e fa brute force!
HNSW: Hierarchical Navigable Small World
HNSW e l'algoritmo ANN più popolare nel 2025-2026. Si basa sulla teoria dei grafi small world: strutture dove ogni nodo e raggiungibile da qualsiasi altro in pochi passi (il fenomeno dei "6 gradi di separazione"). La struttura gerarchica permette di navigare rapidamente verso la zona di spazio vettoriale più rilevante, usando i livelli superiori come "autostrade" e il livello base per la ricerca di precisione.
Come Funziona HNSW
HNSW costruisce una struttura a livelli gerarchica:
- Livello 0 (base): Tutti i vettori, connessi ai loro vicini più prossimi. Grafo denso.
- Livelli superiori (1, 2, ...): Subset progressivamente più piccoli di vettori. Grafi sparsi per navigazione veloce.
- Entry point: La ricerca inizia sempre al livello più alto (meno vettori) e scende greedy verso il target.
-- Struttura HNSW concettuale:
--
-- Livello 2: o-----------o-----------o (pochi nodi, salti lunghi - navigazione veloce)
-- \ | /
-- Livello 1: o---o---o---o---o---o---o (navigazione intermedia)
-- \ | | /
-- Livello 0: o-o-o-o-o-o-o-o-o-o-o-o (tutti i vettori, ricerca precisa)
--
-- Algoritmo di ricerca per query Q:
-- 1. Inizia dal livello più alto con un entry point fisso
-- 2. Greedy descent: scendi verso il nodo più vicino a Q a ogni livello
-- 3. Usa quel nodo come entry point per il livello inferiore
-- 4. Ripeti fino al livello 0
-- 5. Al livello 0: beam search con ef_search candidati (candidati esplorati)
-- 6. Restituisci i top-k tra i candidati esplorati
-- Complessità teorica:
-- Build: O(n * log(n)) - sub-lineare nel numero di vettori
-- Query: O(log(n)) - logaritmica! vs O(n) del brute force
-- Creazione indice HNSW (valori ottimali per la maggior parte dei casi):
CREATE INDEX documents_hnsw_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (
m = 16, -- connessioni per nodo (default 16, range 4-64)
ef_construction = 64 -- candidati durante costruzione (default 64, range 16-200)
);
-- Impostazione ef_search (runtime, senza rebuild):
SET hnsw.ef_search = 60; -- valore raccomandato per produzione RAG
Parametri HNSW: Riepilogo Interattivo
| Parametro | Descrizione | Default | Range | Effetto aumento |
|---|---|---|---|---|
m |
Max connessioni per nodo per livello | 16 | 4-64 | Recall migliore, più memoria, build più lento |
ef_construction |
Candidati considerati durante la build | 64 | 16-200 | Recall potenziale migliore, build molto più lento |
ef_search |
Candidati considerati durante la query (runtime) | 40 | 1-ef_construction | Recall migliore, query più lenta |
-- Configurazione ef_search per query (parametro runtime)
SET hnsw.ef_search = 40; -- default, buon equilibrio
-- Alta precisione (RAG enterprise, medico, legale):
SET hnsw.ef_search = 100;
-- Alta velocità (autocomplete, recommendation):
SET hnsw.ef_search = 20;
-- Benchmark indicativo (1M vettori, 1536 dim, m=16, ef_construction=64):
-- ef_search=20: ~5ms/query, recall@10 ~85%
-- ef_search=40: ~10ms/query, recall@10 ~92%
-- ef_search=100: ~25ms/query, recall@10 ~97%
-- ef_search=200: ~50ms/query, recall@10 ~99%
-- Imposta a livello di sessione (non persiste tra sessioni):
SELECT set_config('hnsw.ef_search', '100', false);
-- Imposta per la sessione corrente (equivalente):
SET hnsw.ef_search = 100;
IVFFlat: Inverted File with Flat Compression
IVFFlat usa un approccio diverso: divide lo spazio vettoriale in cluster (celle) tramite K-means e durante una query cerca solo nei cluster più promettenti. E più semplice di HNSW ma richiede dati esistenti per fare K-means e degrada più rapidamente con gli insert.
Come Funziona IVFFlat
- Fase di training: K-means clustering divide i vettori in
listscentroidi - Fase di build: Ogni vettore viene assegnato al cluster (centroide) più vicino
- Query: Trova i
probescluster più vicini alla query, poi cerca esattamente dentro quei cluster
-- IVFFlat richiede dati prima di creare l'indice
-- (ha bisogno di fare K-means clustering)
-- PREREQUISITO: la tabella deve avere almeno qualche centinaio di righe
-- Regola pratica: lists = sqrt(n_rows), con minimo 100
-- Creazione indice IVFFlat
CREATE INDEX documents_ivfflat_idx
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (
lists = 100 -- numero di cluster (default 100, raccomandato: sqrt(n_rows))
);
-- Per 1M righe: lists = sqrt(1000000) = 1000
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);
-- Configurazione probes (quanti cluster cercare a query time)
SET ivfflat.probes = 10; -- default, cerca 10 cluster su 1000
-- Alta precisione:
SET ivfflat.probes = 50; -- cerca 50/1000 cluster = 5% dello spazio
-- Benchmark indicativo (1M vettori, lists=1000):
-- probes=5: ~3ms/query, recall@10 ~72%
-- probes=10: ~6ms/query, recall@10 ~82%
-- probes=30: ~18ms/query, recall@10 ~94%
-- probes=100: ~60ms/query, recall@10 ~99%
-- IVFFlat vs HNSW a parita di recall ~92%:
-- IVFFlat (probes=50): ~28ms - più lento!
-- HNSW (ef_search=40): ~10ms - più veloce
-- Conclusione: per recall alta, HNSW e solitamente più efficiente
HNSW vs IVFFlat: Guida alla Scelta
| Caratteristica | HNSW | IVFFlat |
|---|---|---|
| Query latency | Più veloce (spesso 2-5x a parita recall) | Più lenta a high recall |
| Build time | Più lento (K-means non serve ma build e più complessa) | Molto più veloce (~4x) |
| Memoria indice | Maggiore (~2-4x) | Minore |
| Recall a parita di latency | Migliore (generalmente) | Leggermente inferiore |
| Insert incrementale | Ottimo (nessun re-training) | Degrada: cluster non si riaggiustano |
| Dati richiesti per build | Nessuno (può iniziare vuoto) | Richiede dati esistenti (K-means) |
| Dataset tipico | Dataset crescenti, alta precisione, RAG | Dataset statici, build veloce prioritaria |
Regola Pratica per la Scelta
- Usa HNSW nella maggior parte dei casi: migliore recall, query più veloci, gestisce gli insert bene. Questa e la scelta giusta per il 90% dei sistemi RAG.
- Usa IVFFlat quando: hai un dataset di miliardi di vettori e la memoria per HNSW e insufficiente, o quando hai bisogno di ricostruire l'indice frequentemente su dati che cambiano completamente.
- Usa brute force (nessun indice) quando: hai meno di 50K vettori, o hai bisogno di 100% recall garantito (es. ricerca legale, compliance).
Ottimizzazione delle Query con Filtri Ibridi
Uno dei problemi più comuni in produzione e la combinazione di filtri sui metadati con la ricerca vettoriale. pgvector 0.7+ gestisce questo con l'iterative scan, ma e importante capire come strutturare le query correttamente per evitare il problema del post-filtering.
Il Problema del Post-Filtering e la Soluzione
-- SBAGLIATO: post-filtering - l'ANN trova i top-k globali, poi filtra
-- Se i top-k risultati ANN non soddisfano il filtro, ottieni meno di k risultati!
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10
-- Il problema: se questi 10 risultati non soddisfano source_type='pdf',
-- non ottieni 10 risultati PDF ma potenzialmente 0!
-- CORRETTO: pre-filtering in pgvector con iterative scan
-- pgvector 0.7+ supporta indexed scan con filtri WHERE
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf' -- pre-filter: applicato PRIMA dell'ANN
AND language = 'en'
ORDER BY embedding <=> query_vec -- ANN su subset filtrato
LIMIT 10;
-- EXPLAIN verifica che usi l'indice con il filtro:
EXPLAIN (ANALYZE, FORMAT TEXT)
SELECT id, content, embedding <=> '[...]'::vector AS dist
FROM documents
WHERE source_type = 'pdf'
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Dovrebbe mostrare: "Index Scan using ... with Filter: (source_type = 'pdf')"
-- Se vedi Seq Scan, il filtro potrebbe ridurre troppo il dataset
-- In quel caso, un indice parziale e la soluzione migliore
Partial Indexes per Filtri Frequenti
-- Se filtri sempre per source_type, crea un indice parziale dedicato
-- Vantaggi: molto più piccolo e veloce dell'indice globale
CREATE INDEX documents_hnsw_pdf
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE source_type = 'pdf'; -- solo i documenti PDF
CREATE INDEX documents_hnsw_recent
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE created_at > NOW() - INTERVAL '30 days'; -- solo documenti recenti
-- Query che sfruttano automaticamente gli indici parziali
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf' -- attiva documents_hnsw_pdf automaticamente
ORDER BY embedding <=> query_vec
LIMIT 10;
-- Confronto dimensioni: indice parziale vs totale
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
ROUND(pg_relation_size(indexrelid)::numeric /
(SELECT pg_relation_size(indexrelid) FROM pg_stat_user_indexes
WHERE indexname = 'documents_hnsw_total') * 100, 1) AS pct_of_full
FROM pg_stat_user_indexes
JOIN pg_class ON pg_class.relname = pg_stat_user_indexes.indexname
WHERE tablename = 'documents';
-- documents_hnsw_total: 2.1 GB (100%)
-- documents_hnsw_pdf: 487 MB (23% - solo i PDF)
Query con Threshold di Similarità
-- Filtra per distanza minima: evita risultati irrilevanti
-- cosine distance < 0.4 significa cosine similarity > 0.6
SELECT
id,
source_path,
content,
1 - (embedding <=> query_vec::vector) AS similarity,
embedding <=> query_vec::vector AS distance
FROM documents
WHERE
embedding <=> query_vec::vector < 0.4 -- max distance (= min 60% similarity)
ORDER BY embedding <=> query_vec::vector
LIMIT 10;
-- Conversione distanza/similarità per cosine:
-- cosine_distance = 1 - cosine_similarity
-- distance 0.0 = similarity 1.0 (vettori identici)
-- distance 0.2 = similarity 0.8 (molto simili)
-- distance 0.4 = similarity 0.6 (sufficientemente simili per RAG)
-- distance 0.7 = similarity 0.3 (probabilmente non rilevante)
-- distance 1.0 = similarity 0.0 (non correlati)
-- distance 2.0 = similarity -1.0 (opposti)
-- Threshold raccomandati per use case:
-- RAG documenti tecnici: < 0.35 (similarity > 0.65)
-- FAQ answering: < 0.30 (similarity > 0.70)
-- Product recommendation: < 0.50 (similarity > 0.50)
-- Duplicate detection: < 0.10 (similarity > 0.90)
Semantic Search vs Similarity Search
I termini sono spesso usati in modo intercambiabile, ma c'è una distinzione importante che influenza l'architettura del sistema:
| Tipo | Obiettivo | Esempio | Metrica |
|---|---|---|---|
| Similarity Search | Trovare vettori vicini a un vettore dato | Immagini simili a questa immagine | Distanza L2, cosine |
| Semantic Search | Trovare documenti con significato simile a una query testuale | "Come si installa PostgreSQL?" trova guide senza quella frase esatta | Cosine similarity su text embeddings |
| Keyword Search | Match esatto di parole chiave | "PostgreSQL 16" trova solo documenti con quella stringa | TF-IDF, BM25 |
| Hybrid Search | Combina semantica e keyword | Bilancia rilevanza semantica e match esatto di termini tecnici | RRF, weighted sum |
Implementazione Semantic Search Completa
import psycopg2
from openai import OpenAI
client = OpenAI()
def semantic_search(
conn,
query: str,
top_k: int = 10,
source_type: str = None,
min_similarity: float = 0.6,
include_metadata: bool = True
) -> list[dict]:
"""
Ricerca semantica con filtri opzionali e threshold di qualità.
Args:
conn: Connessione psycopg2 a PostgreSQL
query: La domanda o testo da cercare
top_k: Numero di risultati da restituire
source_type: Filtra per tipo documento ('pdf', 'md', 'html')
min_similarity: Soglia minima di similarità coseno (0.0-1.0)
include_metadata: Se includere il campo metadata JSONB
Returns:
Lista di dict con id, source_path, content, similarity, metadata
"""
# 1. Genera embedding della query
resp = client.embeddings.create(
input=[query.replace("\n", " ")],
model="text-embedding-3-small"
)
query_vec = resp.data[0].embedding
max_distance = 1 - min_similarity # converti similarity -> distance
# 2. Costruisci query SQL dinamica con filtri opzionali
params = [query_vec, max_distance]
filter_clauses = ["embedding <=> %s::vector < %s"]
if source_type:
filter_clauses.append("source_type = %s")
params.append(source_type)
where = " AND ".join(filter_clauses)
metadata_col = "metadata" if include_metadata else "NULL::jsonb"
sql = f"""
SELECT
id,
source_path,
source_type,
chunk_index,
title,
content,
1 - (embedding <=> %s::vector) AS similarity,
{metadata_col} AS metadata
FROM documents
WHERE {where}
ORDER BY embedding <=> %s::vector
LIMIT %s
"""
params_final = [query_vec] + params + [query_vec, top_k]
with conn.cursor() as cur:
cur.execute(sql, params_final)
rows = cur.fetchall()
return [
{
"id": r[0],
"source_path": r[1],
"source_type": r[2],
"chunk_index": r[3],
"title": r[4],
"content": r[5],
"similarity": round(r[6], 4),
"metadata": r[7]
}
for r in rows
]
# Uso
conn = psycopg2.connect("postgresql://postgres:pass@localhost/vectordb")
results = semantic_search(
conn,
query="Come ottimizzare query PostgreSQL per dataset grandi",
top_k=5,
source_type="pdf",
min_similarity=0.65
)
for r in results:
print(f"[{r['similarity']:.3f}] {r['title']} - {r['content'][:100]}...")
Hybrid Search: Combinare Vector e Full-Text
Una delle grandi forze di PostgreSQL per il RAG e la possibilità di combinare in una sola query la ricerca semantica (vector) con la ricerca full-text classica (BM25-like). Questo e particolarmente utile per query che contengono termini tecnici precisi come nomi propri, codici, versioni software.
-- Hybrid search puro SQL: vector + full-text con Reciprocal Rank Fusion (RRF)
-- RRF Score = sum(1/(k + rank)) per ogni lista risultati
WITH vector_results AS (
-- Ricerca semantica: top 20 per similarità vettoriale
SELECT
id, content, source_path,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS v_rank,
1 - (embedding <=> $1::vector) AS vector_score
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
fts_results AS (
-- Ricerca full-text: top 20 per rilevanza ts_rank
SELECT
id, content, source_path,
ROW_NUMBER() OVER (
ORDER BY ts_rank(to_tsvector('italian', content),
plainto_tsquery('italian', $2)) DESC
) AS f_rank,
ts_rank(to_tsvector('italian', content),
plainto_tsquery('italian', $2)) AS fts_score
FROM documents
WHERE to_tsvector('italian', content) @@ plainto_tsquery('italian', $2)
LIMIT 20
),
rrf_combined AS (
-- RRF fusion: combina i due rank
SELECT
COALESCE(v.id, f.id) AS id,
COALESCE(v.content, f.content) AS content,
COALESCE(v.source_path, f.source_path) AS source_path,
-- Peso: 70% vector + 30% full-text
COALESCE(0.7 / (60.0 + v.v_rank), 0) +
COALESCE(0.3 / (60.0 + f.f_rank), 0) AS rrf_score,
v.vector_score,
f.fts_score
FROM vector_results v
FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT id, content, source_path, ROUND(rrf_score::numeric, 6) AS score
FROM rrf_combined
ORDER BY rrf_score DESC
LIMIT 5;
Benchmarking: Misurare la qualità della Ricerca
import time
import psycopg2
from statistics import mean, quantiles
def benchmark_vector_search(conn, query_vectors: list, config: dict) -> dict:
"""
Esegui benchmark di latenza su un set di query vettoriali.
Args:
conn: Connessione PostgreSQL
query_vectors: Lista di vettori query (1536 dimensioni)
config: Dict con ef_search o probes da impostare
Returns:
Dict con metriche di latenza (p50, p95, p99, mean)
"""
# Imposta parametri ANN per questo benchmark
with conn.cursor() as cur:
if "ef_search" in config:
cur.execute(f"SET hnsw.ef_search = {config['ef_search']}")
if "probes" in config:
cur.execute(f"SET ivfflat.probes = {config['probes']}")
latencies = []
for query_vec in query_vectors:
start = time.perf_counter()
with conn.cursor() as cur:
cur.execute("""
SELECT id, embedding <=> %s::vector AS dist
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT 10
""", (query_vec, query_vec))
cur.fetchall()
elapsed_ms = (time.perf_counter() - start) * 1000
latencies.append(elapsed_ms)
p_values = quantiles(latencies, n=100)
return {
"config": config,
"n_queries": len(query_vectors),
"p50_ms": round(p_values[49], 2),
"p95_ms": round(p_values[94], 2),
"p99_ms": round(p_values[98], 2),
"mean_ms": round(mean(latencies), 2),
"min_ms": round(min(latencies), 2),
"max_ms": round(max(latencies), 2)
}
# Confronta diverse configurazioni HNSW
configs = [
{"name": "HNSW ef=20 (fast)", "ef_search": 20},
{"name": "HNSW ef=40 (default)", "ef_search": 40},
{"name": "HNSW ef=100 (precise)", "ef_search": 100},
]
# Usa query di test reali (embedding generati con lo stesso modello dei documenti)
test_queries = [...] # lista di vettori 1536-dim
print(f"Benchmark con {len(test_queries)} query di test:")
for cfg in configs:
result = benchmark_vector_search(conn, test_queries[:100], cfg)
print(f"{cfg['name']}:")
print(f" p50={result['p50_ms']}ms, p95={result['p95_ms']}ms, p99={result['p99_ms']}ms")
Recall Measurement: Verificare la qualità ANN
def measure_recall_at_k(conn, query_vectors: list, k: int = 10) -> float:
"""
Misura Recall@K confrontando ANN con brute force.
Recall@K = (risultati ANN corretti) / k
Un Recall@10 di 0.95 significa che l'ANN trova
9.5 dei 10 risultati esatti su media.
Nota: usa un campione di 50-100 query per stabilità statistica.
"""
total_recall = 0.0
for query_vec in query_vectors:
# Risultati esatti (brute force) - ground truth
with conn.cursor() as cur:
cur.execute("SET enable_indexscan = off")
cur.execute("""
SELECT id FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, k))
exact_ids = set(r[0] for r in cur.fetchall())
cur.execute("SET enable_indexscan = on")
# Risultati ANN (con indice HNSW/IVFFlat)
with conn.cursor() as cur:
cur.execute("""
SELECT id FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, k))
ann_ids = set(r[0] for r in cur.fetchall())
# Recall = overlap / k
overlap = len(exact_ids & ann_ids)
total_recall += overlap / k
avg_recall = total_recall / len(query_vectors)
print(f"Recall@{k}: {avg_recall:.4f} ({avg_recall*100:.1f}%)")
return avg_recall
# Target di recall per diversi use case:
# Recall@10 > 0.90 per RAG generale
# Recall@10 > 0.95 per RAG enterprise (medico, legale)
# Recall@10 > 0.85 accettabile per autocomplete/recommendation
# Con HNSW m=16, ef_construction=64, ef_search=40: tipicamente 0.92-0.95
# Con HNSW m=16, ef_construction=64, ef_search=100: tipicamente 0.97-0.99
Ottimizzazione Avanzata: Query Planning
-- 1. Statistiche aggiornate: fondamentale per buone query plans
ANALYZE documents;
-- 2. Verifica dimensione e statistiche dell'indice HNSW
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS times_used,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched,
ROUND(idx_tup_fetch::numeric / NULLIF(idx_scan, 0), 1) AS avg_tuples_per_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
ORDER BY idx_scan DESC;
-- 3. Parametri di memoria per performance ottimale
SET maintenance_work_mem = '512MB'; -- per BUILD dell'indice HNSW (temporaneo)
SET work_mem = '64MB'; -- per query sort operations
-- 4. Parallel query per dataset grandi
SET max_parallel_workers_per_gather = 4; -- usa 4 worker per query
-- 5. Verifica che shared_buffers sia adeguato
-- L'indice HNSW deve stare in memoria per performance ottimale
SHOW shared_buffers;
-- Regola pratica: shared_buffers = 25% della RAM
-- pg_relation_size(indice HNSW) deve essere << shared_buffers
-- 6. Monitora cache hit ratio per la tabella
SELECT
relname,
heap_blks_read AS disk_reads,
heap_blks_hit AS cache_hits,
ROUND(heap_blks_hit::numeric / NULLIF(heap_blks_read + heap_blks_hit, 0) * 100, 2)
AS cache_hit_pct
FROM pg_statio_user_tables
WHERE relname = 'documents';
-- Target: cache_hit_pct > 95% per performance ottimale
-- 7. Verifica indici non utilizzati (candidati per DROP):
SELECT indexname, idx_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
AND idx_scan = 0
AND indexname NOT LIKE '%pkey%'; -- escludi primary keys
Maximal Marginal Relevance (MMR)
Un problema comune nella similarity search e la ridondanza dei risultati: i top-k chunk possono essere tutti molto simili tra loro, coprendo la stessa informazione. Questo riduce la qualità del contesto RAG perchè il modello riceve le stesse informazioni ripetute. MMR bilancia rilevanza e diversità selezionando progressivamente il prossimo chunk che e rilevante per la query MA diverso dai già selezionati.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def maximal_marginal_relevance(
query_vec: list[float],
candidate_vecs: list[list[float]],
candidate_data: list[dict],
k: int = 5,
lambda_param: float = 0.5
) -> list[dict]:
"""
MMR: seleziona k risultati bilanciando rilevanza e diversità.
Algoritmo:
1. Seleziona sempre il candidato più rilevante per la query
2. Per ogni successivo candidato, usa MMR score che penalizza
la ridondanza rispetto ai già selezionati
lambda_param:
1.0 = solo rilevanza (equivale a top-k standard)
0.0 = solo diversità (massima varieta)
0.5 = bilanciamento ottimale per RAG (default)
0.6 = leggermente più rilevanza rispetto diversità
Returns:
Lista di k candidati diversificati e rilevanti
"""
query_array = np.array(query_vec).reshape(1, -1)
candidate_array = np.array(candidate_vecs)
# Similarità query-candidato (rilevanza)
query_sims = cosine_similarity(query_array, candidate_array)[0]
selected = []
selected_indices = []
remaining_indices = list(range(len(candidate_vecs)))
while len(selected) < k and remaining_indices:
if not selected:
# Prima iterazione: scegli il più rilevante
best_idx = int(np.argmax(query_sims))
else:
# Iterazioni successive: MMR score
selected_array = candidate_array[selected_indices]
mmr_scores = []
for i in remaining_indices:
relevance = query_sims[i]
# Massima similarità con i già selezionati (ridondanza)
max_redundancy = float(np.max(
cosine_similarity(candidate_array[i:i+1], selected_array)[0]
))
# MMR bilancia rilevanza e ridondanza
mmr = lambda_param * relevance - (1 - lambda_param) * max_redundancy
mmr_scores.append((i, mmr))
best_idx = max(mmr_scores, key=lambda x: x[1])[0]
selected.append(candidate_data[best_idx])
selected_indices.append(best_idx)
remaining_indices.remove(best_idx)
return selected
# Uso con risultati PostgreSQL
# Prima recupera più candidati (top-20), poi applica MMR per top-5 diversificati
chunks_with_vecs = searcher.vector_search_with_embeddings(query, top_k=20)
embeddings = [c['embedding'] for c in chunks_with_vecs]
data = [{'id': c['id'], 'content': c['content']} for c in chunks_with_vecs]
diverse_chunks = maximal_marginal_relevance(
query_vec=query_embedding,
candidate_vecs=embeddings,
candidate_data=data,
k=5,
lambda_param=0.6 # leggermente orientato alla rilevanza
)
# Risultato: 5 chunk rilevanti MA con contenuti diversi tra loro
Errori Comuni nella Similarity Search
- ANN non attivato: Se EXPLAIN mostra un Seq Scan, controlla che il LIMIT sia ragionevole rispetto alla dimensione della tabella, che l'indice esista, e che le statistiche siano aggiornate (ANALYZE).
- ef_search troppo basso: Con ef_search=10 potresti ottenere Recall@10 di solo 70-75%. Per produzione, usa almeno 40, meglio 60-100 per RAG.
- Post-filtering che svuota i risultati: Se applichi filtri WHERE dopo l'ORDER BY (senza pre-filtering), l'ANN restituisce i k globali e poi filtra, potenzialmente dando 0 risultati. Usa sempre pre-filtering.
- Operatore di distanza sbagliato: Usare <-> (L2) invece di <=> (cosine) per text embeddings riduce drammaticamente la qualità della ricerca. Verifica che il type di ops nell'indice corrisponda all'operatore nella query.
- Nessun threshold di similarità: Restituire tutti i top-k anche quando la distanza e molto alta porta a risposte irrilevanti nel RAG.
- Ignorare la ridondanza: I top-5 chunk potrebbero coprire tutti la stessa frase del documento. Usa MMR per diversità nelle risposte RAG.
Conclusioni e Prossimi Passi
La similarity search in PostgreSQL e un campo con molti parametri da configurare. La chiave
e capire il trade-off fondamentale: più candidati si esaminano, più alta e la recall
ma più alta e la latenza. Per la maggior parte dei sistemi RAG in produzione, HNSW
con ef_search=60-100 offre un ottimo equilibrio tra 10ms e 50ms di latenza
con Recall@10 del 92-97%.
Il prossimo articolo approfondisce le strategie di indicizzazione avanzate: come scegliere i parametri HNSW e IVFFlat ottimali per il tuo caso d'uso specifico, come monitorare la degradazione degli indici nel tempo, e le tecniche per aggiornamenti incrementali senza perdita di performance. Incluso: configurazione PostgreSQL completa, parallel index build, e come schedulare il REINDEX senza downtime.
Continua la Serie
- Precedente: RAG con PostgreSQL: Pipeline Completa
- Successivo: Indexing per Vector Search: HNSW e IVFFlat
- Correlato: AI Engineering: RAG Pipeline
- Correlato: Data & AI Business: Vector Search Use Cases







