Rozšířené hledání podobností: Algoritmy a optimalizace v PostgreSQL
Když máte databázi s miliony vektorů a potřebujete najít 10 nejpodobnějších dotazu v méně než 50 milisekund, hledání podobnosti se stává inženýrskou výzvou vážné. Přesné vyhledávání (hrubá síla) je přesné, ale neškáluje: porovnejte každý vektor s každým dotaz má složitost O(n*d), kde n je počet vektorů a d je rozměrnost. S 10 miliony z 1536-dimenzionálních vektorů by každý dotaz vyžadoval 15 miliard operací s pohyblivou řádovou čárkou.
Řešením jsou algoritmy ANN (Přibližný nejbližší soused): vzdávají se na záruku nalezení přesného výsledku pro získání odpovědí v O(log n) nebo dokonce O(1) polstrovaný, s praktickou přesností 95-99%. PostgreSQL s pgvector implementuje dva Nejoblíbenější algoritmy ANN: HNSW e IVFFlat.
V tomto článku analyzujeme, jak tyto algoritmy do hloubky fungují a kdy je použít každý, jak optimalizovat dotazy na podobnostní vyhledávání v PostgreSQL, jak kombinovat vyhledávání vektor s filtry metadat a pokročilé techniky, jako je MMR pro diverzifikované výsledky.
Přehled série
| # | Položka | Soustředit |
|---|---|---|
| 1 | pgvector | Instalace, operátoři, indexace |
| 2 | Vložení do hloubky | Modely, vzdálenosti, generace |
| 3 | RAG s PostgreSQL | End-to-end potrubí RAG |
| 4 | Jste zde - Hledání podobností | Algoritmy a optimalizace |
| 5 | HNSW a IVFFlat | Pokročilé strategie indexování |
| 6 | RAG ve výrobě | Škálovatelnost a výkon |
Co se naučíte
- Rozdíl mezi přesným vyhledáváním a přibližným nejbližším sousedem (ANN)
- Jak HNSW interně funguje: malé grafy s možností navigace ve světě
- Jak IVFFlat funguje: shlukování a vyhledávání sond
- Kdy použít HNSW vs IVFFlat vs hrubá síla
- Optimalizace dotazu: parametry ef_search a sondy
- Hybridní filtry: kombinují filtrování metadat a vektorové vyhledávání
- Operátory vzdálenosti: srovnání kosinus, L2 a vnitřního součinu
- Srovnávání a měření vyvolání pomocí kódu Python
- Maximální mezní relevance (MMR) pro diverzifikované výsledky
- Sémantické vyhledávání vs vyhledávání klíčových slov: kdy co použít
Přesné vyhledávání vs. Přibližné vyhledávání
Hrubá síla (přesné vyhledávání)
Přesné vyhledávání porovnává dotaz s každým jednotlivým vektorem v databázi. Zaručuje 100% vyvolání, ale má lineární složitost. A pouze správná volba pro malé datové soubory nebo když je absolutní přesnost nesmlouvavým požadavkem:
-- 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: The Recall/Performance Trade-off
-- 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
Operátory vzdálenosti v pgvector
pgvector podporuje tři operátory vzdálenosti, každý vhodný pro různé typy vložení. Výběr špatného operátora může výrazně snížit kvalitu vašeho vyhledávání.
| Operátor | Typ | Rozsah | Kdy použít | Podpora indexu |
|---|---|---|---|---|
<=> |
Kosinusová vzdálenost | [0, 2] | Vkládání textu (OpenAI, Sentence Transformers) – VÝCHOZÍ | HNSW, IVFFlat (vector_cosine_ops) |
<-> |
Euklidovská (L2) vzdálenost | [0, inf) | Vektory s významnou velikostí (obrázky, zvuk) | HNSW, IVFFlat (vector_l2_ops) |
<#> |
Negativní vnitřní produkt | (-inf, 0] | Přednormalizované vložení, maximální vnitřní vyhledávání produktů | 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: Hierarchický splavný malý svět
HNSW je nejpopulárnější ANN algoritmus v letech 2025-2026. Vychází z teorie malé světové grafy: struktury, kde je každý uzel dosažitelný z libovolného více v několika krocích (fenomén "6 stupňů oddělení"). Hierarchická struktura umožňuje rychle přejít do nejrelevantnější oblasti vektorového prostoru pomocí horní úrovně jako "dálnice" a základní úroveň pro přesný výzkum.
Jak HNSW funguje
HNSW vytváří hierarchickou úrovňovou strukturu:
- Úroveň 0 (základní): Všechny vektory spojené se svými nejbližšími sousedy. Hustý graf.
- Vyšší úrovně (1, 2, ...): Postupně menší podmnožiny vektorů. Řídké grafy pro rychlou navigaci.
- Vstupní bod: Hledání vždy začíná na nejvyšší úrovni (méně vektorů) a sestupuje chtivě směrem k cíli.
-- 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
Parametry HNSW: Interaktivní souhrn
| Parametr | Popis | Výchozí | Rozsah | Zvyšte účinek |
|---|---|---|---|---|
m |
Maximální počet připojení na uzel a úroveň | 16 | 4-64 | Lepší zapamatování, více paměti, pomalejší stavba |
ef_construction |
Kandidáti zvažovaní během stavby | 64 | 16-200 | Lepší vybavovací potenciál, mnohem pomalejší budování |
ef_search |
Kandidáti zvažovaní během dotazu (během běhu) | 40 | 1-ef_construction | Lepší zapamatování, pomalejší dotaz |
-- 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: Invertovaný soubor s plochou kompresí
IVFFlat používá jiný přístup: rozděluje vektorový prostor na shluky (buňky) via K-znamená a během dotazu prohledává pouze nejslibnější clustery. A jednodušší než HNSW ale vyžaduje existující data, aby provedla K-prostředky a rychleji se degraduje s břitovými destičkami.
Jak IVFFlat funguje
- Tréninková fáze: K-means clustering rozděluje vektory na
listscentroidy - Fáze budování: Každý vektor je přiřazen k nejbližšímu shluku (centroidu).
- dotazy: Najít i
probesshluky nejblíže k dotazu, pak hledá přesně v těchto shlucích
-- 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: Průvodce výběrem
| Charakteristický | HNSW | IVFFlat |
|---|---|---|
| Latence dotazu | Rychlejší (často 2-5x stejné vyvolání) | Pomalejší při vysokém vybavování |
| Čas budování | Pomalejší (K-means není potřeba, ale sestavení je složitější) | Mnohem rychlejší (~4x) |
| Indexová paměť | Hlavní (~2-4x) | Menší |
| Vyvolání se stejnou latencí | Lepší (obecně) | Mírně nižší |
| Inkrementální vložka | Vynikající (žádné přeškolování) | Degraduje: shluky se nepřenastavují |
| Data potřebná pro stavbu | Žádné (může začít prázdné) | Vyžaduje existující data (K-means) |
| Typická datová sada | Rostoucí datové sady, vysoká přesnost, RAG | Statické datové sady, prioritní rychlé sestavení |
Praktické pravidlo pro výběr
- Použijte HNSW ve většině případů: lepší vyvolání, rychlejší dotazy, dobře zpracovává vložky. Toto je správná volba pro 90 % systémů RAG.
- Použijte IVFFlat když: máte datovou sadu o miliardách vektorů a paměť pro HNSW je nedostatečná, nebo když potřebujete často přestavovat index na datech, která se úplně mění.
- Použijte hrubou sílu (bez indexu) když: máte méně než 50 000 dopravců nebo potřebujete 100% zaručené stažení (např. právní průzkum, dodržování předpisů).
Optimalizace dotazů pomocí hybridních filtrů
Jedním z nejčastějších problémů ve výrobě je kombinace filtry metadat con la vektorové vyhledávání. pgvector 0.7+ to řeší pomocí iterativního skenování, ale je důležité pochopit, jak správně strukturovat dotazy, abyste se vyhnuli problému dodatečné filtrování.
Problém po filtraci a řešení
-- 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
Částečné indexy pro časté filtry
-- 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)
Dotaz s prahem podobnosti
-- 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)
Sémantické vyhledávání vs. podobnostní vyhledávání
Termíny se často používají zaměnitelně, ale existuje důležitý rozdíl který ovlivňuje architekturu systému:
| Typ | Objektivní | Příklad | Metrický |
|---|---|---|---|
| Hledání podobnosti | Hledání vektorů blízkých danému vektoru | Obrázky podobné tomuto obrázku | Vzdálenost L2, kosinus |
| Sémantické vyhledávání | Najděte dokumenty s podobným významem jako textový dotaz | "Jak nainstaluji PostgreSQL?" najít průvodce bez této přesné fráze | Kosinová podobnost na vkládání textu |
| Vyhledávání klíčových slov | Přesná shoda klíčových slov | "PostgreSQL 16" najde pouze dokumenty s tímto řetězcem | TF-IDF, BM25 |
| Hybridní vyhledávání | Kombinujte sémantiku a klíčová slova | Vyvážit sémantickou relevanci a přesnou shodu technických termínů | RRF, vážený součet |
Kompletní implementace sémantického vyhledávání
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]}...")
Hybridní vyhledávání: Kombinace vektorového a plného textu
Jednou z velkých předností PostgreSQL pro RAG je schopnost jej zkombinovat do jednoho dotaz sémantické (vektorové) vyhledávání s klasickým fulltextovým vyhledáváním (podobné BM25). Toto a zvláště užitečné pro dotazy, které obsahují přesné technické výrazy, jako jsou vlastní jména, kódy, verze softwaru.
-- 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ěření kvality výzkumu
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")
Vyvolání měření: Zkontrolujte kvalitu 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
Pokročilá optimalizace: Plánování dotazů
-- 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
Maximální okrajová relevance (MMR)
Častým problémem při hledání podobnosti je redundance výsledků: i top-k bloky mohou být všechny navzájem velmi podobné a pokrývají stejné informace. Toto snižuje kvalitu kontextu RAG, protože model dostává stejné informace opakovaně. MMR vyvažuje relevanci a rozmanitost postupným výběrem další části, např relevantní pro dotaz, ALE odlišné od již vybraných.
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
Časté chyby při hledání podobnosti
- ANN není aktivováno: Pokud EXPLAIN zobrazí Seq Scan, zkontrolujte, že LIMIT odpovídá velikosti tabulky, že index existuje a že statistiky jsou aktuální (ANALYZE).
- ef_search je příliš nízký: S ef_search=10 byste mohli získat Recall@10 pouze 70-75%. Pro výrobu použijte alespoň 40, lépe 60-100 na RAG.
- Následné filtrování, které vymaže výsledky: Pokud použijete filtry WHERE po ORDER BY (bez předběžného filtrování), ANN vrátí globální ks a poté filtry, což může mít 0 výsledků. Vždy používejte předfiltrování.
- Špatný operátor vzdálenosti: Použití <-> (L2) místo <=> (kosinus) pro vkládání textu dramaticky snižuje kvalitu vyhledávání. Ověřte, že typ ops v indexu odpovídá operátoru v dotazu.
- Žádný práh podobnosti: Vrácení všech top-k, i když je vzdálenost velmi vysoká, vede k irelevantním odpovědím v RAG.
- Ignorovat redundanci: Prvních 5 kusů by mohlo pokrývat stejnou větu v dokumentu. Použijte MMR pro rozmanitost reakcí RAG.
Závěry a další kroky
Podobnostní vyhledávání v PostgreSQL je pole s mnoha parametry ke konfiguraci. Klíč
a pochopit základní kompromis: čím více kandidátů vyzkoušíte, tím vyšší bude odvolání
ale latence je vyšší. Pro většinu systémů RAG ve výrobě HNSW
s ef_search=60-100 nabízí skvělou rovnováhu mezi latencí 10 ms a 50 ms
s Recall@10 92-97 %.
Další článek jde podrobněji pokročilé strategie indexování: jak vybrat optimální parametry HNSW a IVFFlat pro váš konkrétní případ použití, jak sledovat degradaci indexů v průběhu času a techniky pro přírůstkové aktualizace bez ztráty výkonu. Zahrnuje: úplné nastavení PostgreSQL, paralelní sestavení indexu, a jak naplánovat REINDEX bez prostojů.
Série pokračuje
- Předchozí: RAG s PostgreSQL: Complete Pipeline
- Další: Indexování pro vyhledávání vektorů: HNSW a IVFFlat
- Související: AI Engineering: RAG Pipeline
- Související: Data & AI Business: Případy použití vektorového vyhledávání







