Salta al contenuto principale

09 - Monitoring production vs eval set: drift detection, retraining trigger

Come accorgerti che un RAG peggiora dopo il rilascio: campionamento stabile, drift delle query con KS e PSI, regola di intervento e canary su una quota.

Cosa ne pensi di questo articolo?

09 - Monitoring production vs eval set: drift detection, retraining trigger
RAG EngineeringFoto:Yan KrukauPexels

In Breve

Come accorgerti che un RAG peggiora dopo il rilascio: campionamento stabile, drift delle query con KS e PSI, regola di intervento e canary su una quota.

Dalla valutazione di laboratorio al monitoraggio in produzione

Sul tuo insieme di valutazione il sistema RAG va bene. Due settimane dopo il rilascio qualcuno scrive che «le risposte sono diventate strane». Scarichi un campione di richieste reali, le valuti, e la qualità è più bassa di quella del laboratorio. Nessun bug: l'insieme di valutazione era una fotografia del dominio nel momento in cui lo hai costruito, mentre gli utenti fanno domande che non avevi previsto, il corpus cambia e le aspettative si spostano. Il divario fra ciò che misuri e ciò che succede si chiama in questo episodio divario valutazione-produzione, ed è il problema che chiude la serie.

Costruisci quattro pezzi con numeri che puoi rifare: un campionamento stabile del traffico, un controllo statistico dello spostamento delle query, una regola di intervento che richiede due segnali concordi, e un confronto fra versione corrente e candidata. Per misurare le metriche stesse (faithfulness, pertinenza) rimando a l'episodio 6 e a come eseguirle in batch in l'episodio 7; per la versione generale del tema nella serie AI Engineering, questo articolo sul RAG in produzione. Gli esempi usano query inventate e nessun modello: è il meccanismo che conta, non le cifre.

Tre tipi di scostamento, tre risposte diverse

Chiamare tutto «drift» nasconde che le cause e i rimedi sono diversi. La tabella separa i tre casi che incontri più spesso.

TipoChe cosa cambiaCome lo vediChe cosa fai
Query driftGli utenti chiedono cose nuove o con parole nuoveLe query si allontanano dalle query dell'insieme di valutazioneAggiungi query reali all'insieme di valutazione
Corpus driftDocumenti aggiunti, rimossi o riscrittiCambia quali chunk vengono recuperati; query senza buoni risultatiRifai l'indice, riesegui la valutazione
Concept driftIl fatto cambia nel mondo (prezzi, norme, versioni)Calo di faithfulness o risposte obsolete con retrieval «corretto»Aggiorna o ritira i documenti vecchi

Questo episodio misura il primo caso con un test statistico, perché è l'unico che si vede senza guardare le risposte. Gli altri due emergono dalle metriche di qualità e dall'ispezione del corpus: un indicatore utile per il secondo è la quota di query il cui miglior chunk ha un punteggio basso, che segnala lacune nei documenti.

Campionare senza rallentare e senza distorcere

Non valuti ogni richiesta: ne campioni una frazione. Il modo più semplice, scegliere a caso con un generatore, ha due difetti: non è riproducibile e due repliche del servizio decidono in modo diverso. Meglio una decisione deterministica sull'identificativo della richiesta: calcoli un hash, lo riduci in una scala fissa e confronti con la quota. Lo stesso identificativo dà sempre la stessa risposta, e alzare la quota da 5% a 10% include tutte le richieste già campionate.

import hashlib


def da_campionare(id_richiesta, quota):
    """Decisione deterministica: lo stesso id dà sempre la stessa risposta, su ogni replica."""
    h = int.from_bytes(hashlib.sha256(id_richiesta.encode()).digest()[:8], "big")
    return (h % 10_000) < quota * 10_000


ids = [f"req-{i}" for i in range(20_000)]
scelte = [i for i in ids if da_campionare(i, 0.05)]
print(f"campionate {len(scelte)} richieste su {len(ids)} ({len(scelte) / len(ids):.2%}), quota richiesta 5%")
print("stessa decisione alla seconda chiamata:", all(da_campionare(i, 0.05) for i in scelte))
print("con quota 10% includo anche tutte le precedenti:", all(da_campionare(i, 0.10) for i in scelte))

# Output:
# campionate 961 richieste su 20000 (4.81%), quota richiesta 5%
# stessa decisione alla seconda chiamata: True
# con quota 10% includo anche tutte le precedenti: True

Su ventimila identificativi ne sono stati scelti 961, cioè il 4,81% contro un 5% richiesto: lo scarto è normale per un campionamento pseudocasuale e non è un errore. Le due righe di controllo mostrano le proprietà che servono: stabilità e annidamento. Il costo sul percorso della richiesta è un hash e un confronto; la scrittura vera e la valutazione vanno fatte fuori dal percorso, in batch. Due avvertenze che nessun listato risolve: le richieste campionate possono contenere dati personali e vanno trattate di conseguenza (minimizza, anonimizza, fissa una durata di conservazione), e un campionamento uniforme sotto-rappresenta i temi rari, quindi per i segmenti importanti conviene una quota maggiore.

Misurare lo spostamento delle query

Confrontare due insiemi di vettori ad alta dimensione con un test per ogni dimensione produce centinaia di confronti. Lo riduci a un solo numero per query: la similarità del coseno fra la query e il centroide dell'insieme di valutazione. Resta così una distribuzione unidimensionale, confrontabile con il test di Kolmogorov-Smirnov a due campioni (ks_2samp di SciPy), che misura la distanza massima fra le due funzioni di distribuzione cumulate, e con il Population Stability Index (PSI), che somma lo scarto fra le quote di ciascun intervallo pesato per il logaritmo del loro rapporto.

Nel listato qui sotto le query sono inventate. L'insieme di valutazione ha 200 query su cinque temi tecnici, con pesi diversi come nel traffico reale; la settimana 1 ha la stessa distribuzione; la settimana 2 ha un 40% di query su temi mai visti (fatture, SPID, IBAN, aliquote). Il centroide è calcolato con la prima metà dell'insieme e il riferimento è la seconda metà: se calcolassi i punteggi di riferimento sulle stesse query che hanno costruito il centroide, il riferimento risulterebbe più vicino al centro di quanto sarà mai il traffico nuovo, e vedresti drift dove non c'è. Gli intervalli del PSI sono definiti dai quantili del riferimento.

import numpy as np
from scipy.stats import ks_2samp
from sklearn.feature_extraction.text import TfidfVectorizer

OGGETTI = ["cache", "database", "log", "certificato", "backup"]
AZIONI = ["configurare", "riavviare", "monitorare", "aggiornare", "eliminare", "importare", "esportare", "proteggere"]
NUOVI = ["emettere fattura elettronica", "attivare spid", "verificare iban", "calcolare aliquota iva"]

rng = np.random.default_rng(1)
PESI = [0.40, 0.25, 0.15, 0.10, 0.10]          # nel traffico reale alcuni temi dominano


def traffico(n, quota_nuovi=0.0):
    out = []
    for _ in range(n):
        if rng.random() < quota_nuovi:
            out.append(NUOVI[rng.integers(len(NUOVI))])
        else:
            out.append(f"{AZIONI[rng.integers(8)]} {rng.choice(OGGETTI, p=PESI)}")
    return out


valutazione = traffico(200)                    # insieme di valutazione, costruito una volta
sett1 = traffico(200)                          # stessa distribuzione
sett2 = traffico(200, quota_nuovi=0.4)         # il 40% delle query riguarda temi nuovi

vett = TfidfVectorizer().fit(valutazione)
V = vett.transform(valutazione[:100])          # il centroide usa solo la prima metà
centroide = np.asarray(V.mean(axis=0)).ravel()
centroide /= np.linalg.norm(centroide)


def punteggi(query):
    """Coseno tra ogni query e il centroide dell'insieme di valutazione."""
    return np.asarray(vett.transform(query) @ centroide).ravel()


def psi(riferimento, osservato, bin=5):
    """Population Stability Index su bin definiti dai quantili del riferimento."""
    bordi = np.unique(np.quantile(riferimento, np.linspace(0, 1, bin + 1)))
    bordi[0], bordi[-1] = -np.inf, np.inf
    e = np.histogram(riferimento, bordi)[0] / len(riferimento)
    o = np.histogram(osservato, bordi)[0] / len(osservato)
    e, o = np.clip(e, 1e-4, None), np.clip(o, 1e-4, None)
    return float(((o - e) * np.log(o / e)).sum())


rif = punteggi(valutazione[100:])               # query tenute fuori dal centroide
s1, s2 = punteggi(sett1), punteggi(sett2)
print("valori distinti di punteggio nel riferimento:", len(set(np.round(rif, 6))), "su", len(rif))
for nome, s in [("settimana 1", s1), ("settimana 2", s2)]:
    ks = ks_2samp(rif, s)
    print(f"{nome}: coseno medio={s.mean():.2f}  KS={ks.statistic:.2f} (p={ks.pvalue:.2g})  PSI={psi(rif, s):.2f}")
print("query della settimana 2 con coseno 0:", int((s2 == 0).sum()), "su", len(s2))

# Output:
# valori distinti di punteggio nel riferimento: 33 su 100
# settimana 1: coseno medio=0.39  KS=0.20 (p=0.0069)  PSI=0.21
# settimana 2: coseno medio=0.24  KS=0.45 (p=2.6e-12)  PSI=0.73
# query della settimana 2 con coseno 0: 76 su 200

La settimana 2 si distingue chiaramente: KS 0,45, PSI 0,73 e 76 query su 200 con coseno zero, cioè prive di parole in comune con l'insieme di valutazione. La settimana 1, che proviene dallo stesso generatore, dà però KS 0,20 con p = 0,0069: il test la segnalerebbe. È il limite da conoscere. KS presuppone distribuzioni continue, mentre qui i punteggi assumono solo 33 valori distinti su 100: i molti valori uguali rendono il p-value ottimistico. La lezione pratica è guardare la statistica e non solo il p-value (0,20 contro 0,45), e fissare la soglia su ciò che osservi nei periodi tranquilli del tuo traffico. Con campioni molto grandi vale l'opposto: il test diventa sensibile anche a differenze trascurabili, e ancora una volta conta la grandezza dello scarto. Le soglie di PSI che si leggono in giro sono convenzioni, non norme.

Quando intervenire: due segnali devono concordare

Lo spostamento delle query dice che il mondo è cambiato, non che il sistema peggiora. Un allarme su ogni scostamento produce rumore, e dopo tre falsi allarmi nessuno guarda il quarto. La regola del listato interviene solo se due condizioni sono vere insieme: lo scostamento supera una soglia di rilevanza (statistica KS alta e p-value basso) e la metrica di qualità, misurata sul campione, resta sotto lo SLA per due controlli consecutivi. In tutti gli altri casi lo stato è «osservare» o «nessuna azione».

def azione(storia):
    """storia: lista di controlli con 'ks', 'p', 'metrica'. Interviene solo se i due segnali concordano."""
    SLA, KS_MAX = 0.70, 0.40
    ultimi = storia[-2:]
    deriva = storia[-1]["ks"] > KS_MAX and storia[-1]["p"] < 0.01
    sotto_sla = len(ultimi) == 2 and all(c["metrica"] < SLA for c in ultimi)
    if deriva and sotto_sla:
        return "AGIRE (aggiorna eval set e indice)"
    if deriva or storia[-1]["metrica"] < SLA:
        return "OSSERVARE"
    return "NESSUNA"


controlli = [
    {"ks": 0.12, "p": 0.40, "metrica": 0.78},
    {"ks": 0.45, "p": 0.001, "metrica": 0.74},    # deriva, ma la qualità regge
    {"ks": 0.47, "p": 0.001, "metrica": 0.69},    # prima misura sotto SLA
    {"ks": 0.50, "p": 0.001, "metrica": 0.66},    # seconda consecutiva: i due segnali concordano
]
for n in range(1, len(controlli) + 1):
    print(f"controllo {n}:", azione(controlli[:n]))

# Output:
# controllo 1: NESSUNA
# controllo 2: OSSERVARE
# controllo 3: OSSERVARE
# controllo 4: AGIRE (aggiorna eval set e indice)

Al secondo controllo c'è già deriva (KS 0,45) ma la qualità regge a 0,74, quindi si osserva soltanto. Al terzo la qualità scende a 0,69, la prima misura sotto SLA: ancora si osserva. Al quarto, 0,66 è la seconda misura consecutiva sotto lo SLA e la deriva è confermata: scatta l'azione. Lo SLA di 0,70 e la soglia KS di 0,40 sono valori di esempio. Per sceglierli senza arbitrio, ripeti la misura sullo stesso insieme più volte per stimare la variabilità naturale della metrica (un LLM non è deterministico) e fissa la soglia oltre quella oscillazione. L'azione non è «riaddestrare» per default: nel RAG è quasi sempre aggiornare l'insieme di valutazione, rifare l'indice o correggere i documenti, e il modello si tocca per ultimo.

Provare una versione nuova su una parte del traffico

Quando la modifica passa i test offline, resta il rischio di ciò che l'insieme di valutazione non copre. Instrada una piccola quota del traffico alla versione candidata (qui il 10%), raccogli i punteggi per un periodo fisso e confronta le distribuzioni. Il test di Mann-Whitney U (scipy.stats.mannwhitneyu) è non parametrico: la sua ipotesi nulla è che le due distribuzioni siano uguali, e non confronta le medie ma i ranghi, quindi regge meglio a punteggi asimmetrici e a pochi valori estremi. Accanto al p-value conviene guardare la probabilità che una query della candidata superi una della versione corrente: U diviso il prodotto delle due numerosità.

import numpy as np
from scipy.stats import mannwhitneyu

rng = np.random.default_rng(5)
# Punteggi per richiesta (simulati): la versione B è leggermente migliore nella coda bassa
a = np.clip(rng.beta(7, 3, size=900), 0, 1)       # 90% del traffico, versione corrente
b = np.clip(rng.beta(7.6, 3, size=100), 0, 1)     # 10% del traffico, candidata

r = mannwhitneyu(b, a, alternative="two-sided")
print(f"n_A={len(a)} n_B={len(b)}  media A={a.mean():.3f}  media B={b.mean():.3f}")
print(f"U={r.statistic:.0f}  p={r.pvalue:.3f}  probabilità che una query B superi una A={r.statistic / (len(a) * len(b)):.3f}")
print("quota di richieste con punteggio < 0.4: A", round(float((a < 0.4).mean()), 3), " B", round(float((b < 0.4).mean()), 3))

# Output:
# n_A=900 n_B=100  media A=0.702  media B=0.710
# U=46459  p=0.595  probabilità che una query B superi una A=0.516
# quota di richieste con punteggio < 0.4: A 0.028  B 0.02

Qui la candidata ha media 0,710 contro 0,702, p = 0,595 e probabilità di superiorità 0,516: nessuna differenza dimostrabile con 100 richieste. È il risultato più frequente, e dice qualcosa di importante: con una quota piccola e poco tempo non puoi distinguere miglioramenti piccoli, e il test ti protegge dal promuovere sulla fiducia. Il confronto più utile per la sicurezza guarda la coda, la quota di richieste con punteggio basso (2,8% contro 2,0%): una regressione grave si vede lì prima che nella media. I punteggi sono simulati con semi fissi; sulla tua produzione le cifre saranno altre. Prevedi sempre un ritorno immediato alla versione precedente, con un solo interruttore.

Cosa mettere sul cruscotto

Un cruscotto serve a decidere se guardare altro, non a mostrare tutto. Per un RAG bastano poche serie nel tempo: le metriche di qualità del campione con la banda dello SLA, la statistica di spostamento delle query, la quota di query senza buoni risultati di retrieval e il volume campionato. Aggiungi ogni serie che ha già anticipato un problema reale, non quelle che «potrebbero servire». Le metriche si esportano al sistema di monitoraggio che usi già; la scelta dello strumento è meno importante di tre abitudini: soglie scritte e versionate, annotazioni dei rilasci sul grafico, e un responsabile che riceve l'allarme.

Domande frequenti

Che differenza c'è fra data drift e concept drift in un RAG?

Il data drift è un cambiamento nella distribuzione delle query in ingresso, e si vede confrontando le query con quelle dell'insieme di valutazione. Il concept drift è un cambiamento nella risposta corretta (prezzi, norme, versioni): si manifesta come risposte obsolete anche quando il retrieval trova il documento che ti aspetti.

Quanto devo campionare?

Quanto basta per avere almeno alcune decine di campioni per ogni controllo, a una frequenza che ti permetta di reagire. Con poco traffico alza la quota o aggrega per settimana; sotto qualche decina di campioni i test statistici hanno poca potenza e conviene aspettare.

Il test di Kolmogorov-Smirnov è sempre adatto?

È pensato per distribuzioni continue, quindi con molti valori ripetuti il p-value diventa approssimato, come nell'esempio. Usalo insieme alla grandezza dello scostamento e con soglie calibrate sul traffico tranquillo.

Quando conviene fermarsi a osservare?

Quando i due segnali non concordano: uno spostamento delle query con qualità stabile è un'informazione utile per ampliare l'insieme di valutazione, non un motivo per cambiare il sistema.

Che cosa sai fare adesso

Sai distinguere i tre tipi di scostamento e collegare ciascuno a un rimedio, campionare il traffico in modo riproducibile, misurare lo spostamento delle query con KS e PSI conoscendo i limiti del test, decidere un intervento solo quando spostamento e qualità concordano e confrontare una versione candidata con Mann-Whitney sulle distribuzioni, guardando anche la coda. Con questo la serie si chiude: dalla domanda perché «funziona» non è una metrica a un ciclo di misura che continua dopo il rilascio.

Nella stessa serie trovi anche l'ottimizzazione guidata dalla valutazione e il re-ranking con i cross-encoder.

Fonti

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