Căutare avansată de similaritate: algoritmi și optimizare în PostgreSQL
Când aveți o bază de date cu milioane de vectori și trebuie să găsiți cei 10 cei mai asemănători cu o interogare în mai puțin de 50 de milisecunde, the căutarea de similaritate devine o provocare inginerească serios. Căutarea exactă (forța brută) este precisă, dar nu se scară: comparați fiecare vector cu fiecare interogarea are complexitatea O(n*d) unde n este numărul de vectori și d este dimensionalitatea. Cu 10 milioane de vectori cu 1536 de dimensiuni, fiecare interogare ar necesita 15 miliarde de operații în virgulă mobilă.
Soluția sunt algoritmii ANN (aproximativ cel mai apropiat vecin): ei renunță la garanția găsirii rezultatului exact pentru a obține răspunsuri în O(log n) sau chiar O(1) amortizata, cu o precizie practica de 95-99%. PostgreSQL cu pgvector le implementează pe cele două Cei mai populari algoritmi ANN: HNSW e IVFFlat.
În acest articol analizăm modul în care acești algoritmi funcționează în profunzime și când să îi folosim fiecare, cum să optimizați interogările de căutare similare în PostgreSQL, cum să combinați căutarea vector cu filtre de metadate și tehnici avansate, cum ar fi MMR, pentru rezultate diversificate.
Prezentare generală a seriei
| # | Articol | Concentrează-te |
|---|---|---|
| 1 | pgvector | Instalare, operatori, indexare |
| 2 | Înglobări în profunzime | Modele, distante, generatie |
| 3 | RAG cu PostgreSQL | Conductă RAG de la capăt la capăt |
| 4 | Sunteți aici - Căutare de similaritate | Algoritmi si optimizare |
| 5 | HNSW și IVFFlat | Strategii avansate de indexare |
| 6 | RAG în producție | Scalabilitate și performanță |
Ce vei învăța
- Diferența dintre căutarea exactă și cel mai apropiat vecin (ANN)
- Cum funcționează HNSW pe plan intern: grafice mici navigabile în lume
- Cum funcționează IVFFlat: grupare și căutare de probe
- Când să folosiți HNSW vs IVFFlat vs forța brută
- Optimizarea interogărilor: parametrii ef_search și probes
- Filtre hibride: combinați filtrarea metadatelor și căutarea vectorială
- Operatori de distanță: cosinus, L2 și produs interior comparat
- Evaluarea comparativă și măsurarea reamintirii cu codul Python
- Relevanță marginală maximă (MMR) pentru rezultate diversificate
- Căutare semantică vs căutare prin cuvinte cheie: când să folosiți ce
Căutare exactă vs căutare aproximativă
Forța brută (căutare exactă)
Căutarea exactă compară interogarea cu fiecare vector din baza de date. Garantează 100% reamintire, dar are o complexitate liniară. Și doar alegerea corectă pentru seturi de date mici sau când precizia absolută este o cerință nenegociabilă:
-- 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: Rechemarea/Compromisul de performanță
-- 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 de distanță în pgvector
pgvector acceptă trei operatori la distanță, fiecare adecvat pentru diferite tipuri de încorporare. Alegerea operatorului greșit poate reduce semnificativ calitatea căutării dvs.
| Operator | Tip | Gamă | Când să utilizați | Suport pentru index |
|---|---|---|---|---|
<=> |
Distanța cosinus | [0, 2] | Încorporarea textului (OpenAI, Sentence Transformers) - IMPLICIT | HNSW, IVFFlat (vector_cosine_ops) |
<-> |
Distanța euclidiană (L2). | [0, inf) | Vectori cu magnitudine semnificativă (imagini, audio) | HNSW, IVFFlat (vector_l2_ops) |
<#> |
Produs interior negativ | (-inf, 0] | Încorporare pre-normalizate, căutare maximă internă a produsului | 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: Lume mică navigabilă ierarhică
HNSW este cel mai popular algoritm ANN în 2025-2026. Se bazează pe teoria mici grafice ale lumii: structuri în care fiecare nod este accesibil de la oricare mai mult în câțiva pași (fenomenul „6 grade de separare”). Structura ierarhică vă permite să navigați rapid în zona cea mai relevantă a spațiului vectorial, folosind nivelurile superioare ca „autostrăzi” și nivelul de bază pentru cercetarea de precizie.
Cum funcționează HNSW
HNSW construiește o structură la nivel ierarhic:
- Nivelul 0 (de bază): Toți vectorii, conectați la cei mai apropiați vecini ai lor. Grafic dens.
- Niveluri superioare (1, 2, ...): Subseturi progresiv mai mici de vectori. Grafice rare pentru navigare rapidă.
- Punct de intrare: Căutarea începe întotdeauna de la cel mai înalt nivel (mai puțini vectori) și coboară cu lăcomie către țintă.
-- 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: Rezumat interactiv
| Parametru | Descriere | Implicit | Gamă | Creșterea efectului |
|---|---|---|---|---|
m |
Max conexiuni per nod per nivel | 16 | 4-64 | O reamintire mai bună, mai multă memorie, o construcție mai lentă |
ef_construction |
Candidații luați în considerare în timpul construcției | 64 | 16-200 | Potențial de reamintire mai bun, construcție mult mai lentă |
ef_search |
Candidați luați în considerare în timpul interogării (execuție) | 40 | 1-ef_construcție | Reamintire mai bună, interogare mai lentă |
-- 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: Fișier inversat cu compresie plată
IVFFlat folosește o abordare diferită: împarte spațiul vectorial în clustere (celule) prin K- înseamnă și în timpul unei interogări caută doar cele mai promițătoare clustere. Și mai simplu decât HNSW dar necesită date existente pentru a face K-means și se degradează mai rapid cu inserții.
Cum funcționează IVFFlat
- Faza de antrenament: Gruparea K-means împarte vectorii în
listscentroizii - Faza de construire: Fiecare vector este alocat celui mai apropiat cluster (centroid).
- Întrebări: Găsiți-mă
probesclusterele cele mai apropiate de interogare, apoi caută exact în acele clustere
-- 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: Ghid de alegere
| Caracteristică | HNSW | IVFFlat |
|---|---|---|
| Latența interogării | Mai rapid (de multe ori de 2-5 ori aceeași reamintire) | Mai lent la reamintire ridicată |
| Construiește timp | Mai lent (K-means nu este necesar, dar construirea este mai complexă) | Mult mai rapid (~4x) |
| Memorie index | Major (~2-4x) | Minor |
| Amintiți-vă cu o latență egală | Mai bine (în general) | Puțin mai jos |
| Inserție incrementală | Excelent (fără reinstruire) | Se degradează: clusterele nu se reajustează |
| Date necesare pentru construcție | Niciunul (poate începe gol) | Necesită date existente (K-means) |
| Setul de date tipic | Seturi de date în creștere, precizie ridicată, RAG | Seturi de date statice, construcție rapidă prioritară |
Regulă practică pentru alegere
- Utilizați HNSW în majoritatea cazurilor: o reamintire mai bună, interogări mai rapide, gestionează bine inserările. Aceasta este alegerea potrivită pentru 90% dintre sistemele RAG.
- Utilizați IVFFlat când: aveți un set de date de miliarde de vectori și memoria pentru HNSW este insuficientă sau când trebuie să reconstruiți frecvent indexul pe date care se modifică complet.
- Utilizați forța brută (fără index) când: aveți mai puțin de 50.000 de operatori sau aveți nevoie de rechemare garantată 100% (de exemplu, cercetare juridică, conformitate).
Optimizarea interogărilor cu filtre hibride
Una dintre cele mai frecvente probleme în producție este combinația de filtre de metadate cu căutare vectorială. pgvector 0.7+ se ocupă de acest lucru cu scanare iterativă, dar este important să înțelegeți cum să structurați corect interogările pentru a evita problema postfiltrare.
Problema post-filtrare și soluția
-- 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
Indici parțiali pentru filtre frecvente
-- 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)
Interogare cu pragul de similitudine
-- 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)
Căutare semantică vs căutare similară
Termenii sunt adesea folosiți interschimbabil, dar există o distincție importantă care influențează arhitectura sistemului:
| Tip | Obiectiv | Exemplu | Metric |
|---|---|---|---|
| Căutare de similaritate | Găsirea vectorilor aproape de un vector dat | Imagini similare cu această imagine | Distanța L2, cosinus |
| Căutare semantică | Găsiți documente cu semnificație similară unei interogări de text | „Cum instalez PostgreSQL?” găsiți ghiduri fără acea expresie exactă | Asemănarea cosinusului pe înglobările de text |
| Căutare prin cuvinte cheie | Potrivirea exactă a cuvintelor cheie | „PostgreSQL 16” găsește doar documentele cu acel șir | TF-IDF, BM25 |
| Căutare hibridă | Combinați semantica și cuvintele cheie | Echilibrează relevanța semantică și potrivirea exactă a termenilor tehnici | RRF, sumă ponderată |
Implementarea completă a Căutării Semantice
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]}...")
Căutare hibridă: combinarea vectorului și a textului integral
Unul dintre marile puncte forte ale PostgreSQL pentru RAG este capacitatea de a-l combina într-unul singur căutare semantică (vectorală) de interogări cu căutare clasică full-text (cum ar fi BM25). Aceasta și deosebit de util pentru interogări care conțin termeni tehnici preciși, cum ar fi numele proprii, coduri, versiuni de 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: Măsurarea calității cercetării
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")
Măsurarea retragerii: verificați calitatea 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
Optimizare avansată: Planificarea interogărilor
-- 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
Relevanța marginală maximă (MMR)
O problemă comună în căutarea de similaritate este redundanță a rezultatelor: i Bucățile top-k pot fi toate foarte asemănătoare între ele, acoperind aceleași informații. Aceasta reduce calitatea contextului RAG deoarece modelul primește aceeași informație în mod repetat. MMR echilibrează relevanța și diversitatea selectând progresiv următoarea bucată care e relevante pentru interogare DAR diferite de cele deja selectate.
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
Greșeli frecvente în căutarea de similaritate
- ANN nu este activat: Dacă EXPLAIN afișează o scanare Seq, verificați dacă LIMIT este rezonabilă pentru dimensiunea tabelului, dacă indexul există și că statisticile sunt actualizate (ANALYZE).
- ef_search prea mic: Cu ef_search=10 puteți obține Recall@10 de numai 70-75%. Pentru producție, folosiți cel puțin 40, mai bine 60-100 pentru RAG.
- Post-filtrare care șterge rezultatele: Dacă aplicați filtre WHERE după ORDER BY (fără pre-filtrare), ANN returnează ks globale și apoi filtrează, dând potențial 0 rezultate. Utilizați întotdeauna prefiltrarea.
- Operator de distanță greșit: Utilizarea <-> (L2) în loc de <=> (cosinus) pentru încorporarea textului reduce dramatic calitatea căutării. Verificați dacă tipul operațiunilor din index se potrivește cu operatorul din interogare.
- Fără prag de similaritate: Returnarea tuturor top-k-urilor chiar și atunci când distanța este foarte mare duce la răspunsuri irelevante în RAG.
- Ignorați redundanța: Primele 5 bucăți ar putea acoperi aceeași propoziție în document. Utilizați MMR pentru diversitate în răspunsurile RAG.
Concluzii și pașii următori
Căutarea de similaritate în PostgreSQL este un câmp cu mulți parametri de configurat. Cheia
și înțelegeți compromisul fundamental: cu cât examinați mai mulți candidați, cu atât este mai mare reamintirea
dar latența este mai mare. Pentru majoritatea sistemelor RAG în producție, HNSW
cu ef_search=60-100 oferă un echilibru excelent între latența de 10 ms și 50 ms
cu Recall@10 de 92-97%.
Următorul articol intră în mai multe detalii strategii avansate de indexare: cum să alegeți parametrii optimi HNSW și IVFFlat pentru cazul dvs. de utilizare specific, cum monitorizați degradarea indicilor în timp și tehnicile pentru actualizări incrementale fără pierderi de performanță. Inclus: setare completă PostgreSQL, construirea de index paralel, și cum să programați REINDEX fără întreruperi.







