Salta al contenuto principale

05 - Retrieval quality deep-dive: NDCG, MRR, HitRate, quando il retriever fallisce

Hit rate, recall, MRR e nDCG scritti dalla definizione, con la trappola di scikit-learn che gonfia l'nDCG e come leggere un recupero che fallisce.

Cosa ne pensi di questo articolo?

05 - Retrieval quality deep-dive: NDCG, MRR, HitRate, quando il retriever fallisce
RAG EngineeringFoto:Leeloo The FirstPexels

In Breve

Hit rate, recall, MRR e nDCG scritti dalla definizione, con la trappola di scikit-learn che gonfia l'nDCG e come leggere un recupero che fallisce.

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

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

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