Salta al contenuto principale

02 - Ragas framework: 4 metriche senza golden label, synthetic data generation

Le metriche di Ragas sono verdetti di un giudice ridotti a un'aritmetica che puoi rifare: formule, origine dei NaN, dati sintetici e come misurare il costo.

Cosa ne pensi di questo articolo?

02 - Ragas framework: 4 metriche senza golden label, synthetic data generation
RAG EngineeringFoto:RDNE Stock projectPexels

In Breve

Le metriche di Ragas sono verdetti di un giudice ridotti a un'aritmetica che puoi rifare: formule, origine dei NaN, dati sintetici e come misurare il costo.

Un giudice produce verdetti, il punteggio è aritmetica

Ragas valuta un sistema RAG senza che tu debba scrivere la risposta giusta per ogni domanda. Non è magia: un modello fa da giudice, scompone ciò che deve valutare in pezzi e dà un verdetto per ciascuno, e la libreria riduce i verdetti a un numero con un'aritmetica semplice. Capire questa divisione cambia il modo in cui leggi i punteggi. Il giudice può sbagliare, e questo lo controlli con un campione etichettato a mano; l'aritmetica non sbaglia, e puoi rifarla con carta e penna. Il primo episodio ha spiegato che cosa misurano le quattro metriche e che input richiedono; qui si guarda dentro, e si risponde a tre domande pratiche: da dove arriva un NaN, come si genera un test set senza annotare a mano, e quanto costa una valutazione.

Una nota sul metodo. Nessun modello è stato chiamato per scrivere questo articolo. Le formule sono state riscritte in Python con NumPy e scikit-learn, con verdetti scritti a mano al posto di quelli del giudice, e poi, per la context precision e per la faithfulness, confrontate con le funzioni di calcolo interne della versione 0.4.3 di Ragas installata in una cartella temporanea: i valori coincidono (con uno scarto dell'ordine di 10-10 sulla precision, dovuto a una costante di protezione contro la divisione per zero). La answer relevancy non è stata confrontata, perché la libreria la calcola con un modello di embedding. La libreria cambia rapidamente, quindi verifica sempre la versione che usi: le importazioni da ragas.metrics emettono oggi un avviso che rimanda a ragas.metrics.collections.

Le formule rifatte a mano

Il listato implementa le quattro metriche senza alcun modello. La context_precision riceve un verdetto per ogni chunk, nell'ordine in cui il retriever li ha consegnati: 1 se il chunk serve a rispondere, 0 altrimenti. Calcola la precisione a ogni posizione occupata da un chunk utile e ne fa la media. Faithfulness e context recall condividono la stessa funzione perché hanno la stessa forma, affermazioni supportate diviso affermazioni totali: la prima conta le affermazioni della risposta, la seconda quelle della risposta di riferimento. La answer_relevancy è l'unica che non lavora con verdetti ma con vettori: si genera qualche domanda a partire dalla risposta e si misura quanto somigliano alla domanda reale. Qui le domande generate sono scritte a mano e il vettore è un TF-IDF, perché un embedding richiederebbe un modello; i valori assoluti, quindi, non sono confrontabili con quelli di un embedding vero.

# b1.py - il giudice produce verdetti, il punteggio e aritmetica: le quattro formule con verdetti scritti a mano
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

def context_precision(verdetti: list) -> float:
    """Precisione media ai ranghi rilevanti. verdetti[i]=1 se il chunk in posizione i+1 serve."""
    rilevanti = sum(verdetti)
    if rilevanti == 0:
        return 0.0                                           # nessun chunk giusto: uno zero vero, non un NaN
    return sum(sum(verdetti[: i + 1]) / (i + 1) for i, v in enumerate(verdetti) if v) / rilevanti

def rapporto_affermazioni(verdetti: list) -> float:
    """Faithfulness (affermazioni della risposta) e context recall (affermazioni del riferimento).
    Senza affermazioni da contare il risultato e NaN: il rapporto non e definito, non vale zero."""
    return sum(verdetti) / len(verdetti) if verdetti else float("nan")

def answer_relevancy(domanda: str, domande_generate: list, evasive: list) -> float:
    """Media dei coseni domanda/domande generate dalla risposta; 0 se tutte sono evasive."""
    tf = TfidfVectorizer().fit([domanda, *domande_generate])           # TF-IDF al posto di un embedding
    v = tf.transform([domanda, *domande_generate]).toarray()
    coseni = v[1:] @ v[0] / (np.linalg.norm(v[1:], axis=1) * np.linalg.norm(v[0]))
    return float(coseni.mean()) * int(not all(evasive))

print("context_precision, chunk giusto in posizione 1:", round(context_precision([1, 0, 0]), 3))
print("context_precision, chunk giusto in posizione 2:", round(context_precision([0, 1, 0]), 3))
print("context_precision, giusti in posizione 1 e 3:  ", round(context_precision([1, 0, 1]), 3))
print("context_precision, nessun chunk giusto:         ", context_precision([0, 0, 0]))
print("faithfulness, 2 affermazioni su 3 supportate:   ", round(rapporto_affermazioni([1, 1, 0]), 3))
print("faithfulness, nessuna affermazione estratta:    ", rapporto_affermazioni([]))

D = "Quanti utenti include il piano Pro?"
pertinente = ["Quanti utenti ha il piano Pro?", "Quanti utenti sono inclusi nel piano Pro?", "Il piano Pro include utenti?"]
generica = ["Quali sono i piani disponibili?", "Quale piano conviene scegliere?", "Come funzionano i prezzi?"]
print("answer_relevancy, risposta a tema:   ", round(answer_relevancy(D, pertinente, [False] * 3), 3))
print("answer_relevancy, risposta generica: ", round(answer_relevancy(D, generica, [False] * 3), 3))
print("answer_relevancy, 'non lo so':       ", round(answer_relevancy(D, pertinente, [True] * 3), 3))

# Output:
# context_precision, chunk giusto in posizione 1: 1.0
# context_precision, chunk giusto in posizione 2: 0.5
# context_precision, giusti in posizione 1 e 3:   0.833
# context_precision, nessun chunk giusto:          0.0
# faithfulness, 2 affermazioni su 3 supportate:    0.667
# faithfulness, nessuna affermazione estratta:     nan
# answer_relevancy, risposta a tema:    0.657
# answer_relevancy, risposta generica:  0.046
# answer_relevancy, 'non lo so':        0.0

Le prime tre righe mostrano perché la context precision guarda l'ordine. Lo stesso unico chunk utile vale 1,0 in prima posizione e 0,5 in seconda; con due chunk utili, in prima e in terza posizione, la media delle precisioni ai loro ranghi dà 0,833. Se nessun chunk serve, la libreria restituisce 0,0 e non NaN: è uno zero vero, il retriever ha fallito. Il NaN è riservato ai casi in cui il rapporto non è definito, come nella faithfulness senza affermazioni da contare. Per la answer relevancy la risposta a tema ottiene 0,657 e quella generica 0,046, con questo TF-IDF e questi tre esempi inventati; quello che conta è la distanza fra le due, non i decimali.

L'ultima riga ha una conseguenza che nella pratica sorprende. Nel codice della libreria il punteggio viene moltiplicato per zero quando tutte le domande generate dal giudice sono classificate come evasive (noncommittal): una risposta onesta come «il contesto non contiene questa informazione» riceve answer relevancy 0. Per le domande a cui il sistema dovrebbe rispondere «non lo so» questa metrica penalizza il comportamento giusto. Conviene tenere queste domande in uno strato separato del dataset e valutarle con un altro criterio, come spiega l'articolo sul golden dataset.

Da dove arriva un NaN

Un NaN non è un punteggio basso: è l'assenza di un punteggio. Leggendo il sorgente della versione 0.4.3 e la documentazione di evaluate(), le origini sono poche e riconoscibili.

  • un'eccezione durante una chiamata al giudice (formato di risposta non valido, timeout, errore del provider): con raise_exceptions=False la riga fallita diventa NaN e il lavoro prosegue;
  • nessuna affermazione estratta dalla risposta, per esempio una risposta vuota o di una sola parola: la faithfulness non ha nulla da contare;
  • nessuna domanda utilizzabile generata dalla risposta, nel calcolo della answer relevancy;
  • un denominatore nullo nei casi del context recall, quando il riferimento non produce affermazioni.

Le quattro cause chiedono rimedi diversi. La prima è un problema del giudice o dell'infrastruttura e si risolve guardando i log, dove la libreria registra l'eccezione di ogni lavoro con il suo numero; le altre tre sono problemi dei dati, e vanno risolte a monte con campioni validi. Una precisazione che smentisce un consiglio frequente: raise_exceptions vale già False per impostazione predefinita in evaluate(), quindi non è un'opzione da attivare per «non bloccarsi»; al contrario, nel metodo di generazione del test set il valore predefinito è True, e lì conviene deciderlo consapevolmente.

Resta il punto che pesa di più: che cosa fai con i NaN al momento di riportare un risultato. Escluderli in silenzio equivale a supporre che i campioni falliti si sarebbero comportati come gli altri, e di solito non è vero, perché un giudice fallisce più spesso sui contesti lunghi e sulle domande difficili. Il listato mostra l'effetto con dieci punteggi di esempio, tre dei quali NaN.

# b2.py - i NaN non sono zeri e non sono assenti: riportali, e dai l'intervallo in cui puo stare la media
import math

# faithfulness di 10 campioni; NaN = il giudice ha fallito o non ha estratto affermazioni
punteggi = [1.0, 0.9, 1.0, 0.8, math.nan, 1.0, math.nan, 0.9, math.nan, 1.0]

validi = [p for p in punteggi if not math.isnan(p)]
n_nan = len(punteggi) - len(validi)

media_sui_validi = sum(validi) / len(validi)           # ipotesi ottimista: i falliti avrebbero fatto come gli altri
media_nan_a_zero = sum(validi) / len(punteggi)         # ipotesi pessimista: tutti i falliti erano risposte pessime

print(f"campioni={len(punteggi)}  NaN={n_nan} ({n_nan / len(punteggi):.0%})  validi={len(validi)}")
print(f"media sui validi:   {media_sui_validi:.3f}")
print(f"media con NaN a 0:  {media_nan_a_zero:.3f}")
print(f"la media vera sta fra {media_nan_a_zero:.2f} e {media_sui_validi:.2f}")

# Output:
# campioni=10  NaN=3 (30%)  validi=7
# media sui validi:   0.943
# media con NaN a 0:  0.660
# la media vera sta fra 0.66 e 0.94

La media sui soli campioni validi è 0,943, quella che conta i NaN come zeri è 0,660: la media vera, che non conosci, sta in mezzo, e l'ampiezza dell'intervallo (28 punti) supera di molto qualunque miglioramento che volessi dimostrare. La regola che ne discende è riportare sempre la quota di NaN accanto alla media e, se è alta, risolvere il perché prima di confrontare versioni del sistema. Non esiste una soglia di NaN accettabile valida per tutti: la soglia è la quota oltre la quale l'intervallo diventa più largo della differenza che stai cercando.

Dati sintetici: utili per cominciare, con un difetto di costruzione

TestsetGenerator parte dai tuoi documenti, ne costruisce un grafo di conoscenza e chiede a un modello di scrivere domande e risposte di riferimento. La documentazione indica come distribuzione predefinita tre tipi di domanda: a un solo passo su un dettaglio specifico (metà del totale), a più passi su un tema astratto (un quarto) e a più passi su dettagli specifici (un quarto). La vecchia tassonomia «simple, reasoning, multi-context» delle prime versioni non esiste più; se la trovi in un tutorial, il tutorial è datato. Il listato è uno schema da adattare e non è stato eseguito, perché richiede un modello e i tuoi documenti.

# b3.py - schema da adattare (non eseguito): servono un modello giudice, uno di embedding e i tuoi documenti
# Firme verificate con inspect.signature su ragas 0.4.3; nessuna chiamata a un modello e stata fatta.
from langchain_community.document_loaders import DirectoryLoader
from ragas import EvaluationDataset, evaluate
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import Faithfulness, LLMContextPrecisionWithReference, LLMContextRecall, ResponseRelevancy
from ragas.testset import TestsetGenerator
from ragas.testset.synthesizers import default_query_distribution

generatore_llm = LangchainLLMWrapper(CHAT_MODEL_LANGCHAIN)             # il tuo modello, via LangChain
generatore_emb = LangchainEmbeddingsWrapper(EMBEDDINGS_LANGCHAIN)

docs = DirectoryLoader("documenti/", glob="**/*.md").load()
generatore = TestsetGenerator(llm=generatore_llm, embedding_model=generatore_emb)
testset = generatore.generate_with_langchain_docs(
    docs,
    testset_size=30,
    query_distribution=default_query_distribution(generatore_llm),   # single-hop, multi-hop specifici e astratti
    raise_exceptions=False,                                              # il default di questo metodo e True
)
righe = testset.to_pandas()          # user_input, reference_contexts, reference, synthesizer_name

# Ora fai rispondere il TUO sistema e completa ogni riga con la risposta e i contesti recuperati
campioni = []
for r in righe.to_dict("records"):
    risposta, contesti = IL_TUO_RAG(r["user_input"])
    campioni.append({"user_input": r["user_input"], "reference": r["reference"],
                     "response": risposta, "retrieved_contexts": contesti})

risultato = evaluate(
    dataset=EvaluationDataset.from_list(campioni),
    metrics=[LLMContextPrecisionWithReference(), LLMContextRecall(), Faithfulness(), ResponseRelevancy()],
    llm=generatore_llm, embeddings=generatore_emb,
    raise_exceptions=False,              # e gia il default di evaluate(): i fallimenti diventano NaN
    token_usage_parser=PARSER_DEI_TOKEN_DEL_TUO_PROVIDER,                # senza, total_tokens() solleva un errore
)
print(risultato.to_pandas().isna().mean())    # quota di NaN per metrica
print(risultato.total_tokens())               # token consumati davvero dal giudice

Tre righe meritano attenzione. query_distribution decide la proporzione dei tipi di domanda; se lasci il valore predefinito, accetti la ripartizione citata sopra. raise_exceptions=False nella generazione evita che una domanda mal riuscita fermi l'intero lotto. L'ultima coppia di righe è quella che la maggior parte degli esempi omette: token_usage_parser è ciò che permette a total_tokens() di dire quanto ha consumato il giudice. Senza, la chiamata solleva un errore, come indica il codice della libreria.

Il difetto di costruzione è questo: ogni domanda sintetica nasce da un documento che la contiene, quindi ha sempre una risposta nel corpus. Mancano le domande senza risposta, quelle formulate con le parole degli utenti e quelle su cose che il corpus non copre, cioè proprio i casi in cui un RAG fallisce. Inoltre la risposta di riferimento è scritta da un modello, e se è lo stesso che poi fa da giudice rischi il bias di auto-preferenza descritto nell'articolo di Zheng. Leggi per intero il primo lotto di trenta domande, scarta quelle ambigue e correggi i riferimenti sbagliati: il dataset sintetico è una bozza che accelera il lavoro, non un golden dataset.

Quanto costa una valutazione

Il costo non si stima a priori con una cifra tonda: si misura su un campione piccolo e si moltiplica. Il motivo sta nel numero di chiamate che ogni metrica fa al giudice. Nel codice, la context precision con riferimento interroga il giudice una volta per ogni chunk recuperato; la faithfulness fa almeno due chiamate per campione, una per estrarre le affermazioni e una per giudicarle; la answer relevancy genera più domande per campione (tre per impostazione predefinita, parametro strictness) e chiede gli embedding. Ne segue che il costo cresce con il numero di chunk e con la lunghezza delle risposte, non solo con il numero di campioni: raddoppiare il top-k raddoppia le chiamate della precision.

Esegui prima su dieci campioni con token_usage_parser attivo, leggi total_tokens(), moltiplica per il numero di campioni e per il prezzo del tuo modello giudice. Il tempo si misura nello stesso modo. Con un modello locale il costo monetario si azzera ma il tempo cresce, e i modelli piccoli producono più risposte non valide, quindi più NaN: costo, tempo e quota di NaN si pagano insieme, e il giudice più economico non è sempre quello che costa meno in totale.

Che cosa sai fare adesso

Sai rifare a mano le quattro metriche di Ragas a partire dai verdetti del giudice e sai perché la context precision dipende dall'ordine, mentre la answer relevancy penalizza un «non lo so» onesto. Sai riconoscere le quattro origini di un NaN, riportarlo accanto alla media con l'intervallo che ne deriva e distinguere un default di evaluate() da uno di generate_with_langchain_docs(). Sai generare un test set sintetico e conosci il suo limite strutturale. Il prossimo passo è portare queste metriche dentro la tua pipeline di integrazione continua, con DeepEval a confronto con Ragas; per le metriche del solo retriever, c'è l'articolo su NDCG, MRR e HitRate.

Nella stessa serie trovi anche la metrica di faithfulness.

Domande frequenti

Ragas richiede un dataset annotato a mano?

Faithfulness e answer relevancy no: bastano domanda, risposta e contesti recuperati. Context precision e context recall, nelle versioni con riferimento, richiedono una risposta di riferimento, che puoi far proporre da TestsetGenerator ma che devi rileggere. Esiste anche una variante della precision senza riferimento.

Perché ottengo NaN in alcune righe?

Perché il giudice ha sollevato un'eccezione, perché non è stata estratta nessuna affermazione dalla risposta o perché un denominatore è nullo. Con raise_exceptions=False, già predefinito in evaluate(), la riga diventa NaN e il lavoro continua. Leggi i log: riportano l'eccezione di ogni lavoro.

Un «non lo so» corretto penalizza la answer relevancy?

Sì, nell'implementazione letta: se tutte le domande generate dalla risposta risultano evasive, il punteggio viene moltiplicato per zero. Valuta le domande a cui il sistema deve rifiutarsi in uno strato separato del dataset, con un criterio che premia il rifiuto, e non mescolarle con le altre.

Come stimo il costo di una valutazione?

Esegui dieci campioni con token_usage_parser attivo, leggi total_tokens() e moltiplica per il numero di campioni e per il prezzo del tuo modello. Il costo dipende dal numero di chunk recuperati e dalla lunghezza delle risposte, quindi va rimisurato se cambi il top-k.

Fonti

Le formule dei listati con un'uscita riportata sono state eseguite due volte con Python 3.14, NumPy e scikit-learn, con uscite identiche, e, per precision e faithfulness, confrontate con le funzioni interne di Ragas 0.4.3. Il listato dello schema di generazione e valutazione non è stato eseguito: richiede un modello giudice e i tuoi documenti.

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