Non scegli un vincitore, scegli dove mettere il cancello
Ragas e DeepEval fanno cose in gran parte sovrapposte: entrambi calcolano faithfulness, pertinenza della risposta e precisione e richiamo del contesto con un modello come giudice. La differenza che incide sul lavoro di ogni giorno non sta nelle metriche ma nel punto in cui si inseriscono. Ragas nasce per valutare un lotto di campioni e restituire una tabella di punteggi; DeepEval si presenta come un test: una funzione assert_test che fallisce con una ragione leggibile e un comando, deepeval test run, che esegue pytest e restituisce il suo codice di uscita. Se il tuo problema è «sapere come sta andando», il primo basta. Se è «impedire che una modifica peggiori il sistema», ti serve il secondo, o una colla scritta da te attorno al primo.
Questo articolo parte dal caso di Ragas e porta le metriche in un cancello di integrazione continua. Il metodo è rovesciato rispetto al solito: il primo listato non usa nessun modello. Costruisce un cancello con due metriche deterministiche scritte da te e lo esegue davvero, perché la parte che in CI si rompe per prima non è il giudice, è l'intelaiatura: soglie, codici di uscita, messaggi di errore. Il giudice a modello arriva dopo, come schema da adattare. Le firme di DeepEval sono state lette sulla versione 4.3.4 installata in una cartella temporanea; il listato con i modelli non è stato eseguito perché nessun modello viene chiamato.
Che cosa hanno in comune e dove differiscono
La tabella contiene solo ciò che ho potuto verificare nella documentazione e nel codice delle due librerie. Non confronta velocità, prezzi né qualità del giudizio: non li ho misurati.
| Aspetto | Ragas 0.4.3 | DeepEval 4.3.4 |
|---|---|---|
| Faithfulness | Faithfulness: risposta e contesti |
FaithfulnessMetric: input, risposta e contesto recuperato |
| Precisione del contesto | ContextPrecision con riferimento; esiste anche una variante senza riferimento |
ContextualPrecisionMetric: richiede expected_output |
| Soglia di successo | nessuna: restituisce un valore | un parametro threshold, 0,5 per impostazione predefinita |
| Motivazione del punteggio | campo opzionale reason nel risultato dell'interfaccia collections (non osservato valorizzato: nessun modello chiamato) |
include_reason, attivo per impostazione predefinita |
| Criterio personalizzato | metriche a rubrica e a criteri semplici | GEval (criteri in linguaggio naturale) e BaseMetric per metriche in codice |
| Dati sintetici | TestsetGenerator |
un sintetizzatore di golden, descritto nella documentazione |
| Integrazione con pytest | da scrivere | assert_test e deepeval test run |
La riga che pesa è quella della soglia. Ragas ti restituisce numeri e la decisione resta tua; DeepEval incorpora la decisione (threshold) dentro la metrica, e per questo si presta a un test che passa o fallisce. Due avvertenze che la tabella non può contenere. Primo: le metriche con lo stesso nome non sono intercambiabili, perché i prompt e i passaggi interni sono diversi, quindi un 0,82 di faithfulness in una libreria non vale un 0,82 nell'altra; non mischiare le serie storiche. Secondo: se non passi un modello, la documentazione di DeepEval indica come predefinito un modello di OpenAI, il che significa chiave di quel fornitore e invio a quel fornitore dei tuoi documenti. Passa sempre il giudice in modo esplicito, e sceglilo sapendo dove vanno i dati.
Un cancello senza modello: due metriche, quattro casi
Il listato definisce due metriche estendendo BaseMetric, la classe base di DeepEval. Una metrica personalizzata deve fornire measure, la versione asincrona a_measure, is_successful e il nome. RecuperoMetric vale 1 se almeno un documento atteso compare fra i primi k recuperati; gli identificativi viaggiano nel campo metadata del caso di test. NumeriMetric è il controllo sui numeri dell'episodio 1, qui espresso come quota di numeri della risposta che compaiono nel contesto. Entrambe hanno threshold=1.0: un caso passa solo se tutti i numeri sono appoggiati e se il recupero è riuscito.
# b1.py - un cancello di qualita con assert_test e due metriche deterministiche (nessun modello)
import os, re
os.environ["DEEPEVAL_TELEMETRY_OPT_OUT"] = "YES" # niente telemetria dalla macchina di prova
from deepeval import assert_test
from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase
class RecuperoMetric(BaseMetric):
"""1 se un documento atteso e fra i primi k recuperati, altrimenti 0. Gli id stanno in metadata."""
def __init__(self, k: int = 3, threshold: float = 1.0):
self.k, self.threshold = k, threshold
def measure(self, test_case: LLMTestCase) -> float:
attesi = set(test_case.metadata["id_attesi"])
trovati = set(test_case.metadata["id_recuperati"][: self.k])
self.score = 1.0 if attesi & trovati else 0.0
self.success = self.score >= self.threshold
self.reason = f"attesi={sorted(attesi)} recuperati_top{self.k}={test_case.metadata['id_recuperati'][: self.k]}"
return self.score
async def a_measure(self, test_case: LLMTestCase) -> float:
return self.measure(test_case)
def is_successful(self) -> bool:
return self.success
@property
def __name__(self):
return "Recupero"
class NumeriMetric(BaseMetric):
"""Punteggio = quota di numeri della risposta presenti nel contesto (1.0 se la risposta non ne ha)."""
def __init__(self, threshold: float = 1.0):
self.threshold = threshold
def measure(self, test_case: LLMTestCase) -> float:
contesto = " ".join(test_case.retrieval_context)
nella_risposta = re.findall(r"\d+(?:[.,]\d+)?", test_case.actual_output)
ok = [n for n in nella_risposta if n in re.findall(r"\d+(?:[.,]\d+)?", contesto)]
self.score = len(ok) / len(nella_risposta) if nella_risposta else 1.0
self.success = self.score >= self.threshold
self.reason = f"numeri nella risposta={nella_risposta}, con appoggio={ok}"
return self.score
async def a_measure(self, test_case: LLMTestCase) -> float:
return self.measure(test_case)
def is_successful(self) -> bool:
return self.success
@property
def __name__(self):
return "Numeri"
def caso(i, domanda, risposta, contesto, attesi, recuperati):
return LLMTestCase(input=domanda, actual_output=risposta, retrieval_context=contesto,
metadata={"id_attesi": attesi, "id_recuperati": recuperati}, name=i)
CASI = [
caso("q1", "Quanto costa il piano Pro?", "Costa 49 euro al mese.", ["Il piano Pro costa 49 euro al mese."], ["prezzi"], ["prezzi", "api"]),
caso("q2", "Quanti utenti include il Pro?", "Include fino a 25 utenti.", ["Il piano Pro costa 49 euro al mese."], ["utenti"], ["prezzi", "api"]),
caso("q3", "Quante richieste al minuto?", "Il limite e di 100 richieste al minuto.", ["Le API accettano 100 richieste al minuto."], ["api"], ["sso", "api"]),
caso("q4", "Come reimposto la password?", "Usa il link inviato per email.", ["Per reimpostare la password usa il link inviato per email."], ["password"], ["fatture", "api", "sso", "password"]),
]
def test_caso(c: LLMTestCase):
"""Con pytest sarebbe @pytest.mark.parametrize("c", CASI) e si lancerebbe 'deepeval test run file.py'."""
assert_test(c, [RecuperoMetric(k=3), NumeriMetric()], run_async=False)
fallimenti = 0
for c in CASI:
try:
test_caso(c)
print(f"{c.name}: PASSA")
except AssertionError as e:
fallimenti += 1
falliti = re.findall(r"(\w+) \(score: ([\d.]+)", str(e)) # nomi e punteggi delle metriche sotto soglia
print(f"{c.name}: FALLISCE -> " + ", ".join(f"{n}={s}" for n, s in falliti))
tasso = 1 - fallimenti / len(CASI)
print(f"tasso di successo={tasso:.0%} soglia del cancello=75% -> {'merge consentito' if tasso >= 0.75 else 'merge bloccato'}")
# Output:
# q1: PASSA
# q2: FALLISCE -> Recupero=0.0, Numeri=0.0
# q3: PASSA
# q4: FALLISCE -> Recupero=0.0
# tasso di successo=50% soglia del cancello=75% -> merge bloccato
Il cancello è nell'ultima riga, non in assert_test. Ogni singolo caso passa o fallisce, e il messaggio dell'errore elenca le metriche sotto soglia con il loro punteggio: q2 fallisce su entrambe (il retriever non ha portato il documento sugli utenti e la risposta inventa «25 utenti»), q4 fallisce solo sul recupero. Il dettaglio utile è q4: la risposta è giusta, ma il documento atteso era al quarto posto, fuori da k=3. Il caso non è un errore del generatore, è un retriever che consegna il risultato troppo tardi, e il test lo dice con il nome della metrica. Poi si decide a livello di insieme: il 50% di successo sta sotto la soglia del 75% e il merge viene bloccato. Una soglia per caso sarebbe fragile con un giudice a modello, che a parità di input può dare punteggi leggermente diversi; la decisione sulla quota di casi superati assorbe parte di quel rumore.
Come si lancia. Il funzionamento previsto è un file di test con @pytest.mark.parametrize("c", CASI) sulla funzione test_caso e il comando deepeval test run. In questa sessione non ho eseguito pytest: ho chiamato test_caso in un ciclo, che esercita le stesse funzioni e le stesse metriche ma non la raccolta dei test di pytest. Il codice di deepeval test run che chiama pytest e ne restituisce il codice di uscita è stato letto nel sorgente della libreria, non osservato in esecuzione.
Con un giudice: lo schema
Quando passi dalle metriche in codice a quelle a modello cambia poco nella forma e molto nei rischi. Il listato è uno schema: definisce un giudice minimale ereditando da DeepEvalBaseLLM, che richiede quattro metodi (load_model, generate, a_generate, get_model_name), e lo passa a quattro metriche standard più una GEval con un criterio in linguaggio naturale. Rispetto alla tabella: ContextualPrecisionMetric e ContextualRecallMetric richiedono l'expected_output, quindi un golden dataset con riferimenti; faithfulness no.
# b2.py - schema da adattare (non eseguito): servono un modello giudice e la sua chiave o il suo endpoint
# Firme verificate con inspect.signature su deepeval 4.3.4; nessuna metrica a modello e stata calcolata.
from deepeval import assert_test
from deepeval.metrics import AnswerRelevancyMetric, ContextualPrecisionMetric, ContextualRecallMetric, FaithfulnessMetric, GEval
from deepeval.models import DeepEvalBaseLLM
from deepeval.test_case import LLMTestCase, SingleTurnParams
class MioGiudice(DeepEvalBaseLLM):
"""Rende utilizzabile qualunque modello: i quattro metodi astratti sono questi."""
def load_model(self):
return CLIENT_DEL_TUO_MODELLO
def generate(self, prompt: str) -> str:
return self.load_model().genera(prompt) # sostituisci con la chiamata del tuo client
async def a_generate(self, prompt: str) -> str:
return await self.load_model().genera_async(prompt)
def get_model_name(self) -> str:
return "nome-del-tuo-giudice"
giudice = MioGiudice()
tono = GEval(
name="Risposta prudente",
criteria="La risposta dichiara l'incertezza quando il contesto non contiene l'informazione richiesta.",
evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT, SingleTurnParams.RETRIEVAL_CONTEXT],
model=giudice, threshold=0.7,
)
metriche = [
FaithfulnessMetric(threshold=0.8, model=giudice, include_reason=True),
AnswerRelevancyMetric(threshold=0.7, model=giudice),
ContextualPrecisionMetric(threshold=0.7, model=giudice), # richiede expected_output
ContextualRecallMetric(threshold=0.7, model=giudice), # richiede expected_output
tono,
]
def test_rag(domanda, atteso, rag):
risposta, contesti = rag(domanda)
caso = LLMTestCase(input=domanda, actual_output=risposta, expected_output=atteso, retrieval_context=contesti)
assert_test(caso, metriche) # fallisce, con la ragione di ogni metrica, se una e sotto la sua soglia
Tre scelte da motivare. Le soglie (0,8 per la faithfulness, 0,7 per le altre) sono segnaposto: derivale dalla tua prima esecuzione sul golden dataset, non da una tabella di «soglie consigliate», perché un valore consigliato in astratto non conosce il tuo giudice né il tuo dominio. Il criterio di GEval è scritto per un comportamento che le altre metriche non vedono, la prudenza, ed è la ragione per cui esiste: la documentazione lo descrive come una metrica versatile da usare accanto a quelle specifiche per RAG. L'ultima scelta è l'assert_test sul caso: se una metrica è sotto soglia, l'asserzione fallisce e il messaggio riporta la ragione data dal giudice, che è il motivo per cui include_reason è utile in CI: leggi perché un caso è fallito senza riaprire il modello.
In integrazione continua
Il comando è uno, e il workflow è poco più di una cornice. Lo schema qui sotto è per GitHub Actions; non è stato eseguito (nessun repository, nessun runner), e ne ho solo verificato che il file sia YAML valido. Le versioni delle azioni sono i rilasci più recenti letti dall'API di GitHub il 2026-11-12: controlla le tue.
# b3.sh - il comando che un cancello di CI esegue: restituisce il codice di uscita di pytest
deepeval test run tests/test_rag_gate.py
# Schema da adattare (non eseguito): un workflow GitHub Actions che esegue il cancello a ogni pull request
name: rag-quality-gate
on:
pull_request:
paths: ["rag/**", "tests/test_rag_gate.py", "golden/**"]
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install deepeval pytest
- run: deepeval test run tests/test_rag_gate.py
env:
DEEPEVAL_TELEMETRY_OPT_OUT: "YES"
Il filtro paths è la scelta con più effetto sul costo: il cancello gira solo quando cambiano il codice del RAG, il test o il golden dataset, non a ogni modifica di un file di documentazione. Il timeout-minutes evita che un giudice bloccato consumi minuti all'infinito. Resta un problema che nessun file risolve: ogni esecuzione con un giudice a modello spende token e tempo, quindi tieni due livelli, un sottoinsieme piccolo e deterministico a ogni modifica (le metriche in codice del primo listato) e il golden dataset completo con il giudice a modello a cadenza più rada, per esempio prima di un rilascio. Le soglie del cancello a modello vanno decise con la statistica dell'episodio 1: un margine più stretto dell'intervallo di confidenza del tuo campione produce blocchi casuali.
Per costruire l'insieme di domande che il cancello esegue servono criteri di campionamento e di stratificazione: li trovi nell'articolo sul golden dataset. Per una versione compatta di un cancello di regressione con un corpus di esempio c'è anche RAG Evaluation Pipeline: golden dataset, che costruisce lo stesso tipo di meccanismo da un'altra angolazione.
Che cosa sai fare adesso
Sai scegliere fra Ragas e DeepEval in base al punto del ciclo in cui ti serve il numero: tabella di punteggi per esplorare, test che fallisce per decidere. Sai scrivere una metrica personalizzata con BaseMetric e usarla in assert_test senza chiamare alcun modello, e sai trasformare i risultati dei singoli casi in una decisione sull'insieme. Sai che il giudice predefinito di DeepEval non è neutro rispetto ai dati e che le soglie vanno ricavate dalla tua base di partenza. Il passo successivo riguarda l'insieme di domande su cui tutto questo poggia, nell'episodio 4, e le metriche del solo retriever da mettere nel cancello, nell'episodio 5.
Domande frequenti
Qual è la differenza pratica fra Ragas e DeepEval?
Ragas restituisce punteggi che interpreti tu. DeepEval attacca una soglia a ogni metrica e offre assert_test e deepeval test run, quindi si adatta a un test che passa o fallisce in integrazione continua. Le metriche con lo stesso nome non sono intercambiabili fra le due librerie.
Posso usare un modello mio come giudice in DeepEval?
Sì: la classe DeepEvalBaseLLM richiede quattro metodi (load_model, generate, a_generate, get_model_name) e si passa alla metrica con il parametro model. Se non lo passi, la documentazione indica un modello di un fornitore esterno come predefinito.
Come fisso la soglia del cancello?
Esegui il sistema attuale sul golden dataset, calcola punteggio e intervallo di confidenza, e metti la soglia sotto il limite inferiore dell'intervallo. Una soglia più stretta del rumore del tuo campione blocca modifiche innocue. Non esiste un valore valido per ogni progetto.
Il cancello deve girare a ogni commit?
Le metriche in codice sì, perché costano zero. Il cancello con giudice a modello spende token e tempo a ogni esecuzione: conviene limitarlo con un filtro sui percorsi modificati e farlo girare sull'insieme completo a una cadenza più rada, per esempio prima di un rilascio.
Fonti
- DeepEval, Unit Testing in CI/CD - assert_test, il comando deepeval test run e la struttura di un file di test, consultata il 2026-11-12
- DeepEval, Faithfulness - argomenti richiesti, threshold e include_reason predefiniti, modello predefinito, consultata il 2026-11-12
- DeepEval, Contextual Precision - il requisito di expected_output, consultata il 2026-11-12
- DeepEval, G-Eval - metrica a criteri in linguaggio naturale, consultata il 2026-11-12
- DeepEval 4.3.4 su PyPI - la versione di cui sono state lette le firme e il codice del comando di test, consultata il 2026-11-12
- Ragas, Faithfulness - la controparte in Ragas per il confronto, consultata il 2026-11-12
- pytest, How to parametrize fixtures and test functions - il decoratore parametrize usato dal pattern, consultata il 2026-11-12
- GitHub Docs, Workflow syntax for GitHub Actions - paths, timeout-minutes e struttura dei job, consultata il 2026-11-12
Il primo listato è stato eseguito due volte con DeepEval 4.3.4 su Python 3.14, con uscite identiche, chiamando le funzioni di test in un ciclo e non tramite pytest. Il listato con i modelli giudice, il comando e il workflow non sono stati eseguiti: il secondo richiede un modello, gli altri un repository con un runner.

Commenti
Caricamento commenti...
Accedi per commentare