Salta al contenuto principale

04 - Vector Search: Similarity e Semantic Search

Guida completa a Vector Search: Similarity e Semantic Search: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

04 - Vector Search: Similarity e Semantic Search
PostgreSQL AIFoto:panumas nikhomkhaiPexels

In Breve

Guida completa a Vector Search: Similarity e Semantic Search: architettura, implementazione pratica e best practices per developer e team tecnici.

Similarity Search Avanzata: Algoritmi e Ottimizzazione in PostgreSQL

Quando hai un database con milioni di vettori e devi trovare i 10 più simili a una query in meno di 50 millisecondi, la ricerca di similarità diventa una sfida ingegneristica seria. La ricerca esatta (brute-force) e precisa ma non scala: confrontare ogni vettore con ogni query ha complessità O(n*d) dove n e il numero di vettori e d la dimensionalità. Con 10 milioni di vettori a 1536 dimensioni, ogni query richiederebbe 15 miliardi di operazioni floating point.

La soluzione sono gli algoritmi ANN (Approximate Nearest Neighbor): rinunciano alla garanzia di trovare il risultato esatto per ottenere risposte in O(log n) o addirittura O(1) ammortizzato, con una precisione pratica del 95-99%. PostgreSQL con pgvector implementa i due algoritmi ANN più diffusi: HNSW e IVFFlat.

In questo articolo analizziamo come funzionano questi algoritmi in profondità, quando usare ciascuno, come ottimizzare le query di similarity search in PostgreSQL, come combinare la ricerca vettoriale con i filtri sui metadati, e le tecniche avanzate come MMR per risultati diversificati.

Panoramica della Serie

# Articolo Focus
1pgvectorInstallazione, operatori, indexing
2Embeddings in ProfonditàModelli, distanze, generazione
3RAG con PostgreSQLPipeline RAG end-to-end
4Sei qui - Similarity SearchAlgoritmi e ottimizzazione
5HNSW e IVFFlatStrategie di indicizzazione avanzate
6RAG in ProduzioneScalabilità e performance

Cosa Imparerai

  • Differenza tra exact search e approximate nearest neighbor (ANN)
  • Come funziona HNSW internamente: grafi navigabili small world
  • Come funziona IVFFlat: clustering e probe search
  • Quando usare HNSW vs IVFFlat vs brute force
  • Ottimizzazione delle query: parametri ef_search e probes
  • Filtri ibridi: combinare metadata filtering e vector search
  • Operatori di distanza: cosine, L2 e inner product a confronto
  • Benchmarking e misurazione della recall con codice Python
  • Maximal Marginal Relevance (MMR) per risultati diversificati
  • Semantic search vs keyword search: quando usare cosa

Ricerca Esatta vs Ricerca Approssimata

Brute Force (Exact Search)

La ricerca esatta confronta la query con ogni singolo vettore nel database. Garantisce il 100% di recall ma ha complessità lineare. E la scelta giusta solo per dataset piccoli o quando la precisione assoluta e un requisito non negoziabile:

-- Ricerca esatta: nessun indice utilizzato, scan completo
-- Utile per dataset piccoli (< 100K vettori) o quando la precisione e critica
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec  -- scansione sequenziale su tutto il dataset
LIMIT 10;

-- Per forzare il brute force anche quando esiste un indice ANN:
SET enable_indexscan = off;
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10;
SET enable_indexscan = on;  -- ripristina

-- Benchmark comparativo (dataset reale su SSD NVMe):
-- Dataset: 1M vettori, 1536 dim, PostgreSQL 16, 32GB RAM, 16 CPU
-- Brute force (seq scan): ~2000ms per query  -- 2 secondi!
-- HNSW (ef_search=40):    ~10ms per query    -- 200x più veloce
-- HNSW (ef_search=100):   ~25ms per query    -- 80x più veloce
-- IVFFlat (probes=50):    ~28ms per query    -- 71x più veloce

-- Dataset 100K vettori (brute force accettabile):
-- Brute force: ~50ms per query  -- accettabile
-- HNSW:        ~3ms per query   -- ancora meglio se disponibile

ANN: Il Trade-off Recall/Performance

-- Verifica se la query sta usando l'indice ANN
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

-- Output desiderato (usa l'indice HNSW):
-- Index Scan using documents_embedding_hnsw on documents
--   Index Cond: (embedding <=> '...'::vector)
--   Limit: 10
--   Buffers: shared hit=347  (tutto da cache = ottimo!)
--   -> Execution Time: 8.3 ms

-- Output indesiderato (brute force):
-- Seq Scan on documents
--   Sort Key: (embedding <=> '...'::vector)  -- ordina dopo scan completo
--   Buffers: shared hit=234 read=8921  (molti disk read = lento!)
--   -> Execution Time: 2341 ms

-- MOTIVO per cui PostgreSQL non usa l'indice:
-- 1. LIMIT troppo grande (>10% delle righe per tabelle piccole)
-- 2. Le statistiche sono vecchie: ANALYZE documents;
-- 3. L'indice non esiste: verifica con \d documents
-- 4. enable_indexscan = off accidentalmente

Operatori di Distanza in pgvector

pgvector supporta tre operatori di distanza, ognuno appropriato per diversi tipi di embedding. La scelta dell'operatore sbagliato può ridurre significativamente la qualità della ricerca.

Operatore Tipo Range Quando Usare Supporto indice
<=> Cosine distance [0, 2] Text embeddings (OpenAI, Sentence Transformers) - DEFAULT HNSW, IVFFlat (vector_cosine_ops)
<-> Euclidean (L2) distance [0, inf) Vettori con magnitudine significativa (immagini, audio) HNSW, IVFFlat (vector_l2_ops)
<#> Negative inner product (-inf, 0] Embedding pre-normalizzati, max inner product search HNSW, IVFFlat (vector_ip_ops)
-- Cosine distance (più comune per text embeddings):
SELECT id, content,
    embedding <=> query_vec AS cosine_distance,
    1 - (embedding <=> query_vec) AS cosine_similarity
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 5;

-- L2 distance (euclidea, per immagini/audio):
SELECT id, content,
    embedding <-> query_vec AS l2_distance
FROM documents
ORDER BY embedding <-> query_vec
LIMIT 5;

-- Inner product (per embedding normalizzati):
SELECT id, content,
    (embedding <#> query_vec) * -1 AS inner_product  -- negato per avere più alto = più simile
FROM documents
ORDER BY embedding <#> query_vec  -- ORDER BY funziona perchè <#> e negativo
LIMIT 5;

-- Crea indice con l'operatore corretto (DEVE corrispondere alla query!):
-- Per cosine distance (default per text):
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- Per L2 distance:
CREATE INDEX ON documents USING hnsw (embedding vector_l2_ops);
-- Per inner product:
CREATE INDEX ON documents USING hnsw (embedding vector_ip_ops);

-- ERRORE COMUNE: indice con vector_cosine_ops ma query con <->
-- Risultato: PostgreSQL ignora l'indice e fa brute force!

HNSW: Hierarchical Navigable Small World

HNSW e l'algoritmo ANN più popolare nel 2025-2026. Si basa sulla teoria dei grafi small world: strutture dove ogni nodo e raggiungibile da qualsiasi altro in pochi passi (il fenomeno dei "6 gradi di separazione"). La struttura gerarchica permette di navigare rapidamente verso la zona di spazio vettoriale più rilevante, usando i livelli superiori come "autostrade" e il livello base per la ricerca di precisione.

Come Funziona HNSW

HNSW costruisce una struttura a livelli gerarchica:

  • Livello 0 (base): Tutti i vettori, connessi ai loro vicini più prossimi. Grafo denso.
  • Livelli superiori (1, 2, ...): Subset progressivamente più piccoli di vettori. Grafi sparsi per navigazione veloce.
  • Entry point: La ricerca inizia sempre al livello più alto (meno vettori) e scende greedy verso il target.
-- Struttura HNSW concettuale:
--
-- Livello 2:  o-----------o-----------o   (pochi nodi, salti lunghi - navigazione veloce)
--                 \         |         /
-- Livello 1:  o---o---o---o---o---o---o   (navigazione intermedia)
--                  \   |   |   /
-- Livello 0:  o-o-o-o-o-o-o-o-o-o-o-o   (tutti i vettori, ricerca precisa)
--
-- Algoritmo di ricerca per query Q:
-- 1. Inizia dal livello più alto con un entry point fisso
-- 2. Greedy descent: scendi verso il nodo più vicino a Q a ogni livello
-- 3. Usa quel nodo come entry point per il livello inferiore
-- 4. Ripeti fino al livello 0
-- 5. Al livello 0: beam search con ef_search candidati (candidati esplorati)
-- 6. Restituisci i top-k tra i candidati esplorati

-- Complessità teorica:
-- Build: O(n * log(n))  - sub-lineare nel numero di vettori
-- Query: O(log(n))      - logaritmica! vs O(n) del brute force

-- Creazione indice HNSW (valori ottimali per la maggior parte dei casi):
CREATE INDEX documents_hnsw_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (
    m = 16,              -- connessioni per nodo (default 16, range 4-64)
    ef_construction = 64 -- candidati durante costruzione (default 64, range 16-200)
);

-- Impostazione ef_search (runtime, senza rebuild):
SET hnsw.ef_search = 60;  -- valore raccomandato per produzione RAG

Parametri HNSW: Riepilogo Interattivo

Parametro Descrizione Default Range Effetto aumento
m Max connessioni per nodo per livello 16 4-64 Recall migliore, più memoria, build più lento
ef_construction Candidati considerati durante la build 64 16-200 Recall potenziale migliore, build molto più lento
ef_search Candidati considerati durante la query (runtime) 40 1-ef_construction Recall migliore, query più lenta
-- Configurazione ef_search per query (parametro runtime)
SET hnsw.ef_search = 40;   -- default, buon equilibrio

-- Alta precisione (RAG enterprise, medico, legale):
SET hnsw.ef_search = 100;

-- Alta velocità (autocomplete, recommendation):
SET hnsw.ef_search = 20;

-- Benchmark indicativo (1M vettori, 1536 dim, m=16, ef_construction=64):
-- ef_search=20:  ~5ms/query,  recall@10 ~85%
-- ef_search=40:  ~10ms/query, recall@10 ~92%
-- ef_search=100: ~25ms/query, recall@10 ~97%
-- ef_search=200: ~50ms/query, recall@10 ~99%

-- Imposta a livello di sessione (non persiste tra sessioni):
SELECT set_config('hnsw.ef_search', '100', false);

-- Imposta per la sessione corrente (equivalente):
SET hnsw.ef_search = 100;

IVFFlat: Inverted File with Flat Compression

IVFFlat usa un approccio diverso: divide lo spazio vettoriale in cluster (celle) tramite K-means e durante una query cerca solo nei cluster più promettenti. E più semplice di HNSW ma richiede dati esistenti per fare K-means e degrada più rapidamente con gli insert.

Come Funziona IVFFlat

  1. Fase di training: K-means clustering divide i vettori in lists centroidi
  2. Fase di build: Ogni vettore viene assegnato al cluster (centroide) più vicino
  3. Query: Trova i probes cluster più vicini alla query, poi cerca esattamente dentro quei cluster
-- IVFFlat richiede dati prima di creare l'indice
-- (ha bisogno di fare K-means clustering)

-- PREREQUISITO: la tabella deve avere almeno qualche centinaio di righe
-- Regola pratica: lists = sqrt(n_rows), con minimo 100

-- Creazione indice IVFFlat
CREATE INDEX documents_ivfflat_idx
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (
    lists = 100   -- numero di cluster (default 100, raccomandato: sqrt(n_rows))
);

-- Per 1M righe: lists = sqrt(1000000) = 1000
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);

-- Configurazione probes (quanti cluster cercare a query time)
SET ivfflat.probes = 10;  -- default, cerca 10 cluster su 1000

-- Alta precisione:
SET ivfflat.probes = 50;  -- cerca 50/1000 cluster = 5% dello spazio

-- Benchmark indicativo (1M vettori, lists=1000):
-- probes=5:   ~3ms/query,  recall@10 ~72%
-- probes=10:  ~6ms/query,  recall@10 ~82%
-- probes=30:  ~18ms/query, recall@10 ~94%
-- probes=100: ~60ms/query, recall@10 ~99%

-- IVFFlat vs HNSW a parita di recall ~92%:
-- IVFFlat (probes=50): ~28ms  - più lento!
-- HNSW (ef_search=40): ~10ms  - più veloce
-- Conclusione: per recall alta, HNSW e solitamente più efficiente

HNSW vs IVFFlat: Guida alla Scelta

Caratteristica HNSW IVFFlat
Query latency Più veloce (spesso 2-5x a parita recall) Più lenta a high recall
Build time Più lento (K-means non serve ma build e più complessa) Molto più veloce (~4x)
Memoria indice Maggiore (~2-4x) Minore
Recall a parita di latency Migliore (generalmente) Leggermente inferiore
Insert incrementale Ottimo (nessun re-training) Degrada: cluster non si riaggiustano
Dati richiesti per build Nessuno (può iniziare vuoto) Richiede dati esistenti (K-means)
Dataset tipico Dataset crescenti, alta precisione, RAG Dataset statici, build veloce prioritaria

Regola Pratica per la Scelta

  • Usa HNSW nella maggior parte dei casi: migliore recall, query più veloci, gestisce gli insert bene. Questa e la scelta giusta per il 90% dei sistemi RAG.
  • Usa IVFFlat quando: hai un dataset di miliardi di vettori e la memoria per HNSW e insufficiente, o quando hai bisogno di ricostruire l'indice frequentemente su dati che cambiano completamente.
  • Usa brute force (nessun indice) quando: hai meno di 50K vettori, o hai bisogno di 100% recall garantito (es. ricerca legale, compliance).

Ottimizzazione delle Query con Filtri Ibridi

Uno dei problemi più comuni in produzione e la combinazione di filtri sui metadati con la ricerca vettoriale. pgvector 0.7+ gestisce questo con l'iterative scan, ma e importante capire come strutturare le query correttamente per evitare il problema del post-filtering.

Il Problema del Post-Filtering e la Soluzione

-- SBAGLIATO: post-filtering - l'ANN trova i top-k globali, poi filtra
-- Se i top-k risultati ANN non soddisfano il filtro, ottieni meno di k risultati!
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10
-- Il problema: se questi 10 risultati non soddisfano source_type='pdf',
-- non ottieni 10 risultati PDF ma potenzialmente 0!

-- CORRETTO: pre-filtering in pgvector con iterative scan
-- pgvector 0.7+ supporta indexed scan con filtri WHERE
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf'           -- pre-filter: applicato PRIMA dell'ANN
  AND language = 'en'
ORDER BY embedding <=> query_vec   -- ANN su subset filtrato
LIMIT 10;

-- EXPLAIN verifica che usi l'indice con il filtro:
EXPLAIN (ANALYZE, FORMAT TEXT)
SELECT id, content, embedding <=> '[...]'::vector AS dist
FROM documents
WHERE source_type = 'pdf'
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Dovrebbe mostrare: "Index Scan using ... with Filter: (source_type = 'pdf')"
-- Se vedi Seq Scan, il filtro potrebbe ridurre troppo il dataset
-- In quel caso, un indice parziale e la soluzione migliore

Partial Indexes per Filtri Frequenti

-- Se filtri sempre per source_type, crea un indice parziale dedicato
-- Vantaggi: molto più piccolo e veloce dell'indice globale

CREATE INDEX documents_hnsw_pdf
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE source_type = 'pdf';  -- solo i documenti PDF

CREATE INDEX documents_hnsw_recent
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE created_at > NOW() - INTERVAL '30 days';  -- solo documenti recenti

-- Query che sfruttano automaticamente gli indici parziali
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf'  -- attiva documents_hnsw_pdf automaticamente
ORDER BY embedding <=> query_vec
LIMIT 10;

-- Confronto dimensioni: indice parziale vs totale
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size,
    ROUND(pg_relation_size(indexrelid)::numeric /
          (SELECT pg_relation_size(indexrelid) FROM pg_stat_user_indexes
           WHERE indexname = 'documents_hnsw_total') * 100, 1) AS pct_of_full
FROM pg_stat_user_indexes
JOIN pg_class ON pg_class.relname = pg_stat_user_indexes.indexname
WHERE tablename = 'documents';
-- documents_hnsw_total: 2.1 GB  (100%)
-- documents_hnsw_pdf:   487 MB  (23% - solo i PDF)

Query con Threshold di Similarità

-- Filtra per distanza minima: evita risultati irrilevanti
-- cosine distance < 0.4 significa cosine similarity > 0.6

SELECT
    id,
    source_path,
    content,
    1 - (embedding <=> query_vec::vector) AS similarity,
    embedding <=> query_vec::vector AS distance
FROM documents
WHERE
    embedding <=> query_vec::vector < 0.4  -- max distance (= min 60% similarity)
ORDER BY embedding <=> query_vec::vector
LIMIT 10;

-- Conversione distanza/similarità per cosine:
-- cosine_distance = 1 - cosine_similarity
-- distance 0.0 = similarity 1.0 (vettori identici)
-- distance 0.2 = similarity 0.8 (molto simili)
-- distance 0.4 = similarity 0.6 (sufficientemente simili per RAG)
-- distance 0.7 = similarity 0.3 (probabilmente non rilevante)
-- distance 1.0 = similarity 0.0 (non correlati)
-- distance 2.0 = similarity -1.0 (opposti)

-- Threshold raccomandati per use case:
-- RAG documenti tecnici:   < 0.35 (similarity > 0.65)
-- FAQ answering:           < 0.30 (similarity > 0.70)
-- Product recommendation:  < 0.50 (similarity > 0.50)
-- Duplicate detection:     < 0.10 (similarity > 0.90)

Semantic Search vs Similarity Search

I termini sono spesso usati in modo intercambiabile, ma c'è una distinzione importante che influenza l'architettura del sistema:

Tipo Obiettivo Esempio Metrica
Similarity Search Trovare vettori vicini a un vettore dato Immagini simili a questa immagine Distanza L2, cosine
Semantic Search Trovare documenti con significato simile a una query testuale "Come si installa PostgreSQL?" trova guide senza quella frase esatta Cosine similarity su text embeddings
Keyword Search Match esatto di parole chiave "PostgreSQL 16" trova solo documenti con quella stringa TF-IDF, BM25
Hybrid Search Combina semantica e keyword Bilancia rilevanza semantica e match esatto di termini tecnici RRF, weighted sum

Implementazione Semantic Search Completa

import psycopg2
from openai import OpenAI

client = OpenAI()

def semantic_search(
    conn,
    query: str,
    top_k: int = 10,
    source_type: str = None,
    min_similarity: float = 0.6,
    include_metadata: bool = True
) -> list[dict]:
    """
    Ricerca semantica con filtri opzionali e threshold di qualità.

    Args:
        conn: Connessione psycopg2 a PostgreSQL
        query: La domanda o testo da cercare
        top_k: Numero di risultati da restituire
        source_type: Filtra per tipo documento ('pdf', 'md', 'html')
        min_similarity: Soglia minima di similarità coseno (0.0-1.0)
        include_metadata: Se includere il campo metadata JSONB

    Returns:
        Lista di dict con id, source_path, content, similarity, metadata
    """
    # 1. Genera embedding della query
    resp = client.embeddings.create(
        input=[query.replace("\n", " ")],
        model="text-embedding-3-small"
    )
    query_vec = resp.data[0].embedding
    max_distance = 1 - min_similarity  # converti similarity -> distance

    # 2. Costruisci query SQL dinamica con filtri opzionali
    params = [query_vec, max_distance]
    filter_clauses = ["embedding <=> %s::vector < %s"]

    if source_type:
        filter_clauses.append("source_type = %s")
        params.append(source_type)

    where = " AND ".join(filter_clauses)
    metadata_col = "metadata" if include_metadata else "NULL::jsonb"

    sql = f"""
        SELECT
            id,
            source_path,
            source_type,
            chunk_index,
            title,
            content,
            1 - (embedding <=> %s::vector) AS similarity,
            {metadata_col} AS metadata
        FROM documents
        WHERE {where}
        ORDER BY embedding <=> %s::vector
        LIMIT %s
    """

    params_final = [query_vec] + params + [query_vec, top_k]

    with conn.cursor() as cur:
        cur.execute(sql, params_final)
        rows = cur.fetchall()

    return [
        {
            "id": r[0],
            "source_path": r[1],
            "source_type": r[2],
            "chunk_index": r[3],
            "title": r[4],
            "content": r[5],
            "similarity": round(r[6], 4),
            "metadata": r[7]
        }
        for r in rows
    ]

# Uso
conn = psycopg2.connect("postgresql://postgres:pass@localhost/vectordb")

results = semantic_search(
    conn,
    query="Come ottimizzare query PostgreSQL per dataset grandi",
    top_k=5,
    source_type="pdf",
    min_similarity=0.65
)

for r in results:
    print(f"[{r['similarity']:.3f}] {r['title']} - {r['content'][:100]}...")

Hybrid Search: Combinare Vector e Full-Text

Una delle grandi forze di PostgreSQL per il RAG e la possibilità di combinare in una sola query la ricerca semantica (vector) con la ricerca full-text classica (BM25-like). Questo e particolarmente utile per query che contengono termini tecnici precisi come nomi propri, codici, versioni software.

-- Hybrid search puro SQL: vector + full-text con Reciprocal Rank Fusion (RRF)
-- RRF Score = sum(1/(k + rank)) per ogni lista risultati

WITH vector_results AS (
    -- Ricerca semantica: top 20 per similarità vettoriale
    SELECT
        id, content, source_path,
        ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS v_rank,
        1 - (embedding <=> $1::vector) AS vector_score
    FROM documents
    ORDER BY embedding <=> $1::vector
    LIMIT 20
),
fts_results AS (
    -- Ricerca full-text: top 20 per rilevanza ts_rank
    SELECT
        id, content, source_path,
        ROW_NUMBER() OVER (
            ORDER BY ts_rank(to_tsvector('italian', content),
                             plainto_tsquery('italian', $2)) DESC
        ) AS f_rank,
        ts_rank(to_tsvector('italian', content),
                plainto_tsquery('italian', $2)) AS fts_score
    FROM documents
    WHERE to_tsvector('italian', content) @@ plainto_tsquery('italian', $2)
    LIMIT 20
),
rrf_combined AS (
    -- RRF fusion: combina i due rank
    SELECT
        COALESCE(v.id, f.id) AS id,
        COALESCE(v.content, f.content) AS content,
        COALESCE(v.source_path, f.source_path) AS source_path,
        -- Peso: 70% vector + 30% full-text
        COALESCE(0.7 / (60.0 + v.v_rank), 0) +
        COALESCE(0.3 / (60.0 + f.f_rank), 0) AS rrf_score,
        v.vector_score,
        f.fts_score
    FROM vector_results v
    FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT id, content, source_path, ROUND(rrf_score::numeric, 6) AS score
FROM rrf_combined
ORDER BY rrf_score DESC
LIMIT 5;

Benchmarking: Misurare la qualità della Ricerca

import time
import psycopg2
from statistics import mean, quantiles

def benchmark_vector_search(conn, query_vectors: list, config: dict) -> dict:
    """
    Esegui benchmark di latenza su un set di query vettoriali.

    Args:
        conn: Connessione PostgreSQL
        query_vectors: Lista di vettori query (1536 dimensioni)
        config: Dict con ef_search o probes da impostare

    Returns:
        Dict con metriche di latenza (p50, p95, p99, mean)
    """
    # Imposta parametri ANN per questo benchmark
    with conn.cursor() as cur:
        if "ef_search" in config:
            cur.execute(f"SET hnsw.ef_search = {config['ef_search']}")
        if "probes" in config:
            cur.execute(f"SET ivfflat.probes = {config['probes']}")

    latencies = []
    for query_vec in query_vectors:
        start = time.perf_counter()
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id, embedding <=> %s::vector AS dist
                FROM documents
                ORDER BY embedding <=> %s::vector
                LIMIT 10
            """, (query_vec, query_vec))
            cur.fetchall()
        elapsed_ms = (time.perf_counter() - start) * 1000
        latencies.append(elapsed_ms)

    p_values = quantiles(latencies, n=100)
    return {
        "config": config,
        "n_queries": len(query_vectors),
        "p50_ms": round(p_values[49], 2),
        "p95_ms": round(p_values[94], 2),
        "p99_ms": round(p_values[98], 2),
        "mean_ms": round(mean(latencies), 2),
        "min_ms": round(min(latencies), 2),
        "max_ms": round(max(latencies), 2)
    }

# Confronta diverse configurazioni HNSW
configs = [
    {"name": "HNSW ef=20 (fast)", "ef_search": 20},
    {"name": "HNSW ef=40 (default)", "ef_search": 40},
    {"name": "HNSW ef=100 (precise)", "ef_search": 100},
]

# Usa query di test reali (embedding generati con lo stesso modello dei documenti)
test_queries = [...]  # lista di vettori 1536-dim

print(f"Benchmark con {len(test_queries)} query di test:")
for cfg in configs:
    result = benchmark_vector_search(conn, test_queries[:100], cfg)
    print(f"{cfg['name']}:")
    print(f"  p50={result['p50_ms']}ms, p95={result['p95_ms']}ms, p99={result['p99_ms']}ms")

Recall Measurement: Verificare la qualità ANN

def measure_recall_at_k(conn, query_vectors: list, k: int = 10) -> float:
    """
    Misura Recall@K confrontando ANN con brute force.

    Recall@K = (risultati ANN corretti) / k

    Un Recall@10 di 0.95 significa che l'ANN trova
    9.5 dei 10 risultati esatti su media.

    Nota: usa un campione di 50-100 query per stabilità statistica.
    """
    total_recall = 0.0

    for query_vec in query_vectors:
        # Risultati esatti (brute force) - ground truth
        with conn.cursor() as cur:
            cur.execute("SET enable_indexscan = off")
            cur.execute("""
                SELECT id FROM documents
                ORDER BY embedding <=> %s::vector
                LIMIT %s
            """, (query_vec, k))
            exact_ids = set(r[0] for r in cur.fetchall())
            cur.execute("SET enable_indexscan = on")

        # Risultati ANN (con indice HNSW/IVFFlat)
        with conn.cursor() as cur:
            cur.execute("""
                SELECT id FROM documents
                ORDER BY embedding <=> %s::vector
                LIMIT %s
            """, (query_vec, k))
            ann_ids = set(r[0] for r in cur.fetchall())

        # Recall = overlap / k
        overlap = len(exact_ids & ann_ids)
        total_recall += overlap / k

    avg_recall = total_recall / len(query_vectors)
    print(f"Recall@{k}: {avg_recall:.4f} ({avg_recall*100:.1f}%)")
    return avg_recall

# Target di recall per diversi use case:
# Recall@10 > 0.90 per RAG generale
# Recall@10 > 0.95 per RAG enterprise (medico, legale)
# Recall@10 > 0.85 accettabile per autocomplete/recommendation

# Con HNSW m=16, ef_construction=64, ef_search=40: tipicamente 0.92-0.95
# Con HNSW m=16, ef_construction=64, ef_search=100: tipicamente 0.97-0.99

Ottimizzazione Avanzata: Query Planning

-- 1. Statistiche aggiornate: fondamentale per buone query plans
ANALYZE documents;

-- 2. Verifica dimensione e statistiche dell'indice HNSW
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan AS times_used,
    idx_tup_read AS tuples_read,
    idx_tup_fetch AS tuples_fetched,
    ROUND(idx_tup_fetch::numeric / NULLIF(idx_scan, 0), 1) AS avg_tuples_per_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
ORDER BY idx_scan DESC;

-- 3. Parametri di memoria per performance ottimale
SET maintenance_work_mem = '512MB';  -- per BUILD dell'indice HNSW (temporaneo)
SET work_mem = '64MB';               -- per query sort operations

-- 4. Parallel query per dataset grandi
SET max_parallel_workers_per_gather = 4;  -- usa 4 worker per query

-- 5. Verifica che shared_buffers sia adeguato
-- L'indice HNSW deve stare in memoria per performance ottimale
SHOW shared_buffers;
-- Regola pratica: shared_buffers = 25% della RAM
-- pg_relation_size(indice HNSW) deve essere << shared_buffers

-- 6. Monitora cache hit ratio per la tabella
SELECT
    relname,
    heap_blks_read AS disk_reads,
    heap_blks_hit AS cache_hits,
    ROUND(heap_blks_hit::numeric / NULLIF(heap_blks_read + heap_blks_hit, 0) * 100, 2)
        AS cache_hit_pct
FROM pg_statio_user_tables
WHERE relname = 'documents';
-- Target: cache_hit_pct > 95% per performance ottimale

-- 7. Verifica indici non utilizzati (candidati per DROP):
SELECT indexname, idx_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
  AND idx_scan = 0
  AND indexname NOT LIKE '%pkey%';  -- escludi primary keys

Maximal Marginal Relevance (MMR)

Un problema comune nella similarity search e la ridondanza dei risultati: i top-k chunk possono essere tutti molto simili tra loro, coprendo la stessa informazione. Questo riduce la qualità del contesto RAG perchè il modello riceve le stesse informazioni ripetute. MMR bilancia rilevanza e diversità selezionando progressivamente il prossimo chunk che e rilevante per la query MA diverso dai già selezionati.

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

def maximal_marginal_relevance(
    query_vec: list[float],
    candidate_vecs: list[list[float]],
    candidate_data: list[dict],
    k: int = 5,
    lambda_param: float = 0.5
) -> list[dict]:
    """
    MMR: seleziona k risultati bilanciando rilevanza e diversità.

    Algoritmo:
    1. Seleziona sempre il candidato più rilevante per la query
    2. Per ogni successivo candidato, usa MMR score che penalizza
       la ridondanza rispetto ai già selezionati

    lambda_param:
        1.0 = solo rilevanza (equivale a top-k standard)
        0.0 = solo diversità (massima varieta)
        0.5 = bilanciamento ottimale per RAG (default)
        0.6 = leggermente più rilevanza rispetto diversità

    Returns:
        Lista di k candidati diversificati e rilevanti
    """
    query_array = np.array(query_vec).reshape(1, -1)
    candidate_array = np.array(candidate_vecs)

    # Similarità query-candidato (rilevanza)
    query_sims = cosine_similarity(query_array, candidate_array)[0]

    selected = []
    selected_indices = []
    remaining_indices = list(range(len(candidate_vecs)))

    while len(selected) < k and remaining_indices:
        if not selected:
            # Prima iterazione: scegli il più rilevante
            best_idx = int(np.argmax(query_sims))
        else:
            # Iterazioni successive: MMR score
            selected_array = candidate_array[selected_indices]
            mmr_scores = []
            for i in remaining_indices:
                relevance = query_sims[i]
                # Massima similarità con i già selezionati (ridondanza)
                max_redundancy = float(np.max(
                    cosine_similarity(candidate_array[i:i+1], selected_array)[0]
                ))
                # MMR bilancia rilevanza e ridondanza
                mmr = lambda_param * relevance - (1 - lambda_param) * max_redundancy
                mmr_scores.append((i, mmr))
            best_idx = max(mmr_scores, key=lambda x: x[1])[0]

        selected.append(candidate_data[best_idx])
        selected_indices.append(best_idx)
        remaining_indices.remove(best_idx)

    return selected

# Uso con risultati PostgreSQL
# Prima recupera più candidati (top-20), poi applica MMR per top-5 diversificati
chunks_with_vecs = searcher.vector_search_with_embeddings(query, top_k=20)
embeddings = [c['embedding'] for c in chunks_with_vecs]
data = [{'id': c['id'], 'content': c['content']} for c in chunks_with_vecs]

diverse_chunks = maximal_marginal_relevance(
    query_vec=query_embedding,
    candidate_vecs=embeddings,
    candidate_data=data,
    k=5,
    lambda_param=0.6  # leggermente orientato alla rilevanza
)
# Risultato: 5 chunk rilevanti MA con contenuti diversi tra loro

Errori Comuni nella Similarity Search

  • ANN non attivato: Se EXPLAIN mostra un Seq Scan, controlla che il LIMIT sia ragionevole rispetto alla dimensione della tabella, che l'indice esista, e che le statistiche siano aggiornate (ANALYZE).
  • ef_search troppo basso: Con ef_search=10 potresti ottenere Recall@10 di solo 70-75%. Per produzione, usa almeno 40, meglio 60-100 per RAG.
  • Post-filtering che svuota i risultati: Se applichi filtri WHERE dopo l'ORDER BY (senza pre-filtering), l'ANN restituisce i k globali e poi filtra, potenzialmente dando 0 risultati. Usa sempre pre-filtering.
  • Operatore di distanza sbagliato: Usare <-> (L2) invece di <=> (cosine) per text embeddings riduce drammaticamente la qualità della ricerca. Verifica che il type di ops nell'indice corrisponda all'operatore nella query.
  • Nessun threshold di similarità: Restituire tutti i top-k anche quando la distanza e molto alta porta a risposte irrilevanti nel RAG.
  • Ignorare la ridondanza: I top-5 chunk potrebbero coprire tutti la stessa frase del documento. Usa MMR per diversità nelle risposte RAG.

Conclusioni e Prossimi Passi

La similarity search in PostgreSQL e un campo con molti parametri da configurare. La chiave e capire il trade-off fondamentale: più candidati si esaminano, più alta e la recall ma più alta e la latenza. Per la maggior parte dei sistemi RAG in produzione, HNSW con ef_search=60-100 offre un ottimo equilibrio tra 10ms e 50ms di latenza con Recall@10 del 92-97%.

Il prossimo articolo approfondisce le strategie di indicizzazione avanzate: come scegliere i parametri HNSW e IVFFlat ottimali per il tuo caso d'uso specifico, come monitorare la degradazione degli indici nel tempo, e le tecniche per aggiornamenti incrementali senza perdita di performance. Incluso: configurazione PostgreSQL completa, parallel index build, e come schedulare il REINDEX senza downtime.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti