Ottimizzare il RAG con la valutazione: una variabile alla volta, un test accoppiato
Cambi il prompt perché «suona meglio», aggiungi un reranker perché ne hai letto bene, passi da BM25 a un indice vettoriale: il sistema forse migliora, ma non sai di quanto, né quale delle tre mosse ha pesato. L'ottimizzazione a occhio produce miglioramenti per caso e regressioni per caso. Il rimedio è lo stesso di qualunque esperimento: una sola variabile per volta, un insieme di valutazione fisso, la stessa domanda posta ai due sistemi, e un test che dica se la differenza è più grande del rumore.
Qui costruisci quel banco di prova con numeri che puoi rifare. Il laboratorio è volutamente piccolo e sintetico (quaranta documenti, ottanta query) per far vedere il meccanismo; i valori che escono non vanno letti come prestazioni di un sistema vero, ma come illustrazione di che cosa significano intervallo di confidenza, correzione per confronti multipli e segmentazione. Le metriche di retrieval usate sono quelle dell'episodio 5; per il retrieval ibrido in sé vedi questo approfondimento della serie AI Engineering, e per il reranker l'articolo dedicato al re-ranking.
Il banco di prova: tre sistemi, una metrica per query
Il corpus ha quaranta guide, una per ogni coppia (azione, oggetto), come «per configurare il cache apri il pannello...». Le query sono ottanta: quaranta con la forma verbale presente nel documento («configurare cache»), quaranta con il sostantivo che il documento non contiene («configurazione cache»). Servono tre recuperatori. A è BM25 scritto in numpy, che confronta parole intere. B è TF-IDF su n-grammi di caratteri (da tre a cinque) con scikit-learn, che perdona le variazioni morfologiche. C fonde le due classifiche con la Reciprocal Rank Fusion, che somma 1/(k + rango) per ogni classifica con k = 60. La metrica è il reciproco del rango del documento giusto, calcolato per ciascuna query: è questo vettore, e non la sola media, che serve al test.
import math
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
AZIONI = [("configurare", "configurazione"), ("riavviare", "riavvio"), ("monitorare", "monitoraggio"),
("aggiornare", "aggiornamento"), ("eliminare", "eliminazione"), ("importare", "importazione"),
("esportare", "esportazione"), ("proteggere", "protezione")]
OGGETTI = ["cache", "database", "log", "certificato", "backup"]
# 40 documenti, uno per coppia (azione, oggetto); 80 query: metà con la stessa forma
# del documento, metà con il sostantivo ("configurazione cache") che il testo non contiene.
DOCS = [f"Guida: per {inf} il {og} apri il pannello e segui la procedura {i}."
for i, (inf, nom) in enumerate(AZIONI) for og in OGGETTI]
GIUSTO = list(range(len(DOCS)))
Q_ESATTE = [f"{inf} {og}" for inf, nom in AZIONI for og in OGGETTI]
Q_MORFO = [f"{nom} {og}" for inf, nom in AZIONI for og in OGGETTI]
QUERY = Q_ESATTE + Q_MORFO
TARGET = GIUSTO + GIUSTO
SEGMENTO = ["esatta"] * len(Q_ESATTE) + ["morfologica"] * len(Q_MORFO)
def bm25(corpus, k1=1.5, b=0.75):
tok = [d.lower().replace(".", "").split() for d in corpus]
vocab = sorted({t for d in tok for t in d})
idx = {t: i for i, t in enumerate(vocab)}
tf = np.zeros((len(corpus), len(vocab)))
for r, d in enumerate(tok):
for t in d:
tf[r, idx[t]] += 1
lung = tf.sum(1)
df = (tf > 0).sum(0)
idf = np.log(1 + (len(corpus) - df + 0.5) / (df + 0.5))
norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * lung[:, None] / lung.mean()))
def punteggi(q):
v = np.zeros(len(vocab))
for t in q.lower().split():
if t in idx:
v[idx[t]] = 1
return (norm * idf) @ v
return punteggi
def char_tfidf(corpus):
vett = TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5)).fit(corpus)
m = vett.transform(corpus)
return lambda q: (m @ vett.transform([q]).T).toarray().ravel()
def rrf(*punteggi, k=60):
"""Reciprocal Rank Fusion: somma 1/(k + rango) su più classifiche."""
tot = np.zeros(len(punteggi[0]))
for p in punteggi:
rango = np.empty(len(p), dtype=int)
rango[np.argsort(-p, kind="stable")] = np.arange(1, len(p) + 1)
tot += 1 / (k + rango)
return tot
def rr_per_query(funzione):
"""Reciprocal rank del documento giusto, una cifra per query."""
out = []
for q, t in zip(QUERY, TARGET):
ordine = np.argsort(-funzione(q), kind="stable")
out.append(1 / (int(np.where(ordine == t)[0][0]) + 1))
return np.array(out)
A, B = bm25(DOCS), char_tfidf(DOCS)
SISTEMI = {"A bm25": A, "B char-ngram": B, "C rrf(A,B)": lambda q: rrf(A(q), B(q))}
RR = {nome: rr_per_query(f) for nome, f in SISTEMI.items()}
if __name__ == "__main__":
seg = np.array(SEGMENTO)
print(f"{len(DOCS)} documenti, {len(QUERY)} query")
for nome, rr in RR.items():
print(f"{nome:13s} MRR={rr.mean():.3f} esatte={rr[seg == 'esatta'].mean():.3f}"
f" morfologiche={rr[seg == 'morfologica'].mean():.3f}")
# Output:
# 40 documenti, 80 query
# A bm25 MRR=0.670 esatte=1.000 morfologiche=0.340
# B char-ngram MRR=1.000 esatte=1.000 morfologiche=1.000
# C rrf(A,B) MRR=0.938 esatte=1.000 morfologiche=0.875
Lette per segmento, le cifre raccontano più della media. Sulle query «esatte» tutti i sistemi sono perfetti (1,000): confrontando solo quelle non vedresti alcuna differenza. Sulle morfologiche BM25 scende a 0,340, perché il sostantivo non compare nel documento e l'unica parola che combacia è l'oggetto, comune a otto guide. Gli n-grammi di caratteri arrivano a 1,000 perché «configur» è una radice condivisa. La fusione C sta in mezzo (0,875): il ramo BM25 porta rumore dentro un ramo già perfetto. Attenzione a non generalizzare: questo è un fatto del corpus costruito, non una legge del retrieval ibrido, che su dati reali spesso aiuta proprio perché i due rami sbagliano su query diverse.
Il test accoppiato: la differenza è più grande del rumore?
Le tre colonne sono misurate sulle stesse query, quindi vanno confrontate in coppia: per ogni query la differenza tra i due sistemi, e poi un test sulle differenze. Il test di Wilcoxon dei ranghi con segno (scipy.stats.wilcoxon) è non parametrico: la sua ipotesi nulla è che la distribuzione delle differenze sia simmetrica attorno a zero, e non richiede che le metriche siano normali, cosa falsa per punteggi limitati tra 0 e 1 e spesso concentrati ai due estremi. Con l'impostazione predefinita (zero_method="wilcox") le coppie a differenza nulla vengono scartate, ed è il motivo per cui il listato stampa quante sono. Accanto al p-valore calcola un intervallo di confidenza al 95% per la differenza media con il bootstrap (seme fisso), perché il p-valore dice solo se, non di quanto.
import numpy as np
from scipy.stats import wilcoxon
from a_retrieval import RR
def holm(pvalori):
"""Correzione di Holm per confronti multipli: p corretti nello stesso ordine di input."""
ordine = np.argsort(pvalori)
m = len(pvalori)
corretti = np.empty(m)
massimo = 0.0
for pos, i in enumerate(ordine):
massimo = max(massimo, min(1.0, (m - pos) * pvalori[i]))
corretti[i] = massimo
return corretti
def bootstrap_ic(diff, n=5000, seed=11):
rng = np.random.default_rng(seed)
medie = [rng.choice(diff, size=len(diff), replace=True).mean() for _ in range(n)]
return np.percentile(medie, [2.5, 97.5])
confronti = [("B", "A bm25"), ("C", "A bm25"), ("C", "B char-ngram")]
nomi = {"B": "B char-ngram", "C": "C rrf(A,B)"}
righe, p_grezzi = [], []
for x, y in confronti:
diff = RR[nomi[x]] - RR[y]
zeri = int((diff == 0).sum())
p = wilcoxon(diff).pvalue # accoppiato: stessa query nei due bracci
lo, hi = bootstrap_ic(diff)
righe.append((nomi[x], y, diff.mean(), lo, hi, zeri))
p_grezzi.append(p)
for r, p, pc in zip(righe, p_grezzi, holm(np.array(p_grezzi))):
print(f"{r[0]} - {r[1]}: delta={r[2]:+.3f} IC95%[{r[3]:+.3f}, {r[4]:+.3f}] "
f"query invariate={r[5]} p={p:.2g} p_Holm={pc:.2g}")
# Output:
# B char-ngram - A bm25: delta=+0.330 IC95%[+0.249, +0.416] query invariate=45 p=2.3e-07 p_Holm=7e-07
# C rrf(A,B) - A bm25: delta=+0.268 IC95%[+0.196, +0.346] query invariate=45 p=2.4e-07 p_Holm=7e-07
# C rrf(A,B) - B char-ngram: delta=-0.062 IC95%[-0.106, -0.025] query invariate=72 p=0.0097 p_Holm=0.0097
Ci sono tre cose da leggere. B contro A: differenza media +0,330, intervallo da +0,249 a +0,416, p dell'ordine di 10-7: su 45 query il risultato non cambia e sulle altre 35 B vince. C contro B: la differenza è negativa (-0,062) e l'intervallo non include lo zero, quindi aggiungere BM25 a B in questo corpus peggiora, in modo statisticamente rilevabile ma piccolo. Infine l'ultima colonna: i tre confronti sono tre test, e ogni test in più aumenta la probabilità di trovare qualcosa per caso. La correzione di Holm ordina i p-valori e li moltiplica per il numero di test ancora aperti; qui cambia poco perché gli effetti sono forti, ma con quattro o cinque varianti e differenze modeste può far svanire una vittoria apparente.
- Fissa insieme di valutazione, parametri di generazione e semi prima di guardare i risultati.
- Cambia una variabile per volta: prompt e retriever insieme non si distinguono.
- Ripeti più volte la generazione se la temperatura è maggiore di zero e aggrega, altrimenti misuri il rumore.
- Decidi prima la differenza minima che ti interessa: un +0,01 significativo ma irrilevante non giustifica un deploy.
Queste regole sono il vero contenuto dell'episodio, e il codice ne è solo il supporto. La terza è quella che si dimentica più spesso con un LLM nel mezzo: un giudizio ottenuto con temperatura positiva è un campione, non un valore, e un confronto fra due campioni singoli può invertirsi alla ripetizione. La quarta evita il vizio opposto, ossia dichiarare vittoria su una differenza reale ma di nessun peso per l'utente.
Reranker, prompt e ottimizzazione automatica
L'ablazione del reranker segue lo stesso schema: sistema senza reranker contro sistema con reranker, a parità di tutto il resto, stessa metrica per query, stesso test. Il costo da mettere nel conto è la latenza, perché il cross-encoder valuta ogni coppia domanda-documento con una passata del modello; come funziona il recupero a due stadi, e dove si ferma, lo trovi in questo articolo. I modelli a interazione tardiva, come ColBERT, precalcolano offline le rappresentazioni dei documenti, come dichiara l'abstract del paper originale: lo spazio che occupano e l'indice che richiedono sono un costo operativo da pesare, non un dettaglio.
Per tenere traccia dei prompt hai due strade. Gli strumenti di gestione dei prompt, come Langfuse, conservano versioni ed etichette (per esempio quale versione è in produzione) e permettono di collegare le tracce alla versione usata; un file versionato in git con il messaggio di commit che riporta la variazione delle metriche fa lo stesso lavoro con meno parti mobili. L'ottimizzazione automatica con DSPy cerca per te istruzioni ed esempi: MIPROv2, secondo la documentazione del progetto, ottimizza congiuntamente istruzioni ed esempi few-shot generando candidati e scegliendo la combinazione con un'ottimizzazione bayesiana. Serve un modello configurato, quindi il listato è uno schema da adattare e la metrica che gli passi diventa il tuo vincolo: se è sbagliata, l'ottimizzatore la ottimizza comunque.
# Schema da adattare, non eseguito: serve un modello di linguaggio configurato.
# Struttura tratta dall'esempio nella documentazione di MIPROv2 (repository DSPy).
import dspy
from dspy.teleprompt import MIPROv2
dspy.configure(lm=dspy.LM("<provider>/<modello>", api_key="<chiave>"))
def metrica(esempio, previsione, traccia=None):
# la tua metrica di valutazione: restituisce un numero (o un booleano) per esempio
return float(esempio.risposta_attesa.lower() in previsione.risposta.lower())
ottimizzatore = MIPROv2(metric=metrica, auto="light") # light | medium | heavy
programma = ottimizzatore.compile(dspy.ChainOfThought("domanda, contesto -> risposta"),
trainset=esempi_di_addestramento)
programma.save("prompt_ottimizzato.json")
Scegliere in produzione con Thompson sampling
Quando hai più prompt già validati offline, un test A/B classico divide il traffico a metà per un tempo fisso, anche quando uno dei due perde chiaramente. Un bandit multi-braccio sposta il traffico verso chi funziona. Nel Thompson sampling ogni prompt ha una distribuzione Beta sul proprio tasso di successo; a ogni richiesta estrai un valore per ciascuno e servi quello con l'estrazione più alta. Il tutorial di Russo e coautori ne spiega le basi. Il listato è una simulazione: i tassi veri (0,60, 0,66, 0,72) sono noti solo a chi scrive, e il «pollice su» è generato con un seme fisso.
import numpy as np
# Simulazione: tre prompt con tasso di gradimento reale sconosciuto al sistema
VERO = np.array([0.60, 0.66, 0.72])
rng = np.random.default_rng(3)
successi = np.ones(3) # prior Beta(1, 1) per ogni prompt
fallimenti = np.ones(3)
scelte = np.zeros(3, dtype=int)
for _ in range(3000):
campioni = rng.beta(successi, fallimenti) # un'estrazione per ogni prompt
k = int(np.argmax(campioni)) # servo il prompt con l'estrazione più alta
esito = rng.random() < VERO[k] # il "pollice su" osservato
successi[k] += esito
fallimenti[k] += 1 - esito
scelte[k] += 1
media_post = successi / (successi + fallimenti)
for i, nome in enumerate("ABC"):
print(f"prompt {nome}: servito {scelte[i]:4d} volte, stima {media_post[i]:.3f} (vero {VERO[i]:.2f})")
print("gradimento medio ottenuto:", round(float((scelte * VERO).sum() / scelte.sum()), 3),
"contro uniforme:", round(float(VERO.mean()), 3))
# Output:
# prompt A: servito 66 volte, stima 0.574 (vero 0.60)
# prompt B: servito 12 volte, stima 0.357 (vero 0.66)
# prompt C: servito 2922 volte, stima 0.720 (vero 0.72)
# gradimento medio ottenuto: 0.717 contro uniforme: 0.66
Il prompt migliore riceve 2922 richieste su 3000 e il gradimento ottenuto (0,717) supera quello di una divisione uniforme (0,660). Ma guarda il prompt B: servito solo 12 volte, stima finale 0,357 contro un valore vero di 0,66. Qualche esito sfavorevole all'inizio ha spinto la sua distribuzione in basso e l'algoritmo ha smesso di provarlo: è l'altra faccia dell'efficienza, e con un seme diverso potrebbe capitare al braccio migliore. Per questo il bandit si usa per scegliere fra varianti già giudicate accettabili, con il feedback reale degli utenti, e non per sostituire la valutazione offline. Il reward può essere un pollice su, un click di copia o un punteggio del giudice: scegli quello che rappresenta davvero l'utilità e non è facile da gonfiare.
Domande frequenti
Quante query servono per un confronto affidabile?
Non esiste un numero fisso: dipende dalla differenza che vuoi rilevare e dalla variabilità della metrica. L'intervallo di confidenza è la guida pratica: se è largo quanto l'effetto, servono più query. Parti da qualche centinaio, stratificate per tipo di domanda.
Perché Wilcoxon e non il test t?
Perché le metriche di retrieval e di giudizio sono limitate e spesso concentrate agli estremi, e il test t suppone differenze approssimativamente normali. Wilcoxon richiede solo la simmetria delle differenze sotto l'ipotesi nulla.
Serve la correzione per confronti multipli?
Sì quando confronti più di due varianti sullo stesso insieme. La correzione di Holm è più potente di quella di Bonferroni a parità di garanzia; tutte le correzioni riducono il rischio di dichiarare vincente una variante per caso.
Quando conviene un bandit rispetto a un A/B test?
Quando le varianti sono già validate offline, il traffico è sufficiente e vuoi limitare il costo dell'esplorazione. Per decidere se una modifica regge davvero resta più chiaro un test progettato in anticipo.
Che cosa sai fare adesso
Sai impostare un confronto fra due varianti con un'unica variabile cambiata, confrontarle query per query, leggere insieme differenza, intervallo e correzione per test multipli, riconoscere quando una media nasconde segmenti opposti e scegliere fra A/B e bandit. Nel prossimo episodio, monitorare la produzione rispetto all'insieme di valutazione, usi gli stessi strumenti statistici su traffico reale. Per l'infrastruttura che esegue questi confronti in batch, l'episodio 7.
Fonti
- SciPy - scipy.stats.wilcoxon: ipotesi nulla del test accoppiato e impostazione predefinita
zero_method, consultata il 2026-11-12 - Russo et al., «A Tutorial on Thompson Sampling» (arXiv 1707.02038): algoritmo e compromesso esplorazione-sfruttamento, consultata il 2026-11-12
- Repository DSPy - documentazione di MIPROv2: che cosa ottimizza e come, struttura dell'esempio, consultata il 2026-11-12
- Langfuse - Prompt Management: versioni ed etichette dei prompt, consultata il 2026-11-12
- Khattab e Zaharia, «ColBERT» (arXiv 2004.12832): interazione tardiva e requisiti di indice, consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare