Salta al contenuto principale

96 - Embedding e ricerca semantica: un indice vettoriale costruito da zero, e i casi in cui sbaglia

Un indice vettoriale in NumPy sui docstring di Python: coseno ed euclidea ordinano uguale, il punteggio non è una percentuale, e BM25 si fonde col denso.

Cosa ne pensi di questo articolo?

96 - Embedding e ricerca semantica: un indice vettoriale costruito da zero, e i casi in cui sbaglia
Machine Learning Engineerfedericocalo.dev

In Breve

Un indice vettoriale in NumPy sui docstring di Python: coseno ed euclidea ordinano uguale, il punteggio non è una percentuale, e BM25 si fonde col denso.

In una biblioteca normale trovi un libro se sai il titolo: il catalogo è ordinato per una stringa, e se non è quella sul dorso il libro per te non esiste. Immagina invece una biblioteca disposta per argomento percepito, dove due volumi che dicono la stessa cosa con parole diverse stanno vicini. Il prezzo sta in una frase: «di cosa parla» lo decide un modello, e quel modello ha gusti suoi. Questa disposizione è un indice vettoriale.

La costruiamo da zero: un corpus di docstring della libreria standard di Python, un encoder, l'indice esatto in poche righe di NumPy, BM25 implementato dal paper, recall e MRR su query etichettate, la fusione dei ranghi reciproci e una IVF semplificata. Ogni numero che trovi è stato prodotto da un listato riportato qui, eseguito due volte con la stessa uscita. Su un punto sono esplicito subito: in questa lezione non gira nessun modello.

Come sono scritti gli esempi. Un encoder vero (per esempio nomic-embed-text servito da Ollama in locale) richiede un runtime di modelli, e i suoi vettori non si riproducono senza. Per questo gli esempi usano un encoder finto: un hashing deterministico di parole e trigrammi di caratteri, 768 dimensioni, scritto qui sotto. Non conosce i sinonimi, e proprio per questo serve: l'indice, le metriche, la fusione e l'IVF si comportano allo stesso modo con qualunque encoder, mentre quanto l'encoder capisca il significato si misura solo con quello vero, sul tuo corpus. Dove una cifra dipende da un encoder addestrato, la lezione non la riporta. Versioni usate: Python 3.14.4, NumPy 2.3.5, scikit-learn 1.8.0, su un portatile con Ryzen 5 5600H e 30 GB di RAM.

L'analogia: la biblioteca ordinata per significato

Le due biblioteche sbagliano in modo opposto. Quella per titolo sbaglia per omissione: cerchi «come misuro quanto due testi si somigliano», il libro si chiama Sequence matching, e non lo trovi - ma te ne accorgi, perché il risultato è vuoto. Quella per significato sbaglia per sostituzione: ti porta un libro sui confronti fra oggetti in generale, plausibile e sbagliato. E di questo non te ne accorgi.

L'analogia regge su un punto meno confortante: il bibliotecario che dispone per significato ha letto molto, ma in una lingua e in un dominio. E qui non esiste la risposta «non c'è»: lo scaffale restituisce sempre i k volumi meno distanti, qualunque sia la domanda.

Che cos'è un embedding, in termini operativi

Un embedding è una funzione appresa f: testo → R^d in cui la distanza ha un significato: testi che un lettore considererebbe affini finiscono vicini. Niente di mistico: è addestrata su coppie etichettate come affini, e impara la nozione di affinità contenuta in quei dati. Nell'esempio d = 768.

La prima distinzione che conta è fra embedding statici e contestuali. Negli statici - word2vec di Mikolov e colleghi, Efficient Estimation of Word Representations in Vector Space, arXiv:1301.3781 - ogni parola ha un vettore, sempre lo stesso. Nei contestuali, costruiti sul Transformer della lezione sull'attenzione, il vettore dipende da tutto il testo: key in «dictionary key» e in «front door key» riceve vettori diversi. Per chiamare un encoder vero basta una richiesta HTTP; il listato seguente è lo schema, non eseguito perché serve Ollama in ascolto.

# SCHEMA DA ADATTARE, NON ESEGUITO: richiede Ollama in ascolto su localhost
import numpy as np, urllib.request, json

def embed(testi, modello="nomic-embed-text", lotto=48):
    """Vettori L2-normalizzati, in lotti: una chiamata per ogni 48 testi."""
    pezzi = []
    for i in range(0, len(testi), lotto):
        richiesta = urllib.request.Request(
            "http://localhost:11434/api/embed",
            data=json.dumps({"model": modello, "input": testi[i:i + lotto]}).encode(),
            headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(richiesta, timeout=600) as r:
            pezzi.append(np.array(json.loads(r.read())["embeddings"], dtype=np.float32))
    E = np.vstack(pezzi)
    return E / np.linalg.norm(E, axis=1, keepdims=True)   # mai fidarsi: normalizza sempre

Il listato manda i testi a lotti di 48 (una chiamata per lotto, non una per testo) e normalizza le righe: ripetere la normalizzazione costa niente e protegge da un servizio che restituisce vettori non unitari. Il resto della lezione usa invece questo encoder finto, che ha la stessa firma ma nessun modello dietro.

import hashlib, math, re, collections
import numpy as np

def _hash(f):
    return int.from_bytes(hashlib.blake2b(f.encode(), digest_size=8).digest(), "big")

def parole_di(testo):
    testo = re.sub(r"([a-z])([A-Z])", r"\1 \2", testo)     # camelCase -> camel Case
    return re.findall(r"[a-z0-9]+", testo.lower())

def embed(testi, d=768):
    """Encoder FINTO: hashing con segno di parole e trigrammi di caratteri. Nessun modello."""
    E = np.zeros((len(testi), d), dtype=np.float32)
    for r, t in enumerate(testi):
        feat = collections.Counter()
        for p in parole_di(t):
            feat["w:" + p] += 1
            s = f"^{p}$"
            for k in range(len(s) - 2):
                feat["c:" + s[k:k + 3]] += 1               # trigrammi: "compress" ~ "compression"
        for f, c in feat.items():
            h = _hash(f)
            E[r, h % d] += (1 if (h >> 40) & 1 else -1) * (1 + math.log(c))
    return E / np.linalg.norm(E, axis=1, keepdims=True)    # mai fidarsi: normalizza sempre

Cosa fa il codice. Spezza il testo in parole (anche i nomi in camelCase), poi per ogni parola somma nel vettore una componente per la parola intera e una per ciascun trigramma di caratteri, con segno e posizione decisi da un hash. Usa blake2b e non hash(), perché quest'ultimo cambia a ogni esecuzione e renderebbe l'uscita non riproducibile. I trigrammi sono l'unica generalizzazione che ha: compress e compression si somigliano. Non sa che «temporary» e «disappears when closed» parlano della stessa cosa.

Questo basta per sapere cosa aspettarsi da due strade diverse di recupero. Un encoder addestrato, come quello di Sentence-BERT (Reimers e Gurevych, Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084), è costruito proprio per mettere vicine frasi affini anche senza parole in comune, e fa meglio di una media dei vettori delle singole parole; di quanto, sul tuo corpus, lo decide una misura che la lezione ti insegna a fare ma che qui non posso riportare.

Il coseno, e la cosa che quasi tutti fraintendono

La similarità del coseno è il coseno dell'angolo fra due vettori: prodotto scalare diviso per il prodotto delle norme. Leggila come una frase: somma i prodotti delle componenti corrispondenti, poi togli di mezzo la lunghezza dividendo per le norme. Resta la direzione, ed è il motivo per cui non si usa il prodotto scalare nudo: la norma cresce col documento, e i testi lunghi vincerebbero per il solo fatto di esserlo.

cos(a, b) = (a · b) / (‖a‖ · ‖b‖)        con  a · b = Σᵢ aᵢ · bᵢ

Ora la parte che quasi tutti fraintendono: su vettori normalizzati, ordinare per coseno decrescente è ordinare per distanza euclidea crescente. La dimostrazione è di tre righe, con ‖a‖ = ‖b‖ = 1.

‖a − b‖² = (a − b) · (a − b)
         = ‖a‖² − 2·(a · b) + ‖b‖²
         = 2 − 2·cos(a, b)                   ∎

La distanza al quadrato è strettamente decrescente nel coseno, quindi i due ordinamenti coincidono. Conseguenza pratica: la scelta fra coseno, prodotto scalare e distanza euclidea non cambia il ranking, cambia il codice - e conviene il prodotto scalare, una moltiplicazione sola senza radici né divisioni.

D64 = D.astype(np.float64); a = D64[0]
dist2 = ((D64 - a) ** 2).sum(axis=1); cos64 = D64 @ a
print("float64: scarto massimo |dist^2 - (2 - 2cos)| =", f"{np.abs(dist2 - (2 - 2 * cos64)).max():.1e}")
print("float64: stesso ordinamento:", bool((np.argsort(dist2, kind="stable") == np.argsort(-cos64, kind="stable")).all()))
cos32 = D @ D[0]; dist32 = ((D - D[0]) ** 2).sum(axis=1)
diversi = int((np.argsort(-cos32, kind="stable") != np.argsort(dist32, kind="stable")).sum())
print(f"float32: posizioni diverse fra i due ordinamenti: {diversi} su {len(D)}")
float64: scarto massimo |dist^2 - (2 - 2cos)| = 1.9e-07
float64: stesso ordinamento: True
float32: posizioni diverse fra i due ordinamenti: 0 su 292

Sull'indice del corpus (vettori dell'encoder finto) lo scarto massimo fra ‖a − b‖² e 2 − 2·cos in float64 è dell'ordine di 1e-07 e i due ordinamenti coincidono su tutti i documenti. In float32 il teorema regge ancora, ma due coseni quasi uguali possono ordinarsi diversamente a seconda del calcolo: se un ranking cambia fra due esecuzioni, cerca un pari merito prima di cercare un bug.

L'anisotropia: perché 0,78 non vuol dire «78% simile»

Prendi due testi senza nessuna relazione, la documentazione di turtle contro quella di hmac. L'intuizione dice che il loro coseno sta intorno a zero. Per i modelli veri spesso non è così: i vettori non riempiono la sfera ma occupano un cono, e il coseno fra testi estranei è un numero alto. Il fenomeno si chiama anisotropia; Ethayarajh (How Contextual are Contextualized Word Representations? Comparing the Geometry of BERT, ELMo, and GPT-2 Embeddings, arXiv:1909.00512) mostra che le rappresentazioni contestuali di ELMo, BERT e GPT-2 non sono isotrope in nessuno strato. Il paper riguarda le rappresentazioni delle parole nei modelli contestuali, non ogni encoder di frasi: se il tuo la ha, e quanta, la scopri misurando.

Qui non misuro un modello: simulo il meccanismo, che è abbastanza semplice da calcolare. Parto da vettori isotropi e aggiungo a tutti la stessa direzione m con peso a. Il prodotto scalare di due vettori così costruiti è circa a² e la norma al quadrato circa 1 + a², quindi il coseno atteso fra due casuali è a² / (1 + a²).

rng = np.random.default_rng(42)
m = rng.standard_normal(768); m /= np.linalg.norm(m)         # una direzione comune a tutti i vettori
G = rng.standard_normal((4000, 768))                          # vettori isotropi...
G /= np.linalg.norm(G, axis=1, keepdims=True)
for a in (0.0, 0.5, 1.0, 1.5, 2.0):
    V = G + a * m                                             # ...con una componente fissa di peso a
    V /= np.linalg.norm(V, axis=1, keepdims=True)
    c = np.einsum("ij,ij->i", V[:2000], V[2000:])             # coseno fra 2000 coppie casuali
    p1, p99 = np.percentile(c, [1, 99])
    print(f"{a:5.1f} {a*a/(1+a*a):20.4f} {c.mean():8.4f} {p1:8.4f} {p99:8.4f}")
    a  teorico a^2/(1+a^2)    media       1%      99%
  0.0               0.0000   0.0017  -0.0868   0.0821
  0.5               0.2000   0.2008   0.1226   0.2759
  1.0               0.5000   0.5005   0.4492   0.5499
  1.5               0.6923   0.6927   0.6612   0.7225
  2.0               0.8000   0.8004   0.7807   0.8190
atteso per vettori isotropi: dev.std 1/sqrt(768) = 0.0361

Che cosa ci dice. La colonna teorica e la media misurata coincidono alla terza cifra, e la banda fra il 1° e il 99° percentile è stretta: con a = 1,5 due vettori casuali stanno fra 0,66 e 0,72, e un coseno di 0,70 non significa niente. Con a = 0 invece la deviazione standard è quella teorica degli isotropi, 1/√768 = 0,0361. Tre conseguenze che valgono per qualunque encoder con questo difetto. Un coseno non è una percentuale. Non è confrontabile fra modelli, perché ognuno ha la propria a. Conta solo l'ordine, e le metriche della prossima sezione guardano la posizione.

Per il tuo encoder la domanda pratica è una sola: quanto vale il coseno medio fra coppie di documenti scelte a caso nel tuo corpus? Se è lontano da zero, qualunque soglia assoluta presa da un altro progetto cade nella banda del rumore. È una riga di codice, la stessa del listato qui sopra con i tuoi vettori al posto di V, e va rifatta a ogni cambio di modello.

Esercizio 1

Lo stesso ordine, e un punteggio da interpretare

Due vettori in tre dimensioni, non normalizzati: a = (3, 0, 4) e b = (0, 5, 0); e un terzo, c = (6, 0, 8), che è esattamente 2a.

  1. Calcola cos(a, b) e cos(a, c), poi normalizza e calcola ‖â − b̂‖ e ‖â − ĉ‖. L'ordine dei candidati cambia fra le due misure?
  2. Un collega dice che il suo motore «usa il prodotto scalare, più veloce del coseno», e mostra a · b = 0 contro a · c = 50. Che cosa sta misurando davvero?
  3. Su un indice ipotetico il coseno fra coppie casuali ha media 0,64 e 99° percentile 0,76. Un risultato torna con punteggio 0,78: che cosa puoi dire, e che cosa non puoi dire?

Prima di aprire la soluzione prova a rispondere con carta e penna: i tre punti toccano la dimostrazione del teorema, la differenza fra prodotto scalare e coseno, e la lettura di un punteggio, che sono i tre errori più frequenti di chi costruisce il primo indice.

Mostra la soluzione

1. ‖a‖ = ‖b‖ = 5 e ‖c‖ = 10; a · b = 0 dà cos(a, b) = 0, a · c = 18 + 32 = 50 dà cos(a, c) = 1. Normalizzati: â = ĉ = (0,6; 0; 0,8) e b̂ = (0; 1; 0), quindi ‖â − ĉ‖ = 0 e ‖â − b̂‖ = √2 ≈ 1,4142. L'ordine non cambia, ed è la verifica di ‖â − b̂‖² = 2 − 2·cos: 2 − 2·0 = 2 = (√2)².

2. Su vettori già normalizzati ha ragione: stesso numero, senza divisioni. Su questi sta misurando una miscela di direzione e lunghezza - c ha il contenuto di a e norma doppia. In un corpus vero la norma cresce col documento: il risultato è un motore che preferisce i testi lunghi, e non solleva eccezioni.

3. Puoi dire che 0,78 sta sopra il 99° percentile delle coppie casuali di quel corpus con quel modello: un segnale forte in senso relativo. Non puoi dire «simili al 78%», confrontarlo con lo 0,78 di un altro modello, né concluderne che il documento risponde alla domanda: il primo risultato ha un punteggio alto anche quando è sbagliato.

Gli esercizi servono a fissare tre idee che si confondono facilmente. Il primo punto è la dimostrazione, il secondo la differenza fra prodotto scalare e coseno quando le norme non sono unitarie, il terzo la lettura di un punteggio. Se la soluzione del secondo ti sorprende, rileggi la sezione sul coseno: è la trappola numero uno dell'elenco in fondo.

Il corpus e le query, costruiti offline

Per misurare un motore serve un insieme di recupero etichettato: query, documenti, e la risposta giusta per ogni query. La libreria standard di Python ne contiene uno già fatto: ogni funzione pubblica ha un docstring che dice cosa fa e un nome qualificato che è la domanda a cui risponde. L'etichetta è la corrispondenza fra i due, e non l'ha scritta nessuno.

import importlib, inspect, re

MODULI = ["abc", "argparse", "array", "ast", "asyncio", "base64", "bisect", "calendar",
          "cmath", "collections", "configparser", "contextlib", "copy", "csv", "dataclasses",
          "datetime", "decimal", "difflib", "enum", "fnmatch", "fractions", "functools",
          "getpass", "glob", "graphlib", "gzip", "hashlib", "heapq", "hmac", "html",
          "importlib", "inspect", "io", "ipaddress", "itertools", "json", "keyword",
          "linecache", "locale", "logging", "math", "mimetypes", "numbers", "operator",
          "pathlib", "pickle", "pprint", "queue", "random", "re", "reprlib", "sched",
          "secrets", "selectors", "shelve", "shlex", "shutil", "signal", "smtplib",
          "socket", "sqlite3", "statistics", "string", "struct", "subprocess", "tarfile",
          "tempfile", "textwrap", "threading", "time", "timeit", "tokenize", "traceback",
          "types", "typing", "unicodedata", "uuid", "warnings", "weakref", "zipfile",
          "zlib", "zoneinfo", "http.client", "urllib.parse", "concurrent.futures",
          "email.utils", "xml.dom.minidom", "wsgiref.util"]

def primo_blocco(doc: str) -> str:
    """I primi due paragrafi del docstring, ridotti a una riga sola."""
    righe = inspect.cleandoc(doc).split("\n")
    if righe and re.match(r"^[A-Za-z_][\w.]*\(.*\)?\s*(->.*)?$", righe[0].strip()):
        righe = righe[1:]                      # scarta la riga di firma, se c'e'
    blocchi = re.split(r"\n\s*\n", "\n".join(righe).strip())
    return re.sub(r"\s+", " ", " ".join(blocchi[:2])).strip()

def costruisci(tetto_per_modulo=10, lo=150, hi=900):
    conta, corpus = {}, []
    for nome_mod in MODULI:
        try:
            mod = importlib.import_module(nome_mod)
        except Exception:
            continue                           # modulo assente su questa piattaforma
        for nome, oggetto in sorted(vars(mod).items()):
            if nome.startswith("_"):
                continue
            if not (inspect.isfunction(oggetto) or inspect.isclass(oggetto)):
                continue
            if getattr(oggetto, "__module__", None) != mod.__name__:
                continue                       # le ri-esportazioni le conta il modulo di origine
            doc = inspect.getdoc(oggetto)
            if not doc:
                continue
            testo = primo_blocco(doc)
            if not (lo <= len(testo) <= hi) or conta.get(nome_mod, 0) >= tetto_per_modulo:
                continue
            conta[nome_mod] = conta.get(nome_mod, 0) + 1
            corpus.append({"nome": f"{nome_mod}.{nome}", "testo": testo, "intero": doc})
    return sorted(corpus, key=lambda d: d["nome"])
documenti: 292   moduli distinti: 66
parole per documento: mediana 40  media 45.0  min 11  max 133
documenti il cui testo contiene il proprio nome: 86 (29.5%)
esempio: abc.ABCMeta
   Metaclass for defining Abstract Base Classes (ABCs). Use this metaclass to create an ABC. ...

Cosa fa il codice. Tiene solo funzioni e classi definite in quel modulo - il controllo su __module__ evita che os.path.join finisca sotto tre nomi - riduce il docstring ai primi due paragrafi e mette un tetto di dieci voci per modulo, perché turtle e inspect riempirebbero mezzo corpus. Il numero di documenti dipende dalla versione di Python e dai moduli presenti: con un'altra versione otterrai un corpus un po' diverso e numeri vicini ma non identici, e va bene, perché quello che conta è il metodo.

I limiti dell'insieme, dichiarati invece che nascosti. È monotematico e in inglese: favorisce chi conosce il gergo e non dice nulla sulle altre lingue. Ha una sola risposta giusta per query, e questo rende le metriche pessimistiche - statistics.median_low davanti a statistics.median conta come errore pieno. E le query sono nomi, non domande: vantaggio massimo per il lessicale, ed è apposta.

L'indice esatto, in NumPy

Con i vettori normalizzati in una matrice (N, d), la ricerca è una moltiplicazione e una selezione parziale. Non serve una libreria: serve ricordarsi che argsort ordina tutti gli N elementi quando te ne servono dieci, mentre argpartition li seleziona in tempo lineare.

class IndiceEsatto:
    """Indice vettoriale esatto: una matrice normalizzata e un prodotto scalare."""

    def __init__(self, vettori, etichette):
        norme = np.linalg.norm(vettori, axis=1, keepdims=True)
        self.D = np.ascontiguousarray(vettori / norme, dtype=np.float32)
        self.etichette = etichette

    def cerca(self, q, k=10):
        q = np.asarray(q, dtype=np.float32)
        q = q / np.linalg.norm(q)
        punteggi = self.D @ q                      # (N,) coseni, in un colpo solo
        primi = np.argpartition(-punteggi, k)[:k]  # i k migliori, non ordinati fra loro
        primi = primi[np.argsort(-punteggi[primi])]
        return [(self.etichette[i], float(punteggi[i])) for i in primi]
        N    memoria  ms/query
     1000       3 MiB   0.66-0.70
    10000      29 MiB    6.4-6.5
   100000     293 MiB     61-72
   400000    1172 MiB    254-295

Come leggere la tabella. Matrici casuali di float32 a 768 dimensioni, mediana per punto di più ricerche, tre esecuzioni complete su questo portatile con BLAS limitato a due thread: il tempo è un intervallo, non un numero. Il costo è O(N·d): ogni query tocca tutti i numeri dell'indice, e la crescita è quasi lineare. La memoria è puro conto: N·768·4 byte, quindi un milione di documenti sono circa 2,9 GiB (calcolato, non allocato). In pratica l'esatto regge decine di millisecondi a centomila documenti; oltre, 250 ms e più per query rendono utile un indice approssimato. Sotto le decine di migliaia di documenti non serve altro che questa classe.

Come si misura se funziona

Tre metriche, tutte definite sul rango del documento giusto. Nessuna guarda il punteggio: dopo la sezione sull'anisotropia sai perché.

recall@k = (1/|Q|) · Σ_q  1[ rango(q) ≤ k ]                 frazione di query risolte entro k
MRR      = (1/|Q|) · Σ_q  1 / rango(q)                      premia il primo posto
nDCG@10  = (1/|Q|) · Σ_q  1 / log₂(rango(q) + 1)            se rango ≤ 10, altrimenti 0

Sono tre domande diverse. Recall@k: «la risposta è fra i primi k?». MRR: «quanto è in alto?», con guadagno 1 al primo posto, 0,5 al secondo, 0,2 al quinto. nDCG@10: la stessa idea con sconto logaritmico, la forma della letteratura di information retrieval.

La differenza fra «c'è fra le prime cinque» e «è prima» diventa decisiva nella lezione successiva sul RAG. Se passi al modello cinque frammenti e quello giusto è fra loro, il sistema può ancora rispondere bene: conta il recall@5. Se ne passi uno, conta solo il recall@1, e un sistema che sembra buono sul primo può essere rotto per metà delle domande sul secondo.

def rango_di(punteggi, indice_giusto):
    """Rango 1-based, con rango medio sui pari merito."""
    v = punteggi[indice_giusto]
    maggiori = int((punteggi > v).sum())
    pari = int((punteggi == v).sum())
    return maggiori + (pari - 1) / 2 + 1       # 1 solo se e' davvero il migliore

def metriche(ranghi):
    r = np.asarray(ranghi, dtype=float)
    return {"recall@1": float((r <= 1).mean()),
            "recall@5": float((r <= 5).mean()),
            "MRR": float((1 / r).mean()),
            "nDCG@10": float(np.mean(np.where(r <= 10, 1 / np.log2(r + 1), 0.0)))}

Il dettaglio che separa una misura da un'illusione: i pari merito. BM25 dà zero a ogni documento che non contiene nessun termine della query, e su questo corpus capita al documento giusto in 71 query su 292; in 26 di quelle nessun documento ottiene un punteggio. Col rango come «quanti lo battono, più uno», chi dà zero a tutti otterrebbe rango 1 su quelle 26 query: 8,9 punti di recall@1 regalati. Col rango medio, un pareggio fra 292 documenti vale 146,5.

Il confronto che riporta i piedi a terra

Il recupero lessicale non è un fossile. BM25 (Robertson e Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond, Foundations and Trends in Information Retrieval 3(4), 2009) è ancora la linea di base da battere, ed è venti righe: pesa ogni termine per la rarità, satura le ripetizioni, normalizza per la lunghezza.

punteggio(q, D) = Σ_{t ∈ q}  IDF(t) · ( tf(t,D) · (k₁ + 1) ) / ( tf(t,D) + k₁ · (1 − b + b · |D| / avgdl) )
IDF(t)          = ln( 1 + (N − df(t) + 0,5) / (df(t) + 0,5) )
con k₁ = 1,5 e b = 0,75
class BM25:
    def __init__(self, documenti_tokenizzati, k1=1.5, b=0.75):
        self.k1, self.b, self.docs = k1, b, documenti_tokenizzati
        self.N = len(self.docs)
        self.lung = np.array([len(d) for d in self.docs], dtype=np.float64)
        self.lung_media = self.lung.mean()
        self.freq = [collections.Counter(d) for d in self.docs]
        df = collections.Counter()
        for c in self.freq:
            df.update(c.keys())                 # una volta per documento, non per occorrenza
        self.idf = {t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in df.items()}

    def punteggi(self, query_tokens):
        s = np.zeros(self.N)
        for t in query_tokens:
            if t not in self.idf:
                continue                        # termine mai visto: non dice nulla
            tf = np.array([c.get(t, 0) for c in self.freq], dtype=np.float64)
            denom = tf + self.k1 * (1 - self.b + self.b * self.lung / self.lung_media)
            s += self.idf[t] * (tf * (self.k1 + 1)) / np.where(denom == 0, 1, denom)
        return s

Cosa fa il codice. Conta le frequenze in costruzione, ne ricava l'IDF, e alla query somma il contributo di ogni termine in forma vettoriale. La tokenizzazione fa una cosa che conta: spezza get_terminal_size in parole, altrimenti un nome di funzione sarebbe un termine unico che in prosa non compare mai.

Metto a confronto i due sistemi, più due fusioni, sulle stesse 292 query (il nome qualificato) e sullo stesso corpus. Attenzione a come leggere la riga «encoder finto»: non è un modello semantico, è un recupero per parole e trigrammi, quindi la tabella dice come si comportano le procedure (rango, fusione, taratura) e non quanto vale un encoder addestrato.

sistema (292 query = nomi qualificati) recall@1 recall@5 MRR nDCG@10
BM25 scritto da zero (lessicale)0,4040,7230,5370,587
encoder finto (parole e trigrammi)0,4040,8180,5870,656
ibrido, RRF con k = 60 (dal paper)0,4140,7530,5720,622
ibrido, RRF con k = 10, pesi 0,5 e 0,50,4210,8010,5880,655
ibrido, RRF con k = 10 e peso 0,7 al denso0,4350,8290,6010,670

L'ibrido coi parametri del paper - la fusione dei ranghi reciproci di Cormack, Clarke e Buettcher (Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods, SIGIR 2009), che somma 1/(k + rango) con k = 60 - guadagna un punto di recall@1 ma perde sei punti e mezzo di recall@5 contro il denso da solo: fondere liste di qualità diversa trascina la migliore verso la peggiore. Con k = 10 e peso 0,7 al denso la fusione vince su tutte e quattro le metriche, ma quei parametri li ho scelti guardando le stesse query su cui li misuro: il vantaggio è ottimista, e va confermato su una partizione che non hai usato per tarare.

def rrf(liste_di_punteggi, pesi=None, k=60, taglio=50):
    """Fusione dei ranghi reciproci sulle prime `taglio` posizioni di ogni lista."""
    n = len(liste_di_punteggi[0])
    pesi = pesi or [1.0] * len(liste_di_punteggi)
    s = np.zeros(n)
    for punteggi, peso in zip(liste_di_punteggi, pesi):
        ordine = np.argsort(-np.asarray(punteggi), kind="stable")[:taglio]
        for posizione, indice in enumerate(ordine, start=1):
            s[indice] += peso / (k + posizione)      # solo il rango, non il punteggio
        # un documento fuori dalle prime `taglio` non contribuisce con questo sistema
    return s
query in cui solo il denso mette il documento primo:  28
query in cui solo il lessicale lo mette primo:        28
query in cui lo mettono primo entrambi:               90
query in cui non lo mette primo nessuno dei due:     146
oracolo (il migliore dei due per ogni query): recall@1 = 0.500

Il punto della sezione. Ci sono 28 query che solo il lessicale risolve e 28 che solo il denso risolve: i due sbagliano in modo diverso, non correlato. Un oracolo che scegliesse il migliore dei due per ogni query arriverebbe a 0,500 di recall@1 contro 0,404, e la fusione serve a prendersi quel margine: con k = 10 e peso 0,7 se ne prende circa un terzo (0,435). Fondere non è un vezzo: è l'unico modo di non buttare via le query che uno dei due risolve da solo. Ma va tarato: adottato a occhio, qui perde su recall@5.

Dove vince l'uno, dove vince l'altro

Dove vince il lato denso, il nome condivide trigrammi di caratteri con il testo senza condividerne parole intere (è un'ipotesi, non l'ho verificata): string.capwords (rango 14 contro 178 del lessicale), smtplib.SMTPResponseException (2 contro 155,5), abc.update_abstractmethods (2 contro 150). Dove vince il lessicale, la query è un identificativo che compare per intero nel testo: statistics.kde_random (1 contro 10), ipaddress.ip_network (1 contro 6), collections.ChainMap (1 contro 5). Se nel tuo dominio esistono codici d'errore o identificativi, il lessicale è un componente da tenere.

Le parafrasi, un insieme a parte

Le parafrasi sono un caso a parte: le dodici query scritte da me in inglese senza il nome della funzione (per esempio «a file that disappears by itself when I close it», giusto tempfile.TemporaryFile) danno, su questo corpus, recall@1 pari a 0,250 per BM25 e 0,333 per l'encoder finto, recall@5 pari a 0,583 e 0,667. Nessuno dei due capisce il significato: sono numeri bassi per costruzione, ed è il punto. Con un encoder addestrato questa è la misura che deve salire, e non puoi dedurla dalla misura sui nomi, perché le due famiglie di query premiano cose diverse. Tienile in un insieme separato.

Esercizio 2

Fondere due classifiche a mano

Due sistemi ordinano cinque documenti per la stessa query; il giusto è C. Il semantico restituisce [B, C, A, E, D], il lessicale [A, D, C, B, E].

  1. Calcola il rango reciproco di C per i due sistemi, e il loro MRR su questa singola query.
  2. Fondi le due liste con 1/(k + rango), prima con k = 60 e poi con k = 1. In quale posizione finisce C, e perché k cambia il risultato?
  3. Un terzo sistema mette C primo ma sulle altre query è pessimo. Fondendo i tre con pesi uguali, che cosa rischi?

Fai i conti a mano su una tabella con i cinque documenti e le due colonne di ranghi: la fusione è una somma di frazioni, e vedere dove cambiano le distanze al variare di k spiega più di qualunque descrizione a parole.

Mostra la soluzione

1. C è secondo per il semantico e terzo per il lessicale: ranghi reciproci 1/2 = 0,5 e 1/3 ≈ 0,3333, che su una query sola sono anche l'MRR. Il semantico è migliore, ma nessuno dei due la risolve al primo posto.

2. Con k = 60: A = 1/63 + 1/61 = 0,032266, B = 1/61 + 1/64 = 0,032018, C = 1/62 + 1/63 = 0,032002, D = 0,031514, E = 0,031010. Con k = 1: A = 1/4 + 1/2 = 0,75, B = 1/2 + 1/5 = 0,7, C = 1/3 + 1/4 = 0,583333, D = 1/6 + 1/3 = 0,5, E = 0,366667. In entrambi i casi C resta terzo e vince A, primo in una lista e terzo nell'altra: cambiano solo le distanze. Il parametro k regola quanto conta la differenza fra le prime posizioni - grande appiattisce, piccolo premia chi è primo. Per questo da noi k = 10 batte k = 60: con due soli sistemi, uno netto vale più di due tiepidi.

3. Rischi di far decidere la fusione al sistema peggiore: RRF guarda solo le posizioni, quindi chi indovina per caso contribuisce con lo stesso 1/(k+1) di chi è affidabile. Rimedi: pesare le liste per la qualità misurata, tagliarle alle prime posizioni, e misurare la fusione su una partizione separata.

L'esercizio lega due cose che nel codice stanno lontane: il rango del documento giusto in una lista e il suo piazzamento dopo la fusione. Se il tuo risultato al punto 2 differisce dal mio, controlla di aver contato i ranghi da 1 e non da 0.

Indici approssimati: IVF misurata, HNSW descritta

Quando i 61-72 ms per query a centomila documenti diventano un problema, si smette di confrontare la query con tutto e si accetta di sbagliare qualche volta. È il patto degli indici approssimati: la velocità si compra in cambio di richiamo.

IVF (inverted file, nella forma descritta da Johnson, Douze e Jégou in Billion-scale similarity search with GPUs, arXiv:1702.08734) parte da un'idea elementare: raggruppa i vettori in nlist partizioni con k-means e, alla query, confronta prima i centroidi e poi solo le nprobe partizioni più promettenti. Se il vicino giusto sta in una partizione il cui centroide non è fra i più vicini, è perduto: da qui l'errore.

from sklearn.cluster import KMeans

class IVF:
    """IVF minimale: k-means per partizionare, poi scansione delle sole celle sondate."""

    def __init__(self, D, nlist=16, seed=0):
        self.D = D
        km = KMeans(n_clusters=nlist, n_init=3, random_state=seed).fit(D)
        C = km.cluster_centers_.astype(np.float32)
        self.C = C / np.linalg.norm(C, axis=1, keepdims=True)
        self.celle = [np.where(km.labels_ == c)[0] for c in range(nlist)]

    def cerca(self, q, k=10, sonde=1):
        vicinanza = self.C @ q                              # quali celle vale la pena aprire
        scelte = np.argpartition(-vicinanza, sonde - 1)[:sonde]
        candidati = np.concatenate([self.celle[c] for c in scelte])
        punteggi = self.D[candidati] @ q                    # la scansione, ma solo qui dentro
        k = min(k, len(candidati))
        primi = candidati[np.argpartition(-punteggi, k - 1)[:k]]
        return primi[np.argsort(-(self.D[primi] @ q))]

Per misurare il richiamo serve un insieme con una risposta esatta nota. Ho indicizzato i 1.874 vettori delle parole distinte dei docstring (stesso encoder finto, 768 dimensioni), con 200 parole come query; la verità è la ricerca esatta, e i pari merito col decimo vicino contano come corretti, perché con vettori sparsi molti punteggi coincidono e un'etichetta arbitraria falserebbe la misura. Lo dichiaro: sono vettori dell'encoder finto, non di un modello addestrato, quindi la forma della curva è informativa e i valori no.

insieme indicizzato: 1874 vettori da 768 dimensioni; 200 query
IVF con 16 celle: taglia minima 34, mediana 69, massima 543
 sonde   visitati   % corpus   richiamo@10
     1        236      12.6%         0.607
     2        412      22.0%         0.733
     4        712      38.0%         0.862
     6        929      49.6%         0.917
     8       1126      60.1%         0.952
    12       1491      79.5%         0.982
    16       1874     100.0%         1.000

Come leggere la curva. Con una sonda si visita il 12,6% dei vettori e si ritrova il 60,7% dei dieci vicini veri. Con quattro sonde si visita il 38% del corpus per 0,862, con otto il 60% per 0,952. La forma è quella che ci si aspetta: il richiamo sale in fretta e poi paga sempre di più gli ultimi punti, da 0,952 a 1,000 costa il 40% di corpus in più. Nota le taglie delle celle (da 34 a 543 vettori): k-means non le bilancia, e una cella grande pesa di più nel tempo. Per questo nessuno cerca il richiamo pieno: si decide quanto perdere e si tara nprobe. A questa taglia l'IVF non fa guadagnare tempo, perché la contabilità in Python costa più della scansione che evita; la lezione misura il richiamo, non la velocità.

HNSW (Malkov e Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs, arXiv:1603.09320) usa un grafo in cui ogni vettore è collegato a M vicini su più livelli sovrapposti: in alto pochi nodi con collegamenti lunghi, come le rotte intercontinentali, in basso tutti i nodi con collegamenti corti, come le strade urbane. La ricerca entra dall'alto, va in modo greedy verso il nodo più vicino alla query e, quando non migliora più, scende di un livello. Tre parametri: M (grafo più ricco, più memoria), efConstruction (grafo migliore, costruzione più lenta) ed efSearch, la manopola richiamo-latenza, come nprobe.

Descritto e non misurato: hnswlib non è installato, e un HNSW scritto da zero in Python darebbe latenze che non dicono nulla sull'implementazione vera. La documentazione ufficiale di Redis sui vettori (Redis, indici vettoriali) mostra entrambe le strutture come tipo di campo: FLAT, la scansione esatta della nostra classe, e HNSW. Su un corpus piccolo FLAT fa benissimo.

Due modi di non confrontare tutto con tutto IVF - PARTIZIONI E SONDE (misurata) HNSW - GRAFO A LIVELLI (descritta) q query 4 sonde su 16 celle → si visita il 38% dei vettori richiamo@10 = 0,862 1 sonda → 12,6% dei vettori, richiamo 0,607 i vicini nelle celle non aperte sono persi, e non lo sai livello 2 - pochi nodi, salti lunghi livello 1 - densità intermedia livello 0 - tutti i nodi, collegamenti corti scendi quando non migliori più M = vicini per nodo · efConstruction = qualità del grafo efSearch = la manopola richiamo/latenza, in esercizio è un tipo di indice vettoriale offerto da Redis qui descritto, non misurato: hnswlib non è installato
IVF taglia lo spazio in celle e ne apre poche, HNSW costruisce scorciatoie e le percorre dall'alto. In entrambi i casi la manopola non regola la velocità: regola quanto richiamo sei disposto a perdere.

Trade-off

  • Esatto contro approssimato. L'esatto non sbaglia mai e costa O(N·d): circa 0,7 ms a mille documenti, 61-72 ms a centomila, 254-295 ms a quattrocentomila. L'approssimato compra tempo con richiamo: a quattro sonde su sedici perdiamo il 14% dei vicini veri. Mitigazione: parti esatto, passa all'approssimato quando la latenza misurata esce dal budget.
  • Dimensione dei vettori contro memoria. I 292 documenti occupano meno di 1 MiB; un milione a 768 dimensioni in float32 occupa circa 2,9 GiB, ed è il motivo per cui ogni servizio reale quantizza o riduce le dimensioni. Entrambe costano richiamo: la riduzione non è gratis solo perché la libreria la offre con un parametro.
  • Frammenti corti contro lunghi. Il corto dà un vettore nitido e un contesto povero; il lungo contiene la risposta ma la diluisce, e sopra il limite dell'encoder viene troncato. Non c'è una dimensione giusta: c'è una dimensione da misurare sul tuo corpus.
  • Un indice contro due. Lessicale e denso insieme danno il risultato migliore (recall@1 0,435 contro 0,404 sul corpus di prova), ma sono due indici da mantenere allineati e due insiemi di parametri da tarare. Su un corpus piccolo, un indice solo con la metrica misurata vale più di due con la metrica sperata.
  • Modello generico contro modello di dominio. Il generico funziona subito e male sui gerghi; lo specializzato richiede dati e un insieme di valutazione tuo. Via di mezzo: parti dal generico, costruisci la valutazione come qui con inspect, e usa quel numero per decidere se il salto conviene.

Le cinque voci sono la stessa domanda posta su assi diversi: quanto sei disposto a pagare, in latenza, memoria o manutenzione, per recuperare richiamo. Il filo comune è che nessuna risposta è giusta in astratto, e ognuna si decide con una misura sul tuo corpus: cambiare l'ordine delle voci non cambia niente, per questo sono un elenco.

Le trappole in cui si cade davvero

  1. Prodotto scalare su vettori non normalizzati, chiamato coseno. Sintomo: i documenti lunghi in cima a qualunque query. Causa: la norma è entrata nel punteggio, la lunghezza è diventata rilevanza. Rimedio: normalizza in indicizzazione e metti un assert sulle norme.
  2. Confrontare coseni fra due modelli. Sintomo: «il modello nuovo dà punteggi più bassi, quindi è peggiore». Causa: ogni spazio ha la sua distribuzione, come mostra la simulazione con a diverso. Rimedio: solo metriche d'ordine, sullo stesso insieme di query.
  3. Scegliere una soglia a occhio. Sintomo: con 0,8 il sistema non risponde quasi mai, con 0,5 risponde sempre e a caso. Causa: la soglia viene da un altro spazio. Rimedio: ricavala dai percentili del tuo corpus e rifalla a ogni cambio di modello.
  4. Frammenti più lunghi del massimo dell'encoder. Sintomo: nessuno, ed è il punto - il servizio tronca e restituisce un vettore valido del prefisso. Ogni encoder dichiara il suo limite nella scheda del modello. Una stima senza tokenizzatore, in parole: dei 292 docstring interi del corpus, 23 (7,9%) superano 190 parole, e le parole oltre quel limite sono 2.310 su 24.134 (9,6% del testo). Il 190 è una stima prudente di un limite di 256 token, non una misura; il conto vero richiede il tokenizzatore del tuo encoder. Rimedio: conta i token prima di indicizzare e registra ogni troncamento.
  5. Indice costruito con un modello, interrogato con un altro. Sintomo: risultati casuali ma non vuoti. Causa: i due spazi non hanno nulla in comune, e se hanno lo stesso numero di dimensioni il bug sopravvive al deploy. Anche in versione sottile: la scheda di nomic-embed-text-v1.5 prevede i prefissi search_document: e search_query:, e cambiarli da un lato solo sposta i risultati. Rimedio: modello e prefissi nei metadati dell'indice.
  6. Interrogare in una lingua diversa da quella del corpus. Sintomo: risultati plausibili e sbagliati, senza errori. Rimedio: encoder multilingue, traduzione della query o indice in due lingue, misurando ogni strada con le tue parafrasi nella seconda lingua; non fidarti di un'etichetta «multilingue» che non hai misurato.
  7. Misurare un solo k. Sintomo: il recupero «funziona» in valutazione e il sistema a valle risponde male, perché hai misurato recall@5 e il tuo RAG passa un frammento solo. Rimedio: riporta sempre recall@1, recall@5 e MRR insieme.

Sette trappole, un'unica radice: trattare il punteggio di similarità e il modello come fatti stabili, quando sono proprietà della coppia corpus-encoder. Le prime tre sono errori di lettura del numero, la quarta e la quinta errori di pipeline che non producono eccezioni, le ultime due errori di misura. L'ordine qui non ha importanza, ma conviene iniziare i controlli dalla quinta: è la più difficile da vedere.

Gli esercizi con le soluzioni stanno nel file esercizi.py (Python 3.12+, NumPy 2.3), su un corpus nuovo di dodici annunci di officina meccanica: normalizzazione e verifica dell'ordine, BM25 da zero coi conti che tornano a mano, le tre metriche più la fusione dei ranghi reciproci. Il file non è stato eseguito in questa revisione dell'articolo.

Cosa portarti via

Un indice vettoriale è una biblioteca ordinata per significato, e il significato è quello del modello che hai scelto: la qualità del recupero è una proprietà della coppia corpus-encoder, non del codice. Il codice è poco - una matrice normalizzata, un prodotto scalare, argpartition - e sotto le decine di migliaia di documenti non serve altro. Il lavoro serio sta altrove: un insieme di query etichettato, recall@1 e recall@5 misurati separatamente, e la ricerca lessicale accesa, che su 28 query su 292 era l'unica a trovare la risposta.

Quello che manda fuori strada più spesso non è una scelta di architettura: è prendere il punteggio di similarità per una misura assoluta. Se i vettori stanno in un cono, due testi estranei hanno già un coseno alto, e una soglia scritta a mano cade in quella banda per caso. Ordina, misura le posizioni, e ricorda che un indice vettoriale non sa dire «non c'è». Il tuo compito non è far salire un punteggio, è sapere in quale posizione finisce la risposta giusta - e cosa fai quando non c'è.

Fonti

Le prime sette fonti sono primarie e le ho aperte il 2026-11-12 per verificare titolo e contenuto citato; l'ultima voce dichiara invece da dove vengono le cifre, perché una misura fatta nel repository si cita con il comando che la produce.

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