Salta al contenuto principale

RAG evaluation on-prem: pipeline e metriche RAGAS DeepEval

Come misurare un RAG che gira in casa: le quattro metriche di RAGAS calcolate davvero, un giudice locale, il golden dataset, il test A/B e la deriva.

Cosa ne pensi di questo articolo?

RAG evaluation on-prem: pipeline e metriche RAGAS DeepEval
Sovereign AI PMI EUFoto:Pavel DanilyukPexels

In Breve

Come misurare un RAG che gira in casa: le quattro metriche di RAGAS calcolate davvero, un giudice locale, il golden dataset, il test A/B e la deriva.

Come sai se il tuo RAG funziona, quando non c'è nessuno a guardarlo

Un assistente interno che risponde con sicurezza può sbagliare in due modi molto diversi: il sistema di ricerca porta al modello i documenti sbagliati, oppure i documenti giusti arrivano ma il modello ci aggiunge del suo. Se guardi solo la risposta finale non sai quale dei due guasti hai, e quindi non sai che cosa riparare. Valutare un RAG significa misurare i due pezzi separatamente, su un insieme di domande di cui conosci la risposta giusta, e rifarlo ogni volta che cambi qualcosa.

Questo articolo costruisce quel metodo per una PMI che tiene i propri documenti in casa. Prima le quattro metriche come le definisce la documentazione di RAGAS, con un programma che le calcola a mano per farti vedere dove sta il trucco. Poi il golden dataset, cioè le domande con la risposta di riferimento: quante ne servono davvero si calcola, non si indovina. Poi il giudice, ed è qui che l'idea comune «RAGAS non ha bisogno di un LLM» si rivela falsa: tutte le metriche che seguono usano un modello come giudice, e dove gira quel modello è una decisione che riguarda i tuoi dati. Infine il confronto fra due configurazioni e la sorveglianza nel tempo.

Se ti manca il pezzo a monte, cioè come si costruisce l'indice e il recupero dei documenti, parti da Setup completo Ollama + RAG + RediSearch. Per la scelta del modello che farà da generatore e da giudice, il confronto fra Llama, Mistral, Qwen e Gemma è nell'articolo precedente della serie.

Le quattro metriche come le definisce RAGAS

RAGAS è una libreria open source per valutare pipeline RAG. Le sue quattro metriche più usate si dividono in due coppie: due guardano il recupero (quanto è buono l'insieme di frammenti di testo, i chunk, che arriva al modello) e due guardano la generazione (quanto è buona la risposta). La tabella dice che cosa serve a ciascuna e, punto che conta per la tua infrastruttura, che cosa ci mette dentro un modello.

MetricaChe cosa guardaChe cosa devi avereCome la calcola
Faithfulnessgenerazionedomanda, risposta, chunk recuperatiun LLM scompone la risposta in affermazioni e controlla ciascuna contro i chunk
Answer relevancygenerazionedomanda, rispostaun LLM genera domande dalla risposta; si confrontano con la domanda vera per similarità del coseno
Context precisionrecuperodomanda, riferimento, chunk in ordine di rangoun LLM giudica ogni chunk rilevante o no; si pesa la precisione per posizione
Context recallrecuperodomanda, riferimento, chunk recuperatiun LLM scompone il riferimento in affermazioni e controlla quante si ritrovano nei chunk

La tabella riassume le pagine delle metriche nella documentazione di RAGAS, consultate il 2026-11-13 (le fonti sono in fondo). La lettura utile è questa: due metriche su quattro (precision e recall) richiedono una risposta di riferimento, e tutte e quattro passano da un LLM. Quindi «valutare on-prem» significa far girare in casa anche il giudice, non solo il sistema da valutare.

Faithfulness: la risposta dice solo cose che i documenti sostengono?

La documentazione definisce il punteggio come il numero di affermazioni della risposta sostenute dal contesto diviso il numero totale di affermazioni. L'esempio ufficiale è la data di nascita di Einstein: la risposta «nato in Germania il 20 marzo 1879» contiene due affermazioni, la prima è sostenuta, la seconda no perché il contesto dice 14 marzo, e il punteggio è 1/2 = 0,5. Nota che la faithfulness non misura se la risposta è vera nel mondo, ma solo se i documenti che il sistema ha trovato la sostengono: una risposta corretta ma non ricavabile dai chunk prende zero.

Context precision: conta l'ordine, non solo l'insieme

La versione che circola più spesso (rilevanti trovati diviso totale trovati) non è quella di RAGAS. La documentazione la definisce come media delle precisioni calcolate a ogni posizione in cui compare un chunk rilevante, quindi un chunk utile al primo posto vale molto più dello stesso chunk al quinto. È il comportamento che ti serve davvero, perché il modello legge i primi chunk con più attenzione e il contesto ha un limite di lunghezza.

Context recall: hai perso qualcosa di importante?

Anche qui la definizione è per affermazioni: la risposta di riferimento viene scomposta e si conta quante affermazioni si ritrovano nei chunk recuperati. Se il riferimento dice tre cose e i chunk ne sostengono due, il recupero vale 2/3. È il motivo per cui il golden dataset deve contenere una risposta di riferimento scritta bene, e non solo l'elenco dei documenti attesi.

Answer relevancy: la risposta parla di quello che hai chiesto?

Il giudice genera, a partire dalla risposta, alcune domande artificiali (tre per impostazione predefinita) e RAGAS calcola la similarità del coseno fra ciascuna e la domanda vera, poi ne fa la media. Una risposta evasiva o piena di digressioni produce domande lontane da quella originale e quindi un punteggio basso. Non misura la correttezza: una risposta falsa ma pertinente prende un punteggio alto.

Le formule, eseguite sui giudizi già dati

Per capire dove stanno i trucchi conviene calcolare le metriche senza alcun modello, partendo da giudizi già dati: ogni affermazione e ogni chunk è un booleano che in produzione verrebbe dal giudice. Il programma qui sotto contiene tre funzioni, una per metrica. In context_precision la riga che conta è quella che somma veri_positivi / k solo quando il chunk in posizione k è rilevante: toglila e torni alla formula ingenua che ignora l'ordine. L'ultimo esperimento usa gli stessi cinque chunk, con due soli rilevanti, ma in ordine diverso.

"""Le quattro metriche RAG calcolate a mano sui giudizi gia' dati."""


def faithfulness(giudizi):
    """giudizi: un booleano per affermazione della risposta (True = sostenuta dal contesto)."""
    return sum(giudizi) / len(giudizi)


def context_recall(giudizi_riferimento):
    """Un booleano per affermazione della risposta di riferimento (True = trovata nei chunk)."""
    return sum(giudizi_riferimento) / len(giudizi_riferimento)


def context_precision(rilevanti):
    """rilevanti: un booleano per chunk, in ordine di rango. Media di precision@k pesata."""
    veri_positivi = 0
    somma = 0.0
    for k, ok in enumerate(rilevanti, start=1):
        if ok:
            veri_positivi += 1
            somma += veri_positivi / k
    return somma / veri_positivi if veri_positivi else 0.0


# Esempio di Einstein: due affermazioni, una sbagliata nella data
print("faithfulness  :", faithfulness([True, False]))

# Stessi 5 chunk, due soli rilevanti, ordine diverso
print("precision A   :", round(context_precision([True, True, False, False, False]), 3))
print("precision B   :", round(context_precision([False, False, False, True, True]), 3))

# Risposta di riferimento con 3 affermazioni, 2 trovate nei chunk
print("recall        :", round(context_recall([True, True, False]), 3))

# Output:
# faithfulness  : 0.5
# precision A   : 1.0
# precision B   : 0.325
# recall        : 0.667

L'uscita si legge così. La faithfulness 0,5 riproduce il caso di Einstein. Le due precisioni sono la sorpresa: stessi chunk, stesso numero di rilevanti, ma 1,0 se i due utili sono in testa e 0,325 se sono in fondo, perché le posizioni 4 e 5 danno precisioni parziali di 1/4 e 2/5. Per questo un cambio di reranker, che riordina senza aggiungere né togliere documenti, si vede nella context precision e non nella recall. Il codice non dipende da alcun modello e dà la stessa uscita a ogni esecuzione (verificato con due lanci consecutivi). Il giudice vero sostituisce solo i booleani.

Il golden dataset: quante domande servono e come si controllano

Il golden dataset è l'insieme di domande per cui qualcuno ha già deciso la risposta giusta e quali documenti la contengono. Ogni voce ha quattro campi: la domanda, la risposta di riferimento, gli identificativi dei documenti attesi, e un'etichetta di categoria che poi ti permette di leggere i punteggi per area. Le domande vengono dai ticket e dalle chat reali, perché quelle inventate a tavolino assomigliano alle domande che ti aspetti, non a quelle che arrivano.

Resta la domanda su quante voci servano. La risposta onesta è che dipende da quanta incertezza sei disposto a tollerare sul punteggio. Con poche domande un punteggio dell'80% può nascondere un valore vero lontano da quello. Il programma qui sotto calcola l'intervallo di confidenza di Wilson al 95% (lo stesso che scipy.stats.binomtest(...).proportion_ci(method='wilson') restituisce: ho verificato che per 80 su 100 danno entrambi da 0,711 a 0,867) e, nella prima parte, controlla un piccolo golden dataset costruito per l'esempio. I tre difetti inseriti sono quelli che trovi davvero: una domanda duplicata, un riferimento vuoto, un documento atteso che non esiste più nella base.

"""Controllo del golden dataset e ampiezza dell'incertezza in funzione della dimensione."""
import math

# Esempio costruito: tre voci di un assistente interno di una PMI (dati inventati per l'esempio)
KB = {"kb-101", "kb-102", "kb-205"}
GOLDEN = [
    {"id": "g1", "query": "Come reimposto la password?",
     "reference": "Dal modulo di accesso, voce Password dimenticata.", "expected_ids": ["kb-101"]},
    {"id": "g2", "query": "Come reimposto la password?",
     "reference": "Dal modulo di accesso.", "expected_ids": ["kb-101"]},
    {"id": "g3", "query": "Dove trovo la fattura di marzo?",
     "reference": "", "expected_ids": ["kb-999"]},
]


def controlla(golden, kb):
    problemi, viste = [], {}
    for voce in golden:
        if not voce["reference"].strip():
            problemi.append(f"{voce['id']}: risposta di riferimento vuota")
        for doc in voce["expected_ids"]:
            if doc not in kb:
                problemi.append(f"{voce['id']}: documento atteso {doc} non esiste nella base")
        q = voce["query"].strip().lower()
        if q in viste:
            problemi.append(f"{voce['id']}: domanda uguale a {viste[q]}")
        viste.setdefault(q, voce["id"])
    return problemi


def wilson(successi, n, z=1.96):
    p = successi / n
    centro = (p + z * z / (2 * n)) / (1 + z * z / n)
    meta = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
    return centro - meta, centro + meta


for p in controlla(GOLDEN, KB):
    print("PROBLEMA", p)

print("--- intervallo al 95% se il sistema risponde bene all'80% delle domande")
for n in (30, 100, 300, 1000):
    lo, hi = wilson(round(0.8 * n), n)
    print(f"n={n:4d}  da {lo:.3f} a {hi:.3f}  (ampiezza {hi - lo:.3f})")

print("--- tempo di annotazione, ipotesi: 10 minuti a voce")
for n in (30, 100, 300):
    print(f"n={n:3d} -> {n * 10 / 60:.1f} ore")

# Output:
# PROBLEMA g2: domanda uguale a g1
# PROBLEMA g3: risposta di riferimento vuota
# PROBLEMA g3: documento atteso kb-999 non esiste nella base
# --- intervallo al 95% se il sistema risponde bene all'80% delle domande
# n=  30  da 0.627 a 0.905  (ampiezza 0.278)
# n= 100  da 0.711 a 0.867  (ampiezza 0.155)
# n= 300  da 0.751 a 0.841  (ampiezza 0.090)
# n=1000  da 0.774 a 0.824  (ampiezza 0.050)
# --- tempo di annotazione, ipotesi: 10 minuti a voce
# n= 30 -> 5.0 ore
# n=100 -> 16.7 ore
# n=300 -> 50.0 ore

La seconda metà dell'uscita è il dato che ti serve per decidere. Con 100 voci l'intervallo è largo 15,5 punti percentuali (da 71% a 87%): un sistema che ottiene 80% potrebbe valere 72 o 86. Con 300 voci scende a 9 punti, con 1.000 a 5. Quindi «100 domande bastano» è vero solo se ti accontenti di distinguere un sistema buono da uno pessimo, non due configurazioni vicine. Il costo dell'annotazione è lineare: con l'ipotesi, tua da sostituire con la misura sul tuo team, di 10 minuti a voce, 100 voci sono 16,7 ore di lavoro di una persona competente nel dominio. Delegare l'annotazione a sconosciuti ha senso per i contenuti generici, non per un dominio fiscale o legale, dove la risposta giusta la conosce chi lavora lì.

Il giudice in casa: RAGAS e DeepEval con un modello locale

Dato che ogni metrica passa da un LLM, i documenti dell'azienda e le risposte generate finiscono nel prompt del giudice. Se il giudice è un servizio esterno, ogni valutazione manda i tuoi contenuti fuori dalla tua infrastruttura, con tutte le conseguenze di protezione dei dati che l'articolo sulla privacy by design mette in fila. Un giudice locale evita il problema ma ha un costo diverso: un modello piccolo giudica peggio di uno grande, e RAGAS e DeepEval gli chiedono un JSON strutturato che i modelli piccoli sbagliano più spesso.

RAGAS con Ollama

La guida ai LLM adapter di RAGAS mostra proprio questo caso: Ollama espone un'API compatibile con quella di OpenAI, quindi si usa il client OpenAI con base_url locale e la funzione llm_factory con provider="openai". La chiave è obbligatoria per il client ma Ollama la ignora, per questo vale una stringa qualunque. Lo schema qui sotto è tratto dalla documentazione e adattato alla faithfulness; non l'ho eseguito perché servirebbero un server Ollama e un modello scaricato, e per questo non riporto nessun punteggio.

# Schema da adattare, NON eseguito: richiede ragas, un server Ollama e un modello scaricato.
from openai import OpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness

# Ollama espone un'API compatibile con quella di OpenAI; la chiave è obbligatoria ma ignorata.
client = OpenAI(api_key="ollama", base_url="http://localhost:11434/v1")
llm = llm_factory("mistral", provider="openai", client=client)

scorer = Faithfulness(llm=llm)
risultato = scorer.score(
    user_input="Dove è nato Einstein e quando?",
    response="Einstein è nato in Germania il 20 marzo 1879.",
    retrieved_contexts=["Albert Einstein (nato il 14 marzo 1879) fu un fisico teorico tedesco."],
)
print(risultato.value)  # il punteggio dipende dal modello giudice: non riportiamo un valore

Due avvertenze sul codice. Le versioni di RAGAS cambiano API con frequenza: la documentazione stessa segna come legacy il vecchio percorso from ragas.metrics import ... e raccomanda ragas.metrics.collections, quindi controlla la pagina della versione che installi. E il modello che passi a llm_factory deve esistere sul tuo server: «mistral» è il nome che usa l'esempio della documentazione, non una raccomandazione.

DeepEval con un giudice custom

DeepEval ha le sue metriche, con nomi diversi (FaithfulnessMetric, AnswerRelevancyMetric) e lo stesso principio: un LLM giudica. Per impostazione predefinita usa un modello di OpenAI (la pagina consultata il 2026-11-13 indica gpt-5.4 come valore predefinito di model), ma accetta qualunque classe che erediti da DeepEvalBaseLLM. La guida ai modelli custom elenca le regole: implementare get_model_name, load_model, generate e a_generate, e accettare un parametro schema, perché senza di esso le metriche che si aspettano un oggetto strutturato falliscono con un errore poco chiaro. Il ramo con lo schema usa il campo format di Ollama, che accetta uno schema JSON e vincola l'uscita del modello.

# Schema da adattare, NON eseguito: richiede deepeval, ollama (client Python) e un modello locale.
from pydantic import BaseModel
from ollama import Client
from deepeval.models import DeepEvalBaseLLM
from deepeval.metrics import FaithfulnessMetric
from deepeval.test_case import LLMTestCase


class GiudiceLocale(DeepEvalBaseLLM):
    def __init__(self, modello: str):
        self.modello = modello
        self.client = Client(host="http://localhost:11434")

    def load_model(self):
        return self.client

    def generate(self, prompt: str, schema: BaseModel | None = None):
        messaggi = [{"role": "user", "content": prompt}]
        if schema is None:
            return self.client.chat(model=self.modello, messages=messaggi).message.content
        # Con lo schema Ollama vincola l'uscita a un JSON valido
        r = self.client.chat(model=self.modello, messages=messaggi, format=schema.model_json_schema())
        return schema.model_validate_json(r.message.content)

    async def a_generate(self, prompt: str, schema: BaseModel | None = None):
        return self.generate(prompt, schema)

    def get_model_name(self):
        return self.modello


metrica = FaithfulnessMetric(threshold=0.7, model=GiudiceLocale("mistral"), include_reason=True)
caso = LLMTestCase(
    input="Posso restituire le scarpe?",
    actual_output="Sì, entro 30 giorni e senza costi aggiuntivi.",
    retrieval_context=["Tutti i clienti hanno diritto al rimborso entro 30 giorni, senza costi aggiuntivi."],
)
metrica.measure(caso)
print(metrica.score, metrica.reason)

Anche questo è uno schema da adattare, non eseguito. La riga più importante è quella di generate che passa schema.model_json_schema() a format: senza il vincolo un modello piccolo risponde con testo libero attorno al JSON e DeepEval non lo legge. Quando il tuo giudice dà risultati strani, il primo sospetto è sempre il formato dell'uscita.

RAGAS o DeepEval, e G-Eval

Nessuno dei due è «più accurato» in assoluto: le cifre di accuratezza percentuale che si trovano in giro per i due framework non vengono da una fonte che io abbia potuto verificare, e non le riporto. Le differenze verificabili sono altre.

  • RAGAS offre le metriche del recupero e della generazione descritte sopra, con formule documentate, ed è pensato per il flusso «dataset in, punteggi fuori».
  • DeepEval ha un'impostazione da test: ogni caso ha una soglia (0,5 per impostazione predefinita), un risultato superato o no, e una motivazione in testo; si integra in una suite di test in CI.
  • DeepEval include anche G-Eval, una metrica in cui descrivi tu il criterio in linguaggio naturale e il giudice produce il punteggio.

Come criterio di scelta: se vuoi un cruscotto del recupero e della generazione con metriche standard, RAGAS; se vuoi che la qualità blocchi una modifica come farebbe un test, DeepEval. Si possono usare entrambi sugli stessi dati. G-Eval nasce dal paper di Liu e altri (2023), che propone un giudice LLM con catena di ragionamento e compilazione di una scheda; la documentazione di DeepEval la presenta come il modo per valutare criteri soggettivi e specifici del tuo caso, da affiancare alle metriche di sistema. Il corollario per i domini regolati: un giudice locale non è un avvocato né un medico. Un criterio come «è legalmente corretta» può dare un punteggio, ma il punteggio va confrontato con il giudizio di una persona competente prima di fidarsene.

Quanto fidarsi del giudice locale

Prima di appoggiarti ai punteggi, misura quanto il tuo giudice concorda con una persona. Prendi 30-50 voci del golden dataset, fai giudicare a mano la faithfulness di ciascuna risposta, confronta con il verdetto del giudice e calcola la percentuale di accordo. Se il giudice contraddice l'umano più di una volta su cinque, cambia modello o semplifica il prompt prima di costruirci sopra allarmi automatici. È un controllo che si fa una volta, e salva da mesi di grafici che sembrano precisi e non lo sono.

Confrontare due configurazioni senza ingannarsi

Scenario: il sistema usa chunk da 512 token e vuoi provare chunk più corti. L'impostazione ingenua divide le domande a caso in due gruppi, una configurazione per gruppo, e confronta le medie. Il difetto è che le domande hanno difficoltà diverse: se per caso le più difficili finiscono in un gruppo, vedi una differenza che non c'entra con i chunk. La soluzione è far girare entrambe le configurazioni sulle stesse domande e confrontare, domanda per domanda, chi ha risposto bene.

Il test adatto a questo schema appaiato è quello di McNemar, che guarda solo le domande su cui le due configurazioni differiscono (le discordanti): se sono in numero uguale a favore dell'una e dell'altra, non c'è differenza. In SciPy la forma esatta si ottiene con binomtest sulle discordanti; la funzione mcnemar non è in scipy.stats ma in StatsModels. I numeri del programma sono costruiti per l'esempio: 100 domande, 62 giuste in entrambe, 19 sbagliate in entrambe, 14 giuste solo con i chunk corti e 5 solo con quelli lunghi.

"""Confronto di due configurazioni di RAG sulle STESSE 100 domande (test di McNemar esatto)."""
from scipy.stats import binomtest

# Esito per domanda: 1 = risposta giudicata corretta. Dati costruiti per l'esempio:
# 62 domande corrette in entrambe, 19 sbagliate in entrambe, 5 solo con chunk 512, 14 solo con chunk 256.
solo_512 = 5
solo_256 = 14
entrambe_ok = 62
entrambe_ko = 19
n = solo_512 + solo_256 + entrambe_ok + entrambe_ko

print(f"domande: {n}")
print(f"corrette con 256: {entrambe_ok + solo_256}   con 512: {entrambe_ok + solo_512}")

# Contano solo le domande su cui le due configurazioni DIFFERISCONO (le discordanti).
discordanti = solo_512 + solo_256
esito = binomtest(solo_256, discordanti, p=0.5, alternative="two-sided")
print(f"discordanti: {discordanti}, vinte da 256: {solo_256}")
print(f"p-value esatto: {esito.pvalue:.4f}")

# Stessa differenza, ma con solo 4 vs 9 discordanti: il verdetto cambia.
esito_piccolo = binomtest(9, 13, p=0.5)
print(f"con 9 vs 4 discordanti: p-value {esito_piccolo.pvalue:.4f}")

# Output:
# domande: 100
# corrette con 256: 76   con 512: 67
# discordanti: 19, vinte da 256: 14
# p-value esatto: 0.0636
# con 9 vs 4 discordanti: p-value 0.2668

Il risultato è controintuitivo ed è il motivo per cui il test si fa: 76 risposte giuste contro 67 sono nove punti di differenza, ma il p-value esatto è 0,0636, sopra la soglia del 5% consueta. Con quel campione non puoi dire che i chunk corti vincano. L'ultima riga mostra l'altra faccia: con 9 discordanti a favore contro 4 il p-value è 0,27, a conferma che contano le discordanti, non la differenza lorda. La conseguenza pratica è allargare il golden dataset prima di decidere, non scegliere la configurazione che «sembra» meglio. Ricorda anche che una soglia di 0,05 è una convenzione: dichiarala prima di vedere i dati.

Sorvegliare la deriva nel tempo

Un RAG degrada anche quando nessuno tocca il codice: i documenti cambiano, il golden dataset invecchia, un aggiornamento sostituisce il modello di embedding. L'idea è far girare ogni notte la valutazione sul golden dataset (una riga di cron, sotto) e registrare i punteggi, poi guardare se gli ultimi giorni si discostano dalla base di riferimento. Una soglia fissa come «allarme sotto 0,75» è fragile: dipende dal livello normale del tuo sistema, che può essere 0,60 o 0,92. Meglio confrontare con la media e la dispersione dei giorni precedenti.

# Schema da adattare, NON eseguito. Ogni notte alle 02:00 (ora del server) valuta il golden dataset
# e aggiunge una riga al file dei punteggi; lo script ragas_eval.py è quello della tua pipeline.
0 2 * * * /opt/rag/venv/bin/python /opt/rag/ragas_eval.py --golden /opt/rag/golden.json --append /var/lib/rag/punteggi.jsonl

La riga è uno schema da adattare e non l'ho eseguita: i cinque campi significano minuto, ora, giorno del mese, mese e giorno della settimana, quindi 0 2 * * * è «alle 02:00 di ogni giorno». Lo script e i percorsi sono quelli della tua pipeline; lascia i punteggi in un file a righe JSON, che si accoda senza riscrivere. Il rilevatore vero, invece, è logica pura e si può eseguire.

Il programma usa 14 giorni come base e le tre misure più recenti come finestra, e segnala l'allarme quando la media recente scende sotto la media di base meno tre deviazioni standard. La serie è costruita per l'esempio: quindici giorni stabili attorno a 0,85 e poi un crollo a 0,71, 0,69, 0,72. La seconda riga di uscita verifica che, senza il crollo, il rilevatore non scatti: un allarme che suona sempre viene ignorato.

from statistics import mean, stdev

# Punteggi giornalieri di context recall su una settimana e mezza: SERIE COSTRUITA per l'esempio.
punteggi = [0.84, 0.86, 0.85, 0.83, 0.87, 0.85, 0.84, 0.86, 0.85, 0.84,
            0.85, 0.86, 0.84, 0.85, 0.83, 0.71, 0.69, 0.72]


def deriva(serie, base_giorni=14, finestra=3, k=3.0):
    base = serie[:base_giorni]
    recenti = serie[-finestra:]
    media_base, dev_base = mean(base), stdev(base)
    soglia = media_base - k * dev_base
    return {
        "media_base": round(media_base, 3),
        "dev_base": round(dev_base, 3),
        "soglia": round(soglia, 3),
        "media_recente": round(mean(recenti), 3),
        "allarme": mean(recenti) < soglia,
    }


print(deriva(punteggi))
print("senza il crollo:", deriva(punteggi[:15], finestra=1)["allarme"])

# Output:
# {'media_base': 0.849, 'dev_base': 0.011, 'soglia': 0.817, 'media_recente': 0.707, 'allarme': True}
# senza il crollo: False

Con base 0,849, deviazione 0,011 e soglia 0,817 la media recente di 0,707 fa scattare l'allarme. Il parametro k regola quanto è sensibile il rilevatore: con k più basso scatta prima ma sbaglia più spesso. Due limiti da conoscere: se la base contiene già un periodo degradato, la soglia scende con lei, e con una finestra di tre giorni un solo punteggio anomalo può bastare, per cui conviene richiedere che il calo duri più di un giorno. Un allarme dice che qualcosa è cambiato, non che cosa.

Quando il punteggio crolla: dove guardare per primo

Supponi che la context recall passi da 0,85 a 0,62 da un giorno all'altro. L'ordine delle verifiche conviene sia dal più economico al più costoso, sia da ciò che è cambiato più probabilmente. Una regola di lettura sostiene l'intero schema: se calano le metriche del recupero, il guasto sta a monte del modello, e cambiare il generatore non serve.

  1. Il golden dataset e la base documentale sono ancora allineati? Documenti spostati, rinominati o cancellati fanno sparire i riferimenti attesi, e la recall crolla senza che il sistema sia peggiorato.
  2. Il modello di embedding è lo stesso con cui è stato costruito l'indice? Cambiarlo senza reindicizzare confronta vettori di spazi diversi.
  3. L'indice è integro? Con RediSearch il comando FT.INFO restituisce informazioni e statistiche sull'indice, fra cui il numero di documenti, da confrontare con quello atteso.
  4. Sono cambiati i parametri del recupero, per esempio il numero di chunk restituiti?
  5. È cambiato il giudice? Un aggiornamento del modello giudice sposta tutti i punteggi senza che il sistema valutato sia cambiato.

L'ultima voce è la più subdola: per questo vale la pena registrare accanto a ogni punteggio il nome e la versione del modello giudice e del modello di embedding. Quando due punti consecutivi della serie hanno giudici diversi, il salto non va confrontato. Per la riparazione dell'indice non scrivere FLUSHDB a occhi chiusi: cancella tutte le chiavi della base di dati selezionata, non solo l'indice, e ci sono dentro anche sessioni e cache. Ricostruisci l'indice con i comandi dedicati e verifica con FT.INFO a ricostruzione finita.

Domande frequenti

RAGAS ha bisogno di un LLM?

Sì. Tutte e quattro le metriche descritte qui chiamano un LLM giudice, per scomporre le affermazioni o giudicare i chunk, e la faithfulness e la context recall non si calcolano senza. Se vuoi tenere i dati in casa, il giudice deve essere un modello locale.

Quante domande servono nel golden dataset?

Dipende dalla precisione che ti serve. Con 100 voci un punteggio dell'80% ha un intervallo al 95% da 71% a 87%, con 300 da 75% a 84%. Poche decine bastano per accorgerti di un guasto grosso, non per confrontare due configurazioni vicine.

Posso valutare con lo stesso modello che genera le risposte?

Si può, ma un modello tende a giudicare con indulgenza il proprio stile. Se puoi, usa un modello diverso come giudice e misura l'accordo con le etichette umane su un campione prima di fidarti dei punteggi.

Che cosa sai fare adesso

Sai distinguere un guasto del recupero da uno della generazione, perché le quattro metriche li separano e sai come si calcolano davvero. Sai costruire un golden dataset e dire quanto è credibile un punteggio in funzione di quante voci contiene. Sai collegare RAGAS e DeepEval a un giudice locale senza mandare i documenti all'esterno, e sai che un giudice piccolo va calibrato sulle etichette umane. Sai confrontare due configurazioni con un test appaiato ed evitare di decidere su una differenza che è rumore, e sai sorvegliare il sistema nel tempo con una base di riferimento invece di una soglia fissa. Il passo successivo della serie è il carico, in da 1 a 50 utenti concorrenti, e poi che cosa fare quando il sistema si ferma, nel disaster recovery.

Nella stessa serie trovi anche i casi in cui conviene un servizio gestito e il riepilogo con la scheda di decisione.

Fonti

I programmi dell'articolo sono stati eseguiti con Python 3.14.4 e SciPy 1.16.3, due volte ciascuno con uscita identica; i dati degli esempi sono costruiti e dichiarati tali. Gli schemi con RAGAS, DeepEval e Ollama non sono stati eseguiti.

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