Zaawansowane wyszukiwanie podobieństw: algorytmy i optymalizacja w PostgreSQL
Kiedy masz bazę danych zawierającą miliony wektorów i musisz znaleźć 10 najbardziej podobnych do zapytania mniej niż 50 milisekund, wyszukiwanie podobieństw staje się wyzwaniem inżynierskim poważny. Dokładne wyszukiwanie (brute-force) jest precyzyjne, ale nie skaluje się: porównaj każdy wektor z każdym zapytanie ma złożoność O(n*d), gdzie n to liczba wektorów, a d to wymiar. Z 10 milionami dla wektorów o 1536 wymiarach każde zapytanie wymagałoby 15 miliardów operacji zmiennoprzecinkowych.
Rozwiązaniem są algorytmy ANN (przybliżony najbliższy sąsiad): poddają się do gwarancji znalezienia dokładnego wyniku w celu uzyskania odpowiedzi w O(log n) lub nawet O(1) amortyzowany, z praktyczną dokładnością 95-99%. PostgreSQL z pgvector implementuje oba Najpopularniejsze algorytmy SSN: HNSW e IVFFlat.
W tym artykule szczegółowo analizujemy, jak działają te algorytmy i kiedy z nich korzystać każdy, jak zoptymalizować zapytania wyszukiwania podobieństwa w PostgreSQL, jak połączyć wyszukiwanie wektor z filtrami metadanych i zaawansowane techniki, takie jak MMR, w celu uzyskania zróżnicowanych wyników.
Przegląd serii
| # | Przedmiot | Centrum |
|---|---|---|
| 1 | pgwektor | Instalacja, operatorzy, indeksowanie |
| 2 | Osadzanie w głębi | Modele, odległości, generacja |
| 3 | RAG z PostgreSQL | Rurociąg RAG od końca do końca |
| 4 | Jesteś tutaj - Wyszukiwanie podobieństw | Algorytmy i optymalizacja |
| 5 | HNSW i IVFFlat | Zaawansowane strategie indeksowania |
| 6 | RAG w produkcji | Skalowalność i wydajność |
Czego się nauczysz
- Różnica między dokładnym wyszukiwaniem a przybliżonym najbliższym sąsiadem (ANN)
- Jak HNSW działa wewnętrznie: małe wykresy, na których można nawigować
- Jak działa IVFFlat: grupowanie i wyszukiwanie sond
- Kiedy stosować HNSW, IVFFlat czy brutalną siłę
- Optymalizacja zapytań: parametry ef_search i sond
- Filtry hybrydowe: łączą filtrowanie metadanych z wyszukiwaniem wektorowym
- Operatory odległości: porównanie cosinusa, L2 i iloczynu wewnętrznego
- Benchmarking i pomiary przywoływania danych za pomocą kodu Python
- Maksymalna istotność krańcowa (MMR) dla zróżnicowanych wyników
- Wyszukiwanie semantyczne a wyszukiwanie słów kluczowych: kiedy czego używać
Dokładne wyszukiwanie a przybliżone wyszukiwanie
Brutalna siła (dokładne wyszukiwanie)
Wyszukiwanie dokładne porównuje zapytanie z każdym pojedynczym wektorem w bazie danych. Gwarantuje 100% przypomnienia, ale ma liniową złożoność. I tylko właściwy wybór w przypadku małych zbiorów danych lub gdy bezwzględna precyzja jest wymaganiem niepodlegającym negocjacjom:
-- 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: Kompromis przypomnienia i wydajności
-- 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
Operatory odległości w pgvector
pgvector obsługuje trzy operatory odległości, każdy odpowiedni dla różnych typów osadzania. Wybór niewłaściwego operatora może znacznie obniżyć jakość wyszukiwania.
| Operator | Typ | Zakres | Kiedy stosować | Obsługa indeksu |
|---|---|---|---|---|
<=> |
Odległość cosinusowa | [0, 2] | Osadzanie tekstu (OpenAI, Transformatory zdań) - DOMYŚLNE | HNSW, IVFFlat (vector_cosine_ops) |
<-> |
Odległość euklidesowa (L2). | [0, inf) | Wektory o znacznych rozmiarach (obrazy, dźwięk) | HNSW, IVFFlat (vector_l2_ops) |
<#> |
Negatywny produkt wewnętrzny | (-inf, 0] | Wstępnie znormalizowane osadzanie, maksymalne wewnętrzne wyszukiwanie produktów | 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: Hierarchiczny żeglowny mały świat
HNSW jest najpopularniejszym algorytmem SSN w latach 2025-2026. Opiera się na teorii wykresy małego świata: struktury, w których każdy węzeł jest osiągalny z dowolnego miejsca więcej w kilku krokach (zjawisko „6 stopni separacji”). Struktura hierarchiczna pozwala szybko przejść do najbardziej odpowiedniego obszaru przestrzeni wektorowej za pomocą wyższe poziomy jako „autostrady” i poziom podstawowy do badań precyzyjnych.
Jak działa HNSW
HNSW buduje hierarchiczną strukturę poziomów:
- Poziom 0 (podstawowy): Wszystkie wektory połączone z najbliższymi sąsiadami. Gęsty wykres.
- Wyższe poziomy (1, 2, ...): Stopniowo mniejsze podzbiory wektorów. Rzadkie wykresy dla szybkiej nawigacji.
- Punkt wejścia: Wyszukiwanie zawsze rozpoczyna się od najwyższego poziomu (mniej wektorów) i chciwie schodzi w stronę celu.
-- 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: podsumowanie interaktywne
| Parametr | Opis | Domyślny | Zakres | Zwiększ efekt |
|---|---|---|---|---|
m |
Maksymalna liczba połączeń na węzeł na poziom | 16 | 4-64 | Lepsze zapamiętywanie, więcej pamięci, wolniejsza kompilacja |
ef_construction |
Kandydaci brani pod uwagę podczas kompilacji | 64 | 16-200 | Lepszy potencjał przypominania, znacznie wolniejsza kompilacja |
ef_search |
Kandydaci brani pod uwagę podczas zapytania (runtime) | 40 | 1-ef_konstrukcja | Lepsze zapamiętywanie, wolniejsze zapytania |
-- 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: odwrócony plik z płaską kompresją
IVFFlat wykorzystuje inne podejście: dzieli przestrzeń wektorową na klastry (komórki) poprzez K-średnie i podczas zapytania przeszukuje tylko najbardziej obiecujące klastry. I prostsze niż HNSW ale wymaga istniejących danych do obliczenia średnich K i ulega szybszej degradacji w przypadku wstawek.
Jak działa IVFFlat
- Faza treningowa: Grupowanie K-średnich dzieli wektory na
listscentroidy - Faza budowy: Każdy wektor jest przypisany do najbliższego klastra (centrroidu).
- Zapytania: Znajdź I
probesklastry najbliższe zapytaniu, a następnie przeszukuje dokładnie te skupienia
-- 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: Przewodnik wyboru
| Charakterystyczny | HNSW | IVFFlat |
|---|---|---|
| Opóźnienie zapytania | Szybciej (często 2-5 razy to samo przywołanie) | Wolniej przy wysokim przypominaniu |
| Czas budowania | Wolniejsze (średnie K nie są potrzebne, ale kompilacja jest bardziej złożona) | Znacznie szybciej (~4x) |
| Pamięć indeksowa | Duże (~2-4x) | Drobny |
| Przywołaj z równym opóźnieniem | Lepiej (ogólnie) | Nieco niżej |
| Wkładka przyrostowa | Doskonały (bez ponownego szkolenia) | Degraduje: klastry nie dostosowują się ponownie |
| Dane wymagane do kompilacji | Brak (może zacząć się pusto) | Wymaga istniejących danych (K-średnie) |
| Typowy zbiór danych | Rosnące zbiory danych, wysoka precyzja, RAG | Statyczne zbiory danych, priorytetowa szybka kompilacja |
Praktyczna zasada wyboru
- Użyj HNSW w większości przypadków: lepsze zapamiętywanie, szybsze zapytania, dobrze radzi sobie z wstawkami. Jest to właściwy wybór dla 90% systemów RAG.
- Użyj IVFFlat gdy: masz zbiór danych składający się z miliardów wektorów i pamięć dla HNSW jest niewystarczająca lub gdy musisz często odbudowywać indeks na danych, które całkowicie się zmieniają.
- Użyj brutalnej siły (bez indeksu) gdy: masz mniej niż 50 tys. przewoźników lub potrzebujesz 100% gwarancji wycofania (np. badania prawne, zgodność).
Optymalizacja zapytań za pomocą filtrów hybrydowych
Jednym z najczęstszych problemów w produkcji jest połączenie filtry metadanych con la wyszukiwanie wektorów. pgvector 0.7+ obsługuje to poprzez skanowanie iteracyjne, ale ważne jest, aby zrozumieć, jak poprawnie konstruować zapytania, aby uniknąć problemu filtrowanie końcowe.
Problem filtrowania końcowego i rozwiązanie
-- 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
Częściowe indeksy dla częstych filtrów
-- 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)
Zapytanie z progiem podobieństwa
-- 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)
Wyszukiwanie semantyczne a wyszukiwanie podobieństw
Terminy te są często używane zamiennie, ale istnieje istotna różnica co wpływa na architekturę systemu:
| Typ | Cel | Przykład | Metryczny |
|---|---|---|---|
| Wyszukiwanie podobieństw | Znajdowanie wektorów bliskich zadanemu wektorowi | Obrazy podobne do tego obrazu | Odległość L2, cosinus |
| Wyszukiwanie semantyczne | Znajdź dokumenty o znaczeniu podobnym do zapytania tekstowego | „Jak zainstalować PostgreSQL?” znajdź przewodniki bez tego dokładnego wyrażenia | Podobieństwo cosinusowe w osadzaniu tekstu |
| Wyszukiwanie słów kluczowych | Dokładne dopasowanie słów kluczowych | „PostgreSQL 16” wyszukuje tylko dokumenty zawierające ten ciąg | TF-IDF, BM25 |
| Wyszukiwanie hybrydowe | Połącz semantykę i słowa kluczowe | Zrównoważ znaczenie semantyczne i dokładne dopasowanie terminów technicznych | RRF, suma ważona |
Kompletna implementacja wyszukiwania semantycznego
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]}...")
Wyszukiwanie hybrydowe: łączenie tekstu wektorowego i pełnotekstowego
Jedną z największych zalet PostgreSQL dla RAG jest możliwość połączenia go w jeden wyszukiwanie semantyczne (wektorowe) zapytań z klasycznym wyszukiwaniem pełnotekstowym (podobnym do BM25). To i szczególnie przydatne w przypadku zapytań zawierających precyzyjne terminy techniczne, takie jak nazwy własne, kody, wersje oprogramowania.
-- 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: Pomiar jakości badań
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")
Przywołanie pomiaru: Sprawdź jakość SSN
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
Zaawansowana optymalizacja: planowanie zapytań
-- 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
Maksymalne znaczenie krańcowe (MMR)
Częstym problemem w wyszukiwaniu podobieństw jest redundancja wyników: ja Wszystkie fragmenty top-k mogą być do siebie bardzo podobne i zawierać te same informacje. To obniża jakość kontekstu RAG, ponieważ model otrzymuje wielokrotnie te same informacje. MMR równoważy przydatność i różnorodność poprzez stopniowe wybieranie kolejnego fragmentu, który np istotne dla zapytania, ALE różne od już wybranych.
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
Typowe błędy w wyszukiwaniu podobieństw
- ANN nie została aktywowana: Jeśli EXPLAIN pokazuje Seq Scan, sprawdź, czy LIMIT jest odpowiedni do rozmiaru tabeli, czy indeks istnieje i czy statystyki są aktualne (ANALIZA).
- ef_search za niski: Przy ef_search=10 możesz uzyskać Recall@10 tylko na poziomie 70-75%. Do produkcji użyj co najmniej 40, lepiej 60-100 dla RAG.
- Filtrowanie końcowe, które usuwa wyniki: Jeśli zastosujesz filtry WHERE po ORDER BY (bez wstępnego filtrowania), SSN zwróci globalne ks, a następnie filtruje, potencjalnie dając 0 wyników. Zawsze używaj wstępnego filtrowania.
- Zły operator odległości: Użycie <-> (L2) zamiast <=> (cosinus) do osadzania tekstu drastycznie obniża jakość wyszukiwania. Sprawdź, czy typ operacji w indeksie odpowiada operatorowi w zapytaniu.
- Brak progu podobieństwa: Zwracanie wszystkich górnych k, nawet gdy odległość jest bardzo duża, prowadzi do nieistotnych odpowiedzi w RAG.
- Ignoruj nadmiarowość: Wszystkie pięć najważniejszych fragmentów może obejmować to samo zdanie w dokumencie. Użyj MMR dla różnorodności reakcji RAG.
Wnioski i dalsze kroki
Wyszukiwanie podobieństw w PostgreSQL to pole z wieloma parametrami do skonfigurowania. Klucz
i zrozumieć podstawowy kompromis: im więcej kandydatów przetestujesz, tym większe będzie ich odwołanie
ale opóźnienie jest większe. Dla większości produkowanych systemów RAG, HNSW
z ef_search=60-100 zapewnia doskonałą równowagę pomiędzy opóźnieniem od 10 ms do 50 ms
z Recall@10 wynoszącym 92-97%.
Następny artykuł zawiera bardziej szczegółowe informacje zaawansowane strategie indeksowania: jak wybrać optymalne parametry HNSW i IVFFlat dla konkretnego przypadku użycia, w jaki sposób monitorowanie degradacji indeksów w czasie oraz techniki aktualizacji przyrostowych bez utraty wydajności. Obejmuje: pełną konfigurację PostgreSQL, równoległą kompilację indeksu, i jak zaplanować REINDEX bez przestojów.







