Salta al contenuto principale

06 - Faithfulness metric: come detectare quando LLM allucina dato il contesto

La faithfulness misura quanti claim di una risposta RAG sono sostenuti dal contesto: formula, verificatore eseguibile, attribuzione e allarmi.

Cosa ne pensi di questo articolo?

06 - Faithfulness metric: come detectare quando LLM allucina dato il contesto
RAG EngineeringFoto:RDNE Stock projectPexels

In Breve

La faithfulness misura quanti claim di una risposta RAG sono sostenuti dal contesto: formula, verificatore eseguibile, attribuzione e allarmi.

Faithfulness: quanto la risposta resta dentro il contesto

Il contesto recuperato dice: «Il piano Base costa 100 euro al mese». La risposta del modello dice: «Il piano Base costa 50 euro al mese, con uno sconto del 50% per il primo anno». Nessuna delle due affermazioni è sostenuta dal documento, eppure la frase è fluida e credibile per chi non ha il documento sotto gli occhi. Il retriever ha fatto il suo lavoro; è il generatore che ha riscritto il fatto. Questa è l'allucinazione rispetto al contesto, e la faithfulness è la metrica nata per misurarla senza bisogno di una risposta di riferimento.

Qui costruisci il meccanismo da zero. Prima con un verificatore didattico in Python che gira sulla tua macchina senza alcun modello, per vedere dove nascono gli errori e come si leggono; poi con gli strumenti veri (Ragas e un classificatore NLI), mostrati come schemi da adattare perché richiedono un modello che qui non viene eseguito. È il sesto episodio: per le metriche di retrieval vedi l'episodio sulla qualità del retrieval; per come queste misure entrano in un ciclo di sviluppo, l'episodio sull'automazione.

La definizione, con un esempio che puoi rifare a mano

La documentazione di Ragas la definisce così: una risposta è fedele se tutti i suoi claim si possono inferire dal contesto recuperato, e il punteggio è il numero di claim supportati diviso il numero di claim della risposta. Il suo esempio: alla domanda su dove e quando è nato Einstein, la risposta «in Germania il 20 marzo 1879» si scompone in due enunciati; il primo è supportato, il secondo no (il contesto dice 14 marzo), e il punteggio è 1/2 = 0,5. Nota che la data sbagliata costa metà del punteggio anche se l'altra metà è esatta: la metrica pesa i claim, non la gravità.

Il calcolo si divide sempre nello stesso modo in quattro passi: scomporre la risposta in claim atomici, verificare ciascuno contro il contesto, dividere i supportati per il totale, aggregare i punteggi su molte risposte. Il primo e il secondo passo sono quelli difficili e li affida a un giudice (un LLM o un classificatore); il terzo e il quarto sono aritmetica.

  • Non serve un golden dataset: bastano domanda, contesto recuperato e risposta generata, quindi si può calcolare su traffico reale.
  • Misura la relazione risposta-contesto, non risposta-realtà: un fatto vero ma assente dal contesto abbassa il punteggio.
  • Non misura la pertinenza: una risposta fedele può non rispondere alla domanda.

Il secondo punto è il più frainteso. Se il modello aggiunge dal proprio addestramento un dato esatto, la faithfulness scende lo stesso, ed è voluto: in un sistema RAG ti interessa che ogni affermazione sia ancorata a un documento che puoi mostrare all'utente. Per la pertinenza servono altre metriche, che nella serie vedi insieme in l'episodio su Ragas.

Un verificatore didattico: cosa vede e cosa non può vedere

Per capire dove una metrica sbaglia conviene costruirne una versione trasparente. Il listato seguente confronta ogni claim con le frasi del contesto: sceglie la frase che copre più parole piene (radici di cinque lettere) e numeri, poi decide. Se i numeri del claim stanno nella frase e la copertura supera 0,75, il claim è «supportato». Se la frase copre le parole ma i numeri non coincidono è «contraddetto». Altrimenti è «non verificabile». Le righe che contano sono la costruzione di contenuto, che separa parole e numeri, e il ramo elif (n_c - n_f), che distingue un numero sbagliato da un'informazione assente. Non è un modello linguistico: non capisce sinonimi né negazioni.

import re

STOP = {"il", "lo", "la", "i", "gli", "le", "un", "una", "di", "a", "da", "in", "con",
        "su", "per", "e", "al", "del", "della", "che", "è", "ha", "include", "offre"}


def contenuto(testo):
    """Radici delle parole piene e insieme dei numeri di una frase."""
    parole = [t for t in re.findall(r"[a-zà-ù0-9/]+", testo.lower()) if t not in STOP]
    numeri = {t for t in parole if any(c.isdigit() for c in t)}
    radici = {t[:5] for t in parole if t not in numeri}
    return radici, numeri


def verifica(claim, frasi, soglia=0.75):
    """Confronta un claim con ogni frase del contesto e tiene la più vicina."""
    r_c, n_c = contenuto(claim)
    migliore = None
    for frase in frasi:
        r_f, n_f = contenuto(frase)
        copertura = len((r_c | n_c) & (r_f | n_f)) / len(r_c | n_c)
        if migliore is None or copertura > migliore[0]:
            migliore = (copertura, frase, r_f, n_f)
    copertura, frase, r_f, n_f = migliore
    cop_parole = len(r_c & r_f) / len(r_c) if r_c else 1.0
    if n_c <= n_f and copertura >= soglia:
        etichetta = "supportato"
    elif (n_c - n_f) and cop_parole >= soglia:
        etichetta = "contraddetto"
    else:
        etichetta = "non verificabile"
    return etichetta, round(copertura, 2), frase


if __name__ == "__main__":
    CONTESTO = [
        "Il piano Base costa 100 euro al mese e include 5 utenti.",
        "Il piano Premium costa 200 euro al mese, con utenti illimitati e supporto 24/7.",
        "Il supporto telefonico non è disponibile nel piano Base.",
    ]
    RISPOSTA = ("Il piano Premium costa 200 euro al mese. Include utenti illimitati. "
                "Offre supporto 24/7. Ha 100 GB di storage.")

    claims = [c.strip() for c in RISPOSTA.split(".") if c.strip()]
    supportati = 0
    for claim in claims:
        etichetta, copertura, frase = verifica(claim, CONTESTO)
        supportati += etichetta == "supportato"
        print(f"{etichetta:17s} cop={copertura:.2f}  {claim}")
        print(f"{'':17s} frase più vicina: {frase}")
    print(f"faithfulness = {supportati}/{len(claims)} = {supportati / len(claims):.2f}")

    errata = "Il piano Base costa 50 euro al mese"
    print(errata, "->", verifica(errata, CONTESTO)[:2])

# Output:
# supportato        cop=1.00  Il piano Premium costa 200 euro al mese
#                   frase più vicina: Il piano Premium costa 200 euro al mese, con utenti illimitati e supporto 24/7.
# supportato        cop=1.00  Include utenti illimitati
#                   frase più vicina: Il piano Premium costa 200 euro al mese, con utenti illimitati e supporto 24/7.
# supportato        cop=1.00  Offre supporto 24/7
#                   frase più vicina: Il piano Premium costa 200 euro al mese, con utenti illimitati e supporto 24/7.
# non verificabile  cop=0.33  Ha 100 GB di storage
#                   frase più vicina: Il piano Base costa 100 euro al mese e include 5 utenti.
# faithfulness = 3/4 = 0.75
# Il piano Base costa 50 euro al mese -> ('contraddetto', 0.83)

Il risultato è il 3/4 della formula. I primi tre claim sono ancorati alla frase del piano Premium; il quarto, «ha 100 GB di storage», finisce sulla frase del piano Base solo perché condivide il numero 100, con copertura 0,33: il verificatore lo dichiara non verificabile, ed è corretto. Questo è anche un avvertimento: il «100» ha agganciato la frase sbagliata, e un verificatore appena meno prudente avrebbe confuso il costo con lo spazio disco. L'ultima riga mostra l'altro esito: «Il piano Base costa 50 euro» condivide quasi tutto con la frase del Base e cambia solo il numero, quindi viene marcato contraddetto, non semplicemente non verificabile.

La distinzione tra i due esiti conta in produzione. Un claim non verificabile può essere un'omissione tollerabile o un'informazione utile non presente nei documenti; un claim contraddetto è un'allucinazione attiva, quella che porta un utente a decidere su un prezzo sbagliato. Se registri entrambi i contatori, puoi mettere l'allarme sul secondo e tenere il primo come indicatore di copertura del corpus.

Da claim a documento: attribuzione e gradi di contraddizione

Sapere che un claim è contraddetto non dice dove guardare. Per il debug ti servono due informazioni in più: quale chunk il modello avrebbe dovuto usare, e se il contesto stesso è incoerente. Il secondo listato calcola la prima con la similarità del coseno su TF-IDF (scikit-learn) tra il claim e ciascun chunk, e la seconda contando in quanti chunk il verificatore dà «contraddetto»: nessuno è NONE, tutti ALL, alcuni SOMETIMES. Il contesto è costruito apposta con due listini di anni diversi.

from a_verificatore import verifica
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

CHUNK = [
    "Listino 2025: il piano Base costa 100 euro al mese.",
    "Listino 2026: il piano Base costa 120 euro al mese.",
    "Il supporto telefonico non è disponibile nel piano Base.",
]


def livello(claim, chunk):
    """NONE / SOMETIMES / ALL: quanti chunk contraddicono il claim."""
    etichette = [verifica(claim, [c])[0] for c in chunk]
    n = etichette.count("contraddetto")
    return ("NONE" if n == 0 else "ALL" if n == len(chunk) else "SOMETIMES"), etichette


claim = "Il piano Base costa 120 euro al mese"
liv, etichette = livello(claim, CHUNK)
print(liv, etichette)

# Attribuzione: quale chunk assomiglia di più al claim (similarità del coseno su TF-IDF)
vett = TfidfVectorizer().fit(CHUNK + [claim])
sim = cosine_similarity(vett.transform([claim]), vett.transform(CHUNK))[0]
for s, c in sorted(zip(sim, CHUNK), reverse=True):
    print(f"{s:.2f}  {c}")

# Output:
# SOMETIMES ['contraddetto', 'supportato', 'non verificabile']
# 0.81  Listino 2026: il piano Base costa 120 euro al mese.
# 0.62  Listino 2025: il piano Base costa 100 euro al mese.
# 0.19  Il supporto telefonico non è disponibile nel piano Base.

Il claim «costa 120 euro» è sostenuto dal listino 2026 e contraddetto dal listino 2025: livello SOMETIMES. La colpa non è del generatore, che ha scelto la riga giusta, ma del corpus, che conserva due versioni dello stesso fatto. Un giudice che guardasse un solo chunk darebbe 1,0 oppure 0,0 secondo quale chunk riceve. L'attribuzione conferma la lettura: il chunk più simile al claim, con 0,81, è proprio il listino 2026. Il rimedio sta a monte, nella gestione delle versioni dei documenti, non nel prompt.

Aggregare e allarmare: la media nasconde la coda

Un punteggio per risposta non basta; serve una vista complessiva e una regola che dica quando preoccuparsi. Il terzo listato mostra due cose. Nella prima parte, otto risposte con media 0,781 hanno però una risposta a 0,0 e una a 0,5: la deviazione standard 0,364 e la quota sotto 0,5 (12,5%) dicono ciò che la media nasconde. Nella seconda, un confronto tra l'ultima valutazione e la media delle quattro precedenti fa scattare un avviso quando il calo supera il 5% e un allarme critico oltre il 10%.

import numpy as np


def riepilogo(punteggi):
    p = np.array(punteggi)
    return {"media": p.mean(), "dev_std": p.std(ddof=1),
            "p10": np.percentile(p, 10), "sotto_0.5": float((p < 0.5).mean())}


def allarme(storico, finestra=4, warning=0.05, critico=0.10):
    """Confronta l'ultimo punteggio con la media delle `finestra` valutazioni precedenti."""
    if len(storico) < finestra + 1:
        return None
    base = float(np.mean(storico[-(finestra + 1):-1]))
    calo = (base - storico[-1]) / base
    if calo >= critico:
        return f"CRITICAL calo {calo:.1%} (base {base:.3f}, ora {storico[-1]:.2f})"
    if calo >= warning:
        return f"WARNING calo {calo:.1%} (base {base:.3f}, ora {storico[-1]:.2f})"
    return None


# Otto risposte valutate in una sera: la media nasconde la risposta a 0.0
per_risposta = [1.0, 1.0, 0.75, 1.0, 0.0, 0.5, 1.0, 1.0]
for k, v in riepilogo(per_risposta).items():
    print(f"{k:10s} {v:.3f}")

# Storico settimanale inventato a scopo didattico
storico = [0.88, 0.86, 0.89, 0.87, 0.85, 0.84, 0.82, 0.76]
for settimana in range(5, len(storico) + 1):
    print(f"settimana {settimana}:", allarme(storico[:settimana]) or "ok")

# Output:
# media      0.781
# dev_std    0.364
# p10        0.350
# sotto_0.5  0.125
# settimana 5: ok
# settimana 6: ok
# settimana 7: ok
# settimana 8: CRITICAL calo 10.1% (base 0.845, ora 0.76)

Lo storico settimanale è inventato a scopo didattico, e le soglie 5% e 10% sono valori da calibrare, non standard: misura la variabilità naturale della tua metrica su ripetizioni dello stesso insieme e imposta l'allarme oltre quella oscillazione. Nell'esempio, la settimana 8 cade del 10,1% rispetto alla base 0,845, e l'allarme critico scatta con un margine minimo: una soglia a 0,11 avrebbe taciuto. Per l'intero ciclo di monitoraggio in esercizio, dal campionamento al trigger, rimando a l'episodio 9 e, per il quadro generale sul RAG in produzione, a questo approfondimento della serie AI Engineering.

Con gli strumenti veri: Ragas e un classificatore NLI

Il verificatore didattico è lessicale; gli strumenti reali sono semantici. Ragas affida a un LLM giudice sia la scomposizione in claim sia la verifica. Nella documentazione attuale la metrica si importa da ragas.metrics.collections e si chiama con ascore (o score in sincrono); per l'interfaccia precedente basata su evaluate la documentazione annuncia la deprecazione dalla versione 0.4 e la rimozione dalla 1.0. Lo schema non è eseguito qui: serve un client verso un modello giudice, e il punteggio dipende dal giudice che scegli.

# Schema da adattare, non eseguito: serve un client verso un modello giudice.
# API "collections" della documentazione di Ragas (per la vecchia evaluate(...) la
# documentazione annuncia deprecazione dalla 0.4 e rimozione dalla 1.0).
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness

llm = llm_factory("<nome-del-modello-giudice>", client=client)  # client asincrono compatibile
scorer = Faithfulness(llm=llm)

risultato = await scorer.ascore(
    user_input="Quanto costa il piano Premium?",
    response="Il piano Premium costa 200 euro al mese. Ha 100 GB di storage.",
    retrieved_contexts=["Il piano Premium costa 200 euro al mese, con utenti illimitati."],
)
print(risultato.value)   # attesa: 0.5 (un claim su due supportato), ma dipende dal giudice

L'alternativa più economica è un classificatore di inferenza testuale (NLI). Il modello cross-encoder/nli-deberta-v3-base riceve la coppia (premessa, ipotesi) e restituisce tre punteggi nell'ordine contraddizione, implicazione, neutro; la sua scheda dichiara l'addestramento su SNLI e MultiNLI e un'accuratezza di 92,38 su SNLI-test e 90,04 su MNLI mismatched. Sono dati di inglese: per testi italiani il risultato non è garantito e va validato su esempi tuoi. Il parametro apply_softmax di predict vale False per default, quindi senza attivarlo ottieni logit, non probabilità, e una soglia come 0,5 perde significato.

# Schema da adattare, non eseguito: scarica un modello di Hugging Face.
from sentence_transformers import CrossEncoder

modello = CrossEncoder("cross-encoder/nli-deberta-v3-base")
coppie = [  # (premessa = contesto, ipotesi = claim); il modello è addestrato su inglese
    ("The Base plan costs 100 euros a month.", "The Base plan costs 50 euros a month."),
    ("The Base plan costs 100 euros a month.", "The Base plan costs 100 euros a month."),
]
punteggi = modello.predict(coppie, apply_softmax=True)   # una riga per coppia, tre colonne
ordine = ["contradiction", "entailment", "neutral"]      # ordine dichiarato nella scheda del modello
etichette = [ordine[riga.argmax()] for riga in punteggi]

Il compromesso tra i due è questo: il giudice generativo spiega il verdetto ma è lento e non deterministico; il classificatore è deterministico e adatto al controllo di massa, ma non produce spiegazioni e ha un dominio di addestramento preciso. In pratica li usi in cascata, il classificatore come filtro e il giudice sui casi dubbi, senza che io possa darti qui un tempo per claim: dipende dalla macchina e va misurato.

Dove la metrica sbaglia

Quattro punti ciechi spiegano quasi tutti i casi in cui la faithfulness segnala un problema che non c'è, o non lo vede.

  • Parafrasi: «migliaia di utenti» contro «10.000 utenti concorrenti» confonde un verificatore lessicale e a volte anche uno NLI.
  • Informazione implicita: «ha studiato informatica» è implicata da «laureato in informatica» ma non identica.
  • Negazioni: «non usa Docker» e «Docker non è usato» sono equivalenti, ma i modelli le trattano in modo diseguale.
  • Claim troppo brevi: sotto le cinque parole tendono a risultare neutri per mancanza di contesto.

Il rimedio comune è non fidarsi di un solo segnale: allarga il contesto alle frasi adiacenti, confronta più chunk e controlla a mano un campione dei casi borderline. Prima di mettere un giudice in produzione, costruisci cinquanta o sessanta esempi tuoi con parafrasi e negazioni e misura quanto spesso sbaglia; è un controllo che costa un pomeriggio e ti dice se la metrica è utilizzabile sul tuo dominio.

Domande frequenti

Cosa misura esattamente la faithfulness?

La quota di claim della risposta supportati dal contesto recuperato, cioè claim supportati divisi claim totali. Non misura la qualità assoluta né la pertinenza alla domanda, solo la fedeltà al contesto fornito. Un valore di 0,9 significa che un claim su dieci non è verificabile dal contesto.

Posso calcolarla senza golden dataset?

Sì, e il paper di Ragas la colloca fra le metriche che non richiedono annotazioni umane di riferimento. Basta la tripletta domanda, contesto, risposta, quindi si può calcolare su traffico reale, a patto di salvare il contesto effettivamente passato al modello.

Quale soglia devo usare?

Nessuna soglia vale in generale. Misura la variabilità della metrica sul tuo insieme di valutazione ripetendo la misura più volte, poi imposta l'allarme oltre quella oscillazione naturale. Per domini a rischio (medico, legale) la soglia va alzata, ma il valore lo decide il costo di un errore nel tuo caso.

Quando usare un giudice LLM e quando un classificatore NLI?

Il classificatore per lo screening di molti campioni, il giudice per i casi dubbi o quando serve una spiegazione. Entrambi vanno validati sulla tua lingua e sul tuo dominio prima di fidarti dei punteggi.

Dove va messa la valutazione nel flusso?

Fuori dal percorso della richiesta: campiona una quota delle interazioni, valutale in batch e scrivi il punteggio in uno storico. Un giudice nel percorso critico aggiungerebbe una latenza non prevedibile a ogni risposta.

Che cosa sai fare adesso

Sai calcolare la faithfulness a mano, distinguere un claim non verificabile da uno contraddetto, risalire dal claim al chunk che avrebbe dovuto sostenerlo, riconoscere un contesto incoerente e impostare un allarme che non si fida della sola media. Sai anche quali parti del sistema reale richiedono un modello e quali precauzioni prendere prima di fidarti di un classificatore addestrato su un'altra lingua. Nel prossimo episodio, l'automazione della valutazione, vedi come far girare tutto questo in batch senza aspettare ore.

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