Il retriever va misurato da solo, perché il generatore lo copre
Il retriever consegna dieci documenti e il primo è fuori tema; quello giusto è al settimo posto. Il generatore legge tutto, ignora il rumore, riempie i vuoti con ciò che sa e produce una risposta plausibile. Le metriche calcolate sulla risposta finale restano alte, e tu concludi che il sistema va bene. Il difetto del retriever esiste ancora, ma un altro componente lo nasconde. Finché il dominio è noto al modello e il corpus è piccolo, la copertura regge; quando il corpus cresce e le domande diventano specifiche, la copertura finisce e non hai nessun dato per capire perché. Per questo il recupero si misura separatamente, con metriche che richiedono solo due cose: l'elenco ordinato dei documenti restituiti e quali di essi sono giusti.
Questo articolo definisce quattro metriche di recupero (hit rate, recall, MRR, nDCG), le implementa dalla definizione, le controlla contro scikit-learn, le applica a quattro retriever su un corpus d'esempio e mostra come si legge un fallimento. Presuppone il golden dataset dell'episodio 4, perché ogni domanda deve avere i suoi documenti giusti, e il vocabolario dell'episodio 1. Il corpus è inventato (28 frasi su un prodotto software immaginario, 14 domande) e il confronto fra retriever è un modello didattico: nessun modello di embedding è stato eseguito, e nessun database vettoriale avviato. I valori non dicono quale retriever sia migliore in generale.
Il corpus e due retriever senza modelli
Il primo listato è il materiale su cui lavorano gli altri: salvalo come corpus_rag.py. Ogni domanda ha una rilevanza graduata: 2 per il documento che contiene la risposta, 1 per uno che aiuta ma è parziale. La graduazione serve all'nDCG, che altrimenti non avrebbe nulla da pesare. I due retriever sono BM25, scritto dalla formula con i parametri predefiniti di Lucene (k1 = 1,2 e b = 0,75, e la stessa definizione di idf), e LSA, cioè il TF-IDF ridotto a sei dimensioni con una decomposizione ai valori singolari. LSA è il sostituto deterministico di un retriever «denso»: porta documenti e domande in uno spazio a poche dimensioni, ma lo fa partendo dalle stesse parole, quindi non è un embedding semantico. La funzione classifica scarta i punteggi nulli, perché un punteggio zero non è un risultato.
# corpus_rag.py - corpus inventato (28 frasi, 8 temi), 14 domande con rilevanza graduata e due retriever
import math, re
import numpy as np
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
DOCS = {
"prezzi1": "Il piano Pro ha un prezzo mensile di 49 euro e include 10 utenti.",
"prezzi2": "Il piano Enterprise costa di piu perche aggiunge SSO e assistenza dedicata.",
"prezzi3": "Pagando in anticipo per un anno l'abbonamento costa meno di dodici mensilita.",
"rimb1": "Il rimborso e possibile entro 14 giorni dall'acquisto, con richiesta al supporto.",
"rimb2": "Per riavere il denaro speso scrivi al supporto indicando il numero d'ordine.",
"rimb3": "Dopo 14 giorni l'importo pagato non viene restituito, salvo difetti del servizio.",
"export1": "L'esportazione dei dati produce un file CSV dalla pagina Impostazioni.",
"export2": "Puoi scaricare tutte le tue note in formato CSV o JSON dall'area Impostazioni.",
"export3": "Il file esportato contiene una riga per ogni documento e le date di modifica.",
"sso1": "L'accesso con SSO e disponibile solo nel piano Enterprise tramite SAML.",
"sso2": "Il login unico aziendale si configura con il provider di identita dell'azienda.",
"sso3": "Senza SSO ogni utente entra con email e password personali.",
"backup1": "I backup automatici girano ogni notte e si conservano per 30 giorni.",
"backup2": "Le copie di sicurezza sono giornaliere e restano disponibili per un mese.",
"backup3": "Per ripristinare un backup apri un ticket indicando la data desiderata.",
"api1": "Le API accettano al massimo 100 richieste al minuto per ogni chiave.",
"api2": "Superato il limite di richieste il server risponde con errore 429 e un'attesa.",
"api3": "Le chiavi API si creano e si revocano dal pannello Sviluppatori.",
"fatt1": "Le fatture si scaricano in PDF dalla sezione Fatturazione dell'account.",
"fatt2": "Per la ricevuta con la partita IVA aggiorna i dati aziendali prima del pagamento.",
"fatt3": "Ogni mese ricevi la fattura via email all'indirizzo del referente amministrativo.",
"pwd1": "Per reimpostare la password usa il link inviato alla tua email di registrazione.",
"pwd2": "Se hai dimenticato le credenziali di accesso puoi richiedere un nuovo link.",
"pwd3": "La password deve avere almeno dodici caratteri e non puo ripetere le ultime cinque.",
"var1": "Le integrazioni con Slack e Teams si attivano dal pannello Integrazioni.",
"var2": "Ogni progetto accetta fino a 500 documenti caricati in formato PDF.",
"var3": "L'app per telefono si scarica dagli store e sincronizza le note in pochi secondi.",
"var4": "Il supporto risponde dal lunedi al venerdi, dalle nove alle diciotto.",
}
# (id, domanda, {documento: rilevanza}) - 2 = contiene la risposta, 1 = utile ma parziale
QUERIES = [
("q01", "quanto costa il piano Pro", {"prezzi1": 2, "prezzi2": 1}),
("q02", "posso farmi restituire i soldi", {"rimb1": 2, "rimb2": 2, "rimb3": 1}),
("q03", "voglio portare via tutte le mie note", {"export2": 2, "export1": 1}),
("q04", "accesso unico aziendale con SAML", {"sso1": 2, "sso2": 2}),
("q05", "ogni quanto si fanno le copie di sicurezza", {"backup1": 2, "backup2": 2}),
("q06", "quante richieste al minuto accetta l'API", {"api1": 2, "api2": 1}),
("q07", "dove trovo la ricevuta del pagamento", {"fatt1": 2, "fatt2": 1, "fatt3": 1}),
("q08", "ho perso le credenziali", {"pwd2": 2, "pwd1": 1}),
("q09", "come collego Slack", {"var1": 2}),
("q10", "voglio riavere i dati di ieri", {"backup3": 2, "backup2": 1}),
("q11", "c'e uno sconto se pago per un anno", {"prezzi3": 2}),
("q12", "a che ora risponde il supporto", {"var4": 2}),
("q13", "limite di documenti per progetto", {"var2": 2}),
("q14", "il piano Enterprise serve per il login unico",{"sso1": 2, "sso2": 1, "prezzi2": 1}),
]
STOP = set("il lo la i gli le un una di del dello della dei delle da dal dalla in nel nella con su per tra fra e o ma "
"che chi cui non si se ho hai ha come dove quando quanto quanti quante posso puoi pu miei mio mia l d c".split())
def token(testo: str) -> list:
return [t for t in re.findall(r"[a-zà-ù0-9]+", testo.lower()) if t not in STOP]
IDS = list(DOCS)
TOK = [token(DOCS[i]) for i in IDS]
# --- BM25 Okapi, dalla formula (k1=1.2, b=0.75, come i valori predefiniti di Lucene) ---
N = len(IDS)
DF = {}
for doc in TOK:
for t in set(doc):
DF[t] = DF.get(t, 0) + 1
LUNG_MEDIA = sum(len(d) for d in TOK) / N
def bm25(domanda: str, k1: float = 1.2, b: float = 0.75) -> np.ndarray:
punteggi = np.zeros(N)
for t in set(token(domanda)):
if t not in DF:
continue
idf = math.log((N - DF[t] + 0.5) / (DF[t] + 0.5) + 1)
for j, doc in enumerate(TOK):
f = doc.count(t)
if f:
punteggi[j] += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * len(doc) / LUNG_MEDIA))
return punteggi
# --- LSA: TF-IDF ridotto a 6 dimensioni con SVD (un "denso" deterministico, senza modelli) ---
TF = TfidfVectorizer(tokenizer=token, lowercase=False, token_pattern=None)
X = TF.fit_transform(DOCS.values())
SVD = TruncatedSVD(n_components=6, random_state=0, algorithm="arpack")
Z = SVD.fit_transform(X)
Zn = Z / np.maximum(np.linalg.norm(Z, axis=1, keepdims=True), 1e-12)
def lsa(domanda: str) -> np.ndarray:
q = SVD.transform(TF.transform([domanda]))
n = np.linalg.norm(q)
return Zn @ (q[0] / n) if n > 1e-12 else np.zeros(N)
def classifica(punteggi: np.ndarray, soglia: float = 1e-9) -> list:
"""Id dei documenti in ordine di punteggio decrescente; i punteggi nulli non sono risultati."""
ordine = sorted(range(N), key=lambda j: (-punteggi[j], IDS[j]))
return [IDS[j] for j in ordine if punteggi[j] > soglia]
if __name__ == "__main__":
print(f"documenti={N} domande={len(QUERIES)} vocabolario={len(DF)} termini")
for qid, domanda, _ in QUERIES[:2]:
print(qid, domanda)
print(" BM25:", classifica(bm25(domanda))[:3])
print(" LSA: ", classifica(lsa(domanda))[:3])
# Output:
# documenti=28 domande=14 vocabolario=190 termini
# q01 quanto costa il piano Pro
# BM25: ['prezzi1', 'prezzi2', 'prezzi3']
# LSA: ['prezzi2', 'sso1', 'prezzi1']
# q02 posso farmi restituire i soldi
# BM25: []
# LSA: []
L'esecuzione mostra già un fatto: per «posso farmi restituire i soldi» entrambi i retriever restituiscono una lista vuota. Nessuna parola della domanda, dopo l'eliminazione delle parole vuote, compare nel vocabolario del corpus: il documento sul rimborso parla di «riavere il denaro» e di «importo pagato». È il guasto del vocabolario, e a questo punto non è una questione di classifica ma di assenza di candidati.
Le metriche, dalla definizione
Quattro funzioni, ciascuna con una domanda diversa. L'hit rate (hit_rate) chiede: c'è almeno un documento giusto nei primi k? È la metrica più indulgente e la più utile come soglia di ammissibilità, perché dice se il generatore ha almeno una possibilità. La recall (recall) chiede quanti dei documenti giusti hai portato: conta quando la risposta richiede più documenti. L'MRR (rr, la cui media sulle domande è l'MRR) guarda solo la posizione del primo documento giusto, come reciproco del rango: 1, 1/2, 1/3. L'nDCG (ndcg) è l'unica che usa la graduazione e che premia l'ordine di tutti i documenti giusti: somma i guadagni scontati con 1/log2(posizione + 1) e divide per la somma ottenibile con l'ordinamento ideale, perciò vale 1 solo se i documenti più rilevanti stanno in cima, nell'ordine giusto.
# metriche.py - HitRate@k, Recall@k, MRR e NDCG@k scritti dalla definizione, con controllo contro scikit-learn
import math
import numpy as np
from sklearn.metrics import ndcg_score
def hit_rate(ranking: list, rilevanti: dict, k: int) -> float:
"""1 se almeno un documento rilevante e nei primi k, altrimenti 0."""
return float(any(d in rilevanti for d in ranking[:k]))
def recall(ranking: list, rilevanti: dict, k: int) -> float:
"""Quota dei documenti rilevanti che compaiono nei primi k."""
return sum(d in rilevanti for d in ranking[:k]) / len(rilevanti)
def rr(ranking: list, rilevanti: dict) -> float:
"""Reciproco del rango del primo rilevante (0 se non compare). La media sulle domande e l'MRR."""
for i, d in enumerate(ranking, 1):
if d in rilevanti:
return 1 / i
return 0.0
def dcg(guadagni: list) -> float:
"""DCG con guadagno lineare e sconto 1/log2(posizione+1)."""
return sum(g / math.log2(i + 1) for i, g in enumerate(guadagni, 1))
def ndcg(ranking: list, rilevanti: dict, k: int) -> float:
"""DCG@k diviso il DCG@k del miglior ordinamento possibile dei documenti rilevanti."""
reale = dcg([rilevanti.get(d, 0) for d in ranking[:k]])
ideale = dcg(sorted(rilevanti.values(), reverse=True)[:k])
return reale / ideale if ideale else 0.0
if __name__ == "__main__":
rilevanti = {"a": 2, "b": 1, "c": 1} # a risponde, b e c aiutano
for nome, ranking in (("a in prima posizione", ["a", "x", "y", "b", "z"]),
("a in terza posizione ", ["x", "y", "a", "b", "z"]),
("nessun rilevante ", ["x", "y", "z", "w", "v"])):
print(f"{nome}: hit@3={hit_rate(ranking, rilevanti, 3):.0f} recall@5={recall(ranking, rilevanti, 5):.2f} "
f"RR={rr(ranking, rilevanti):.2f} nDCG@5={ndcg(ranking, rilevanti, 5):.3f}")
# controllo contro scikit-learn. Attenzione: ndcg_score ricava il DCG ideale dai soli documenti che gli passi,
# quindi vanno passati TUTTI i documenti dell'universo, anche quelli non recuperati (punteggio 0)
ranking = ["x", "y", "a", "b", "z"]
universo = ["a", "b", "c", "x", "y", "z", "w", "v"]
veri = np.array([[rilevanti.get(d, 0) for d in universo]])
punteggi = np.array([[5 - ranking.index(d) if d in ranking else 0 for d in universo]])
mio, loro = ndcg(ranking, rilevanti, 5), ndcg_score(veri, punteggi, k=5)
print(f"nDCG@5 mio={mio:.6f} scikit-learn={loro:.6f} uguali={math.isclose(mio, loro)}")
solo_recuperati = np.array([[rilevanti.get(d, 0) for d in ranking]])
print(f"scikit-learn passando solo i 5 recuperati: {ndcg_score(solo_recuperati, np.array([[5, 4, 3, 2, 1]]), k=5):.6f} (sbagliato: manca il documento c)")
# Output:
# a in prima posizione: hit@3=1 recall@5=0.67 RR=1.00 nDCG@5=0.776
# a in terza posizione : hit@3=1 recall@5=0.67 RR=0.33 nDCG@5=0.457
# nessun rilevante : hit@3=0 recall@5=0.00 RR=0.00 nDCG@5=0.000
# nDCG@5 mio=0.456949 scikit-learn=0.456949 uguali=True
# scikit-learn passando solo i 5 recuperati: 0.543791 (sbagliato: manca il documento c)
Guarda come le quattro metriche leggono lo stesso ranking. Con il documento «a» in prima posizione l'hit rate e l'RR valgono 1, la recall a 5 vale 0,67 (due giusti su tre trovati) e l'nDCG vale 0,776, non 1, perché «c» manca. Con «a» in terza posizione l'hit rate resta 1, l'RR scende a 0,33 e l'nDCG a 0,457: l'hit rate non vede la differenza, le altre sì. L'ultima parte è un controllo e contiene una trappola reale: ndcg_score di scikit-learn calcola il DCG ideale solo dai documenti che gli passi. Se passi i cinque documenti recuperati e dimentichi quelli rilevanti non recuperati, ottiene 0,544 invece di 0,457, un nDCG gonfiato che sembra corretto. Passa sempre l'intero universo dei documenti, con punteggio 0 a quelli non recuperati. La definizione usata qui è quella con guadagno lineare; esiste una variante con guadagno esponenziale (2rel − 1) che dà più peso ai documenti molto rilevanti, quindi dichiara quale usi.
Un'ultima distinzione evita un equivoco frequente con Ragas. Le sue metriche di contesto basate sugli identificativi (IDBasedContextPrecision e IDBasedContextRecall) sono calcolate sugli insiemi di identificativi e non guardano l'ordine: su un esempio provato nell'ambiente di prova, con recuperati d3, d1, d9 e giusti d1, d2, danno precisione 0,333 e richiamo 0,5 per qualunque ordine dei tre. L'ordine lo vedono la ContextPrecision a verdetti del giudice, descritta nell'episodio 2, e le metriche di questo articolo.
Quattro retriever sulle stesse quattordici domande
Il listato confronta BM25, LSA e due modi di fonderli: la somma pesata dei punteggi normalizzati con min-max, e la Reciprocal Rank Fusion, che somma 1/(k + rango) su ciascuna lista. L'RRF usa k = 60, che è il valore predefinito di rank_constant nella documentazione di Elasticsearch. La costruzione di un retriever ibrido è trattata nell'articolo sull'hybrid retrieval; qui la fusione serve solo come oggetto da misurare. Dopo la tabella delle medie, il listato stampa il rango del primo documento giusto per ogni domanda e un bootstrap sulla differenza di nDCG fra BM25 e LSA.
# valuta.py - BM25, LSA e due fusioni sulle stesse 12 domande: media e dove sbaglia ciascuna
import numpy as np
from corpus_rag import IDS, QUERIES, bm25, lsa, classifica
from metriche import hit_rate, ndcg, recall, rr
def minmax(x: np.ndarray) -> np.ndarray:
return (x - x.min()) / (x.max() - x.min()) if x.max() > x.min() else np.zeros_like(x)
def pesata(q: str, alfa: float = 0.5) -> list:
"""Somma pesata dei punteggi normalizzati: alfa*BM25 + (1-alfa)*LSA."""
b, l = bm25(q), lsa(q)
if b.max() == 0 and l.max() <= 0:
return []
return classifica(alfa * minmax(b) + (1 - alfa) * minmax(l), soglia=-1)[:10]
def rrf(q: str, k: int = 60) -> list:
"""Reciprocal Rank Fusion: somma di 1/(k+rango) sulle due liste."""
punt = {}
for lista in (classifica(bm25(q))[:10], classifica(lsa(q))[:10]):
for rango, d in enumerate(lista, 1):
punt[d] = punt.get(d, 0) + 1 / (k + rango)
return sorted(punt, key=lambda d: (-punt[d], d))
SISTEMI = {
"BM25": lambda q: classifica(bm25(q)),
"LSA": lambda q: classifica(lsa(q)),
"pesata": pesata,
"RRF": rrf,
}
risultati = {n: [f(testo) for _, testo, _ in QUERIES] for n, f in SISTEMI.items()}
print(f"{'sistema':8s} hit@3 recall@5 MRR nDCG@5")
for nome, rank in risultati.items():
h = np.mean([hit_rate(r, q[2], 3) for r, q in zip(rank, QUERIES)])
c = np.mean([recall(r, q[2], 5) for r, q in zip(rank, QUERIES)])
m = np.mean([rr(r, q[2]) for r, q in zip(rank, QUERIES)])
n = np.mean([ndcg(r, q[2], 5) for r, q in zip(rank, QUERIES)])
print(f"{nome:8s} {h:5.2f} {c:8.2f} {m:5.2f} {n:6.3f}")
print("\nrango del primo documento rilevante (-- = non trovato):")
for i, (qid, testo, rel) in enumerate(QUERIES):
celle = []
for nome, rank in risultati.items():
pos = next((j for j, d in enumerate(rank[i], 1) if d in rel), None)
celle.append(f"{nome}={pos if pos else '--'}")
print(f" {qid} {testo[:38]:38s} " + " ".join(celle))
# BM25 batte LSA di 0.086 di nDCG@5: e una differenza o e rumore? Bootstrap sulle domande, seme fisso
d = np.array([ndcg(risultati["BM25"][i], q[2], 5) - ndcg(risultati["LSA"][i], q[2], 5) for i, q in enumerate(QUERIES)])
rng = np.random.default_rng(0)
medie = np.sort([rng.choice(d, size=len(d)).mean() for _ in range(5000)])
print(f"\ndifferenza media di nDCG@5 (BM25 - LSA) = {d.mean():+.3f} intervallo bootstrap 95% = [{medie[125]:+.3f}, {medie[4874]:+.3f}]")
# Output:
# sistema hit@3 recall@5 MRR nDCG@5
# BM25 0.86 0.74 0.86 0.760
# LSA 0.86 0.77 0.74 0.674
# pesata 0.86 0.77 0.86 0.772
# RRF 0.86 0.81 0.82 0.753
#
# rango del primo documento rilevante (-- = non trovato):
# q01 quanto costa il piano Pro BM25=1 LSA=1 pesata=1 RRF=1
# q02 posso farmi restituire i soldi BM25=-- LSA=-- pesata=-- RRF=--
# q03 voglio portare via tutte le mie note BM25=1 LSA=1 pesata=1 RRF=1
# q04 accesso unico aziendale con SAML BM25=1 LSA=3 pesata=1 RRF=1
# q05 ogni quanto si fanno le copie di sicur BM25=1 LSA=2 pesata=1 RRF=1
# q06 quante richieste al minuto accetta l'A BM25=1 LSA=1 pesata=1 RRF=1
# q07 dove trovo la ricevuta del pagamento BM25=1 LSA=1 pesata=1 RRF=1
# q08 ho perso le credenziali BM25=1 LSA=1 pesata=1 RRF=1
# q09 come collego Slack BM25=1 LSA=1 pesata=1 RRF=1
# q10 voglio riavere i dati di ieri BM25=-- LSA=-- pesata=-- RRF=--
# q11 c'e uno sconto se pago per un anno BM25=1 LSA=1 pesata=1 RRF=1
# q12 a che ora risponde il supporto BM25=1 LSA=1 pesata=1 RRF=1
# q13 limite di documenti per progetto BM25=1 LSA=1 pesata=1 RRF=1
# q14 il piano Enterprise serve per il login BM25=1 LSA=2 pesata=1 RRF=2
#
# differenza media di nDCG@5 (BM25 - LSA) = +0.086 intervallo bootstrap 95% = [-0.014, +0.204]
Le medie sono vicine: tutti e quattro hanno hit rate 0,86 (dodici domande su quattordici), l'MRR va da 0,74 di LSA a 0,86 di BM25 e della somma pesata, l'nDCG a 5 da 0,674 a 0,772. Ordinare i sistemi su queste medie sarebbe un errore, e l'ultima riga dice perché: BM25 supera LSA di 0,086 di nDCG, ma l'intervallo bootstrap al 95% va da −0,014 a +0,204 e contiene lo zero. Con quattordici domande questa differenza non si distingue dal rumore. Il bootstrap rimescola le domande, non i punteggi, che è la scelta giusta perché le domande sono l'unità indipendente. Più utile della media è la tabella dei ranghi: dodici domande su quattordici sono risolte al primo posto da quasi tutti i sistemi, e le due che falliscono falliscono per tutti. Una fusione serve se i due retriever sbagliano in punti diversi; qui entrambi poggiano sullo stesso vocabolario e sbagliano insieme, quindi la fusione non guadagna nulla.
Perché una domanda fallisce: la diagnosi
Una media bassa non dice che cosa riparare. Per ogni domanda in cui il primo documento giusto non è nei primi tre, il listato stampa il rango, il primo risultato restituito e le parole che la domanda condivide con i documenti giusti. Il confronto lessicale è il primo controllo perché è quello che costa meno e che separa subito i due guasti più comuni.
# diagnosi.py - perche una domanda fallisce: quali parole condivide con i documenti che avrebbe dovuto trovare?
from corpus_rag import DOCS, QUERIES, bm25, classifica, token
for qid, testo, rilevanti in QUERIES:
ranking = classifica(bm25(testo))
pos = next((j for j, d in enumerate(ranking, 1) if d in rilevanti), None)
if pos is not None and pos <= 3:
continue # trovato bene: niente da diagnosticare
in_comune = {d: sorted(set(token(testo)) & set(token(DOCS[d]))) for d in rilevanti}
print(f"{qid} '{testo}' -> rango {pos if pos else 'non trovato'}, primo risultato: {ranking[0] if ranking else 'nessuno'}")
for d, parole in in_comune.items():
print(f" {d} (rilevanza {rilevanti[d]}): parole in comune = {parole if parole else 'nessuna'}")
# Output:
# q02 'posso farmi restituire i soldi' -> rango non trovato, primo risultato: nessuno
# rimb1 (rilevanza 2): parole in comune = nessuna
# rimb2 (rilevanza 2): parole in comune = nessuna
# rimb3 (rilevanza 1): parole in comune = nessuna
# q10 'voglio riavere i dati di ieri' -> rango non trovato, primo risultato: rimb2
# backup3 (rilevanza 2): parole in comune = nessuna
# backup2 (rilevanza 1): parole in comune = nessuna
I due casi sono guasti diversi. Per «posso farmi restituire i soldi» il retriever non restituisce nulla, e le parole in comune con i tre documenti giusti sono zero: è una lacuna di vocabolario. Per «voglio riavere i dati di ieri» restituisce un documento sbagliato (rimb2) perché condivide la parola «riavere» con la frase sul rimborso: è un'omonimia di superficie, e il retriever è peggio di uno che non risponde, perché produce un contesto convincente e sbagliato. Le contromisure sono diverse. Il primo caso si affronta con un retriever che non dipende dalle stesse parole, come un modello di embedding, o con una riscrittura della domanda; il secondo con un riordinamento dei candidati che guardi la domanda per intero, come in un re-ranking con cross-encoder. Sono ipotesi ragionevoli, e in questo articolo non le ho misurate: provale con le stesse metriche sul tuo dataset prima di adottarle.
Un terzo guasto è quello del rango tardivo: il documento giusto c'è ma è oltre la soglia. Si riconosce nella tabella dei ranghi quando una domanda ha un valore tra 4 e 10, e si legge con l'MRR e l'nDCG, non con l'hit rate a 10. Il quarto è la duplicazione di versioni dello stesso documento, visto nell'episodio 1. Una sola nota di buon senso sul benchmark di riferimento: nel lavoro BEIR, che confronta sistemi di recupero su diciotto insiemi di dati, il risultato riportato è che BM25 è una base di partenza robusta; non prenderlo come un invito a non provare altro, ma come la ragione per cui nel tuo confronto un BM25 deve sempre esserci.
Che cosa sai fare adesso
Sai calcolare hit rate, recall, MRR e nDCG dalla definizione, sai quale usare per quale decisione e hai visto la trappola di ndcg_score che gonfia il risultato quando manca l'universo dei documenti. Sai confrontare più retriever sulle stesse domande, riconoscere con il bootstrap quando una differenza è rumore e leggere il rango per domanda per capire se il fallimento è di vocabolario, di omonimia o di ordine. Il prossimo passo è collegare queste misure a un cancello di regressione come quello dell'episodio 3, e portarle in esercizio con il monitoraggio descritto in RAG in produzione.
Nella stessa serie trovi anche la metrica di faithfulness e l'ottimizzazione guidata dalla valutazione.
Domande frequenti
Qual è la differenza fra nDCG e MRR?
L'MRR guarda solo la posizione del primo documento giusto. L'nDCG guarda la posizione di tutti i documenti rilevanti e usa la rilevanza graduata, quindi premia anche l'ordine fra un documento molto utile e uno parziale. Se ti serve un solo documento giusto, basta l'MRR; se ne servono diversi, usa l'nDCG.
L'hit rate basta come unica metrica di recupero?
No. Nell'esempio dell'articolo l'hit rate a 3 vale 1 sia con il documento giusto in prima sia in terza posizione, mentre l'RR passa da 1 a 0,33 e l'nDCG da 0,776 a 0,457. L'hit rate dice se il generatore ha almeno una possibilità, non quanto bene è servito.
Perché il mio nDCG con scikit-learn è più alto del previsto?
Probabilmente hai passato a ndcg_score solo i documenti recuperati. La funzione ricava il DCG ideale dai soli documenti che le dai, quindi i rilevanti non recuperati non entrano nel denominatore. Passa l'intero universo dei documenti, con punteggio 0 a quelli non recuperati.
Come capisco se un retriever è davvero migliore di un altro?
Calcola la differenza della metrica domanda per domanda e un intervallo bootstrap sulle domande. Se l'intervallo contiene lo zero, non puoi dire che uno sia meglio dell'altro. Nell'esempio, BM25 batte LSA di 0,086 di nDCG ma l'intervallo va da −0,014 a +0,204.
Fonti
- scikit-learn, ndcg_score - la definizione di nDCG con sconto logaritmico e il fatto che il DCG ideale deriva dai valori di y_true passati, consultata il 2026-11-12
- Apache Lucene, BM25Similarity - i valori predefiniti k1 = 1,2 e b = 0,75 e la formula dell'idf usata nel listato, consultata il 2026-11-12
- Elasticsearch, Reciprocal rank fusion - il parametro rank_constant, predefinito a 60, consultata il 2026-11-12
- Ragas, Context Precision - la formula rank-aware e la presenza della variante basata sugli identificativi, consultata il 2026-11-12
- Thakur et al., BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models (arXiv:2104.08663) - BM25 come base di partenza robusta su diciotto insiemi di dati, consultata il 2026-11-12
I listati sono stati eseguiti due volte con Python 3.14, NumPy e scikit-learn, con uscite identiche. Il corpus è inventato, LSA non è un embedding semantico e i retriever a modello non sono stati eseguiti. Il confronto con le metriche a identificativi di Ragas 0.4.3 è stato fatto su un esempio in un ambiente di prova separato, fuori dai listati. L'articolo originale di Järvelin e Kekäläinen sull'nDCG (ACM TOIS, 2002) è citato dalla documentazione di scikit-learn; la pagina dell'editore non era raggiungibile e non è stata letta.

Commenti
Caricamento commenti...
Accedi per commentare