Todo sistema RAG que vi empeorar lo hizo en silencio. Alguien sube el tamaño de chunk, cambia el modelo de embeddings, reescribe el system prompt o suma doscientos documentos al índice — nada lanza error, la demo sigue respondiendo y tres semanas después soporte avisa que el bot ’se volvió más tonto’. No hay stack trace para ’el retrieval dejó de encontrar la política de reembolsos’.
En este tutorial armo el gate que lo detecta: un golden set de ~50 preguntas revisado por una persona, cuatro métricas de Ragas puntuadas en CI, una separación entre fallas de retrieval y fallas de generación, umbrales que sobreviven al ruido del juez y un job de GitHub Actions que hace fallar el build. Cada llamada a Ragas está fijada en 0.4.3 y verificada contra sus docs y su código — y cada umbral es un placeholder que tenés que medir en tu propio corpus, porque no te voy a pasar números que pertenecen a los datos de otro.
1. El Problema: RAG Falla Sin Dar Error
Un pipeline RAG tiene dos etapas que fallan de maneras distintas. El retrieval puede perder el pasaje que contiene la respuesta, o enterrarlo bajo ruido. La generación puede ignorar el pasaje, adornarlo o responder otra pregunta. El usuario ve un solo síntoma — una mala respuesta — y mirar cinco consultas a ojo después de un cambio no te dice qué etapa se rompió, ni si el ajuste de ayer mejoró o empeoró las cosas.
El Error Más Común
Colapsar todo en un solo número. Un único ’RAG score’ — o un solo ’¿está buena esta respuesta?’ juzgado por LLM — no te dice si tenés que arreglar el índice, el reranker o el prompt. Cuando cae seis puntos tenés una regresión y ninguna dirección. Puntuá las etapas por separado y después poné un gate en cada una.
Un gate de CI es esa idea hecha aburrida: un set fijo de preguntas con respuestas revisadas corre por tu pipeline real en cada cambio relevante, las métricas se calculan por etapa, los resultados se comparan con un baseline que trae un margen de ruido y el job sale con código distinto de cero cuando la calidad cae. No va a hacer bueno a tu sistema. Hace que te enteres cuando deja de ser tan bueno como era.
2. Conceptos Mínimos Antes de Tocar Código
Seis ideas alcanzan. Los nombres de métricas de abajo son los de la API de collections de Ragas 0.4.3 (ragas.metrics.collections). Ojo con la trampa de nombres: la página de docs llama a la última métrica Response Relevancy, la clase de 0.4 es AnswerRelevancy, y la clase vieja que vas a encontrar en tutoriales anteriores es ResponseRelevancy.
Golden set
el contrato fijo
Preguntas con una respuesta de referencia revisada y, idealmente, los documentos fuente que la contienen. Versionado en git y modificado solo mediante un PR revisado — si no, que un score se mueva no te dice nada.
ContextPrecision
retrieval: ¿la evidencia está arriba?
Un LLM juzga cada chunk recuperado como relevante o no contra la referencia y promedia la precisión en cada posición, así que los chunks relevantes cerca del tope puntúan más. Necesita user_input, reference y retrieved_contexts. Bajo significa ruido o mal ranking.
ContextRecall
retrieval: ¿llegó la evidencia?
La respuesta de referencia se divide en afirmaciones y cada una se verifica contra los contextos recuperados. La evidencia faltante lo baja. Bajo significa que la respuesta ni siquiera estaba en el prompt: problema de índice, query o top_k.
Faithfulness
generación: ¿está fundamentada?
La respuesta se divide en afirmaciones y cada una se verifica contra los contextos recuperados: afirmaciones respaldadas sobre total de afirmaciones. Mide fundamento en lo recuperado, no verdad — una respuesta correcta sacada de la memoria del modelo puntúa bajo.
AnswerRelevancy
generación: ¿responde la pregunta?
Un LLM escribe preguntas que la respuesta contestaría (3 por defecto) y los embeddings las comparan con la pregunta real. No verifica hechos, y necesita un modelo de embeddings además del LLM juez.
Ruido del juez
lo que nadie presupuesta
Cada métrica de Ragas de arriba está hecha de llamadas a LLM, y los docs de Ragas dicen que las métricas basadas en LLM pueden variar entre corridas. Los jueces además tienen sesgos documentados (posición, verbosidad, autopreferencia): fijá el modelo y evitá juzgar con el mismo que escribió la respuesta.
El modelo mental a conservar
Las métricas de retrieval preguntan: ¿llegó la evidencia correcta al prompt y en qué orden? Las de generación preguntan: dada esa evidencia, ¿la respuesta se mantuvo en ella y en la pregunta? Leelas como un par. Recall bajo con faithfulness alta es una respuesta amable y fundamentada sobre el material equivocado; recall alto con faithfulness baja significa que el modelo tenía los datos y los ignoró.
3. Paso 1 — Armá un Golden Set de ~50 Preguntas (con Humanos)
Cincuenta preguntas son un detector de humo, no un benchmark: cada una vale 2% de la media, así que el set alcanza para detectar un pipeline roto y es chico para rankear dos buenos. Componelo a propósito: preguntas reales de tus logs, preguntas escritas por alguien que conoce el dominio y algunos borradores sintéticos para ampliar cobertura. La mezcla por defecto del generador de Ragas es 50% single-hop specific y 25% cada una de multi-hop abstract y multi-hop specific; la tuya tiene que sumar lo que los sets generados suelen subrepresentar — redacción real y desprolija, y preguntas que el corpus no puede responder.
JSONL — dos registros golden revisados (contenido inventado, un objeto por línea)
{"id": "q-014", "kind": "multi_hop", "origin": "user_log", "question": "¿Puedo pedir reembolso de un producto que llegó dañado pasados los 30 días?", "reference": "Sí. Los productos dañados al llegar son reembolsables después de 30 días si lo reportás dentro de los 14 días de la entrega y adjuntás una foto.", "expected_doc_ids": ["policies/refunds.md", "policies/shipping-damage.md"], "reviewed_by": "maria", "reviewed_on": "2026-10-12"}
{"id": "q-031", "kind": "unanswerable", "origin": "sme", "question": "¿Igualan el precio de la competencia durante el Black Friday?", "reference": "La documentación no cubre la igualación de precios; el asistente debe decir que no puede responder.", "expected_doc_ids": [], "reviewed_by": "maria", "reviewed_on": "2026-10-12"}Sobregenerá borradores con el TestsetGenerator de Ragas y después revisá cada fila a mano. En los docs actuales el generador sigue tomando un LLM envuelto de LangChain más la clase OpenAIEmbeddings de Ragas, y expone generate_with_langchain_docs(documents, testset_size). La lista de documentos la armo yo, así no hay que instalar una dependencia extra de loaders.
Python — borradores candidatos con TestsetGenerator (ragas 0.4.3)
# evals/make_drafts.py - solo borradores candidatos; nada de esto es "golden" todavía
import os
from pathlib import Path
import openai
from langchain_core.documents import Document
from langchain_openai import ChatOpenAI
from ragas.embeddings import OpenAIEmbeddings
from ragas.llms import LangchainLLMWrapper
from ragas.testset import TestsetGenerator
docs = [
Document(page_content=p.read_text(encoding="utf-8"), metadata={"source": str(p)})
for p in Path("docs").rglob("*.md")
]
generator = TestsetGenerator(
llm=LangchainLLMWrapper(ChatOpenAI(model=os.environ["GEN_MODEL"])),
embedding_model=OpenAIEmbeddings(client=openai.OpenAI()),
)
# sobregenerá: ~80 borradores para quedarte con ~50 que sobrevivan la revisión humana
testset = generator.generate_with_langchain_docs(docs, testset_size=80)
testset.to_pandas().to_csv("evals/drafts.csv", index=False)
print("Ahora abrí evals/drafts.csv y revisá CADA fila a mano.")Después hacé que el set se defienda solo. Un chequeo sin llamadas a LLM corre primero en CI: campos requeridos, ids únicos, kinds conocidos y — el que te salva — expected_doc_ids que todavía existan en el índice. Las filas golden se pudren cuando cambia el corpus; esto hace que la podredumbre sea ruidosa en vez de bajar el recall en silencio.
Python — evals/check_golden.py: integridad antes de gastar tokens
# evals/check_golden.py - sin llamadas a LLM, corre en segundos, falla rápido
import json
import sys
from pathlib import Path
from app.rag import known_doc_ids # TU código: set de doc ids que hoy están en el índice
REQUIRED = {"id", "question", "reference", "kind", "reviewed_by", "reviewed_on"}
KINDS = {"single_hop", "multi_hop", "unanswerable", "messy_query"}
rows = [json.loads(l) for l in Path("evals/golden.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
index, errors, seen = known_doc_ids(), [], set()
for r in rows:
if missing := REQUIRED - r.keys():
errors.append(f"{r.get('id')}: missing {sorted(missing)}")
if r.get("id") in seen:
errors.append(f"{r['id']}: duplicate id")
seen.add(r.get("id"))
if r.get("kind") not in KINDS:
errors.append(f"{r.get('id')}: unknown kind {r.get('kind')!r}")
for doc_id in r.get("expected_doc_ids", []):
if doc_id not in index:
errors.append(f"{r['id']}: stale doc id {doc_id} (el corpus cambió - revisá de nuevo)")
print(f"{len(rows)} golden rows, {len(errors)} problems")
for e in errors:
print("::error title=golden set::" + e)
sys.exit(1 if errors else 0)Tip: la revisión es el trabajo
Por cada borrador preguntate: ¿se puede responder con el corpus?, ¿la referencia es correcta y completa?, ¿nombra la fuente correcta?, ¿un usuario real lo diría así? Eliminá casi-duplicados, reescribí preguntas robóticas, agregá expected_doc_ids más revisor y fecha, y esperá descartar buena parte de los borradores. Un set escrito solo por un LLM hereda el vocabulario del chunk del que salió, lo que tiende a favorecer al retrieval — la pasada humana es lo que convierte borradores en un golden set.
4. Paso 2 — Puntuá Tu Pipeline con Ragas 0.4.3
Fijá la versión: pip install ragas==0.4.3. A 2026-10-14 es el release más nuevo en PyPI (publicado el 2026-01-13) y el tag más nuevo en GitHub. La línea 0.4 movió las métricas a ragas.metrics.collections: construís una métrica con un LLM creado con llm_factory, llamás await metric.ascore(...) con argumentos por nombre y leés .value del MetricResult devuelto. La guía de migración a v0.4 lista evaluate() como deprecado y las clases viejas de ragas.metrics quedan marcadas para eliminarse en 1.0, así que los tutoriales más viejos no van a coincidir. Tener un loop propio además te da lo que un gate de CI necesita: control de concurrencia, NaNs y exit codes. La evaluación necesita exactamente una cosa de tu sistema:
Python — app/rag.py: el contrato (los nombres son placeholders de tu código)
# app/rag.py - lo único que la evaluación necesita de tu sistema
def answer_question(question: str) -> dict:
"""Tiene que correr el MISMO camino que producción: retrieve -> rerank -> armar prompt -> generar."""
chunks = retrieve_and_rerank(question) # tu código
prompt_chunks = fit_to_context_window(chunks) # lo que realmente termina en el prompt
answer = generate(question, prompt_chunks) # tu código
return {
"answer": answer.text,
"contexts": [c.text for c in prompt_chunks], # EXACTAMENTE lo que vio el modelo, en orden de prompt
"doc_ids": [c.doc_id for c in prompt_chunks],
"refused": answer.refused, # ¿dijo "no puedo responder esto"?
}La línea que importa es contexts: pasá los chunks que realmente entraron al prompt, después del reranking y el truncado, no el top-k crudo que trajiste. Puntuar chunks que el modelo nunca vio te da números de precisión sobre un pipeline que no corrés. Este es el harness, con los nombres de argumentos verificados contra los docs y el código de 0.4.3:
Python — evals/run_ragas.py: una corrida del pipeline, cuatro métricas por pregunta
# evals/run_ragas.py - argumentos verificados contra docs y código de ragas==0.4.3
import asyncio
import json
import math
import os
from pathlib import Path
from openai import AsyncOpenAI
from ragas.cache import DiskCacheBackend
from ragas.embeddings.base import embedding_factory
from ragas.llms import llm_factory
from ragas.metrics.collections import (
AnswerRelevancy,
ContextPrecision,
ContextRecall,
Faithfulness,
)
from app.rag import answer_question # TU pipeline -> {"answer", "contexts", "doc_ids", "refused"}
JUDGE_MODEL = os.environ["RAGAS_JUDGE_MODEL"] # un snapshot fechado y fijo - nunca un alias flotante
EMBED_MODEL = os.environ.get("RAGAS_EMBED_MODEL", "text-embedding-3-small")
CONCURRENCY = int(os.environ.get("RAGAS_CONCURRENCY", "6"))
# métrica -> cómo se llama su ascore() (argumentos verificados en el código 0.4.3)
CALLS = {
"faithfulness": lambda m, q, a, c, ref: m.ascore(user_input=q, response=a, retrieved_contexts=c),
"context_precision": lambda m, q, a, c, ref: m.ascore(user_input=q, reference=ref, retrieved_contexts=c),
"context_recall": lambda m, q, a, c, ref: m.ascore(user_input=q, retrieved_contexts=c, reference=ref),
"answer_relevancy": lambda m, q, a, c, ref: m.ascore(user_input=q, response=a),
}
def build_metrics():
client = AsyncOpenAI(max_retries=4) # se crea en cada corrida: un cliente por event loop
cache = DiskCacheBackend(cache_dir=".ragas_cache") if os.environ.get("RAGAS_CACHE") == "1" else None
llm = llm_factory(JUDGE_MODEL, client=client, cache=cache, temperature=0)
emb = embedding_factory("openai", model=EMBED_MODEL, client=client, cache=cache)
return {
"faithfulness": Faithfulness(llm=llm),
"context_precision": ContextPrecision(llm=llm),
"context_recall": ContextRecall(llm=llm),
"answer_relevancy": AnswerRelevancy(llm=llm, embeddings=emb),
}
def clean(value):
"""NaN significa 'el juez no produjo nada' - guardalo como None, nunca como 0."""
v = float(value)
return None if math.isnan(v) else v
def doc_recall(expected, got):
"""Determinístico, sin LLM: ¿qué docs fuente esperados llegaron al prompt?"""
return len(set(expected) & set(got)) / len(set(expected)) if expected else None
async def score_row(row, metrics, sem):
async with sem:
out = await asyncio.to_thread(answer_question, row["question"])
rec = {"id": row["id"], "kind": row["kind"], "answer": out["answer"],
"n_ctx": len(out["contexts"]),
"doc_recall_at_k": doc_recall(row.get("expected_doc_ids", []), out["doc_ids"])}
if row["kind"] == "unanswerable": # lo correcto es negarse: no es pregunta para Ragas
rec["refusal_ok"] = 1.0 if out.get("refused") else 0.0
return rec
q, ref, ans, ctxs = row["question"], row["reference"], out["answer"], out["contexts"]
if not ctxs: # el retrieval no trajo nada: ESA es la falla (y Ragas lanzaría ValueError)
return {**rec, "context_recall": 0.0, "context_precision": 0.0,
"faithfulness": None, "answer_relevancy": None}
names = list(CALLS)
results = await asyncio.gather(
*(CALLS[n](metrics[n], q, ans, ctxs, ref) for n in names), return_exceptions=True
)
errors = []
for name, res in zip(names, results):
if isinstance(res, Exception):
rec[name] = None
errors.append(f"{name}: {type(res).__name__}")
else:
rec[name] = clean(res.value)
rec["errors"] = errors
return rec
async def run_once(golden_path="evals/golden.jsonl"):
lines = Path(golden_path).read_text(encoding="utf-8").splitlines()
rows = [json.loads(l) for l in lines if l.strip()]
metrics, sem = build_metrics(), asyncio.Semaphore(CONCURRENCY)
scored = await asyncio.gather(*(score_row(r, metrics, sem) for r in rows))
return {"judge_model": JUDGE_MODEL, "n": len(scored), "rows": scored}Qué cuesta una corrida, y por qué el caché es solo para desarrollo
Según el código de 0.4.3: Faithfulness hace dos llamadas al juez por pregunta (extraer afirmaciones y luego veredictos), ContextPrecision una llamada por chunk recuperado (en secuencia, así que la latencia crece con top_k), ContextRecall una, y AnswerRelevancy una llamada por paso de strictness (3 por defecto) más dos llamadas de embeddings. Con top_k = 5 son 2 + 5 + 1 + 3 = 11 llamadas al juez por pregunta respondible, unas 550 para una corrida de ~50 preguntas, más 50 generaciones propias. Es aritmética a partir del código, no una medición: registrá tokens reales en una corrida y multiplicá por el precio de tu juez. llm_factory acepta un cache (DiskCacheBackend de ragas.cache), que vuelve casi gratis las re-corridas sin cambios en un loop local pero también congela las respuestas del juez: dejalo apagado en el baseline y en el gate, donde medís ruido a propósito.
5. Paso 3 — Separá Fallas de Retrieval de Fallas de Generación y Fijá Umbrales
Un promedio que cae no es un diagnóstico. El clasificador de abajo asigna cada pregunta a una etapa y culpa primero al retrieval: si la evidencia necesaria nunca llegó al prompt, una faithfulness baja es un síntoma, no la causa. Combinado con un chequeo determinístico de doc-recall (sin LLM: ¿los documentos fuente esperados aparecieron entre los doc ids recuperados?), te dice si abrir la config del índice o el prompt.
Python — evals/faults.py: una clase de falla por pregunta
# evals/faults.py - qué ETAPA se rompió, por pregunta
from collections import Counter, defaultdict
ROW_BAD = 0.5 # corte por pregunta para "esta falló": un placeholder, ajustalo con fallas revisadas
def low(value, cutoff=ROW_BAD):
return value is not None and value < cutoff
def classify(r):
"""El orden importa: culpá primero al retrieval. Si la evidencia nunca llegó al prompt,
una faithfulness baja es un síntoma, no la causa."""
if r["kind"] == "unanswerable":
return "ok" if r.get("refusal_ok") == 1.0 else "should_have_refused"
if r["n_ctx"] == 0 or r.get("doc_recall_at_k") == 0.0 or low(r.get("context_recall")):
return "retrieval_miss" # la evidencia necesaria no está en el prompt -> índice, query, top_k, embeddings
if low(r.get("context_precision")):
return "retrieval_noise" # la evidencia está pero enterrada -> reranker, tamaño de chunk, top_k
if low(r.get("faithfulness")):
return "generation_unfaithful" # la evidencia está, la respuesta se desvió -> prompt, modelo, temperatura
if low(r.get("answer_relevancy")):
return "generation_off_topic" # fundamentada pero no responde la pregunta -> prompt/formato
return "ok"
def fault_report(rows):
by_class, ids = Counter(), defaultdict(list)
for r in rows:
c = classify(r)
by_class[c] += 1
if c != "ok":
ids[c].append(r["id"])
return by_class, idsAhora los umbrales. Dos reglas mantienen honesto a un gate de 50 preguntas. Primero, decidí por movimiento respecto de un baseline, no por un número absoluto que te inventaste: el baseline guarda la media de cada métrica y su ruido entre corridas (el desvío estándar de varias corridas completas), y la línea de falla queda K unidades de ruido por debajo, nunca más cerca que un margen mínimo. Segundo, sumá un piso duro recién cuando hayas visto números reales — es una decisión de producto, así que la config de abajo viene sin ninguno. Como los resultados borderline son justo donde muerde el ruido del juez, un WARN o FAIL dispara dos corridas más y el gate decide con la mediana de tres, nunca con la mejor.
Python — evals/gate.py: pass / warn / fail con banda de ruido
# evals/gate.py - exit 0 = pass/warn, 1 = regresión de calidad, 2 = harness roto
import argparse
import asyncio
import json
import math
import os
import statistics
import sys
from pathlib import Path
from evals.faults import fault_report
from evals.run_ragas import run_once
RAGAS = ["context_recall", "context_precision", "faithfulness", "answer_relevancy"]
METRICS = RAGAS + ["refusal_rate"]
HARD_FLOOR = {m: None for m in METRICS} # decisión de producto: completala DESPUÉS de tu primer baseline
MIN_MARGIN = 0.03 # la caída mínima que llamamos regresión (punto de partida, no ley)
K_NOISE = 3 # falla solo si estamos K unidades de ruido bajo el baseline
MAX_MISSING = 0.10 # más de 10% de fallas del juez = harness roto, no problema de calidad
def mean_and_missing(rows, key):
vals = [r[key] for r in rows if r.get(key) is not None]
mean = statistics.fmean(vals) if vals else math.nan
return mean, (1 - len(vals) / len(rows)) if rows else 1.0
def summarize(run):
answerable = [r for r in run["rows"] if r["kind"] != "unanswerable"]
refusals = [r for r in run["rows"] if r["kind"] == "unanswerable"]
out = {m: mean_and_missing(answerable, m) for m in RAGAS}
if refusals:
out["refusal_rate"] = mean_and_missing(refusals, "refusal_ok")
return out
def decide(metric, value, base):
margin = max(K_NOISE * base["noise"], MIN_MARGIN)
fail_below = max(HARD_FLOOR[metric] or 0.0, base["mean"] - margin)
warn_below = base["mean"] - max(base["noise"], MIN_MARGIN / 2)
if not value >= fail_below: # escrito así para que NaN FALLE en vez de colarse
return "FAIL", fail_below
if value < warn_below:
return "WARN", warn_below
return "PASS", warn_below
def judge(runs, baseline):
per_run = [summarize(r) for r in runs]
verdicts = {}
for m in per_run[0]:
if any(s[m][1] > MAX_MISSING for s in per_run):
return None # demasiadas fallas del juez: el harness está roto, decilo
value = statistics.median(s[m][0] for s in per_run) # mediana de 1 o 3 corridas
verdicts[m] = (value, *decide(m, value, baseline["metrics"][m]))
return verdicts
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--golden", default="evals/golden.jsonl")
ap.add_argument("--baseline", default="evals/baseline.json")
ap.add_argument("--out", default="results")
args = ap.parse_args()
baseline = json.loads(Path(args.baseline).read_text(encoding="utf-8"))
if baseline["judge_model"] != os.environ["RAGAS_JUDGE_MODEL"]:
print("::error title=judge changed::el modelo juez difiere del baseline; rehacé el baseline primero")
sys.exit(2)
runs = [asyncio.run(run_once(args.golden))]
verdicts = judge(runs, baseline)
if verdicts and any(v[1] != "PASS" for v in verdicts.values()):
runs += [asyncio.run(run_once(args.golden)) for _ in range(2)] # escalar: decidir con la mediana de 3
verdicts = judge(runs, baseline)
Path(args.out).mkdir(exist_ok=True)
Path(args.out, "runs.json").write_text(json.dumps(runs, indent=2), encoding="utf-8")
if verdicts is None:
print("::error title=RAG eval harness::demasiadas fallas del juez (ver results/runs.json)")
sys.exit(2)
by_class, ids = fault_report(runs[-1]["rows"])
lines = ["| métrica | valor | veredicto | umbral |", "|---|---|---|---|"]
lines += [f"| {m} | {v:.3f} | {d} | {t:.3f} |" for m, (v, d, t) in verdicts.items()]
lines += ["", f"clases de falla (última corrida): {dict(by_class)}"]
lines += [f"- {c}: {', '.join(i)}" for c, i in ids.items()]
summary = "\n".join(lines)
print(summary)
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write(summary + "\n")
failed = [m for m, (_, d, _) in verdicts.items() if d == "FAIL"]
if failed:
print(f"::error title=RAG eval gate::regresión en {', '.join(failed)}")
sys.exit(1)
if __name__ == "__main__":
main()Tres maneras en que este gate miente si lo dejás
NaN: Faithfulness y ContextRecall devuelven NaN cuando el juez no produce nada, y nan < umbral es False — un ’if media < piso: fallar’ ingenuo pasa en silencio. El código está escrito como not value >= fail_below y cuenta los scores faltantes aparte. Preguntas sin respuesta: una referencia como ’los docs no cubren esto’ no es atribuible a ningún contexto, así que el recall se leería como falla de retrieval; esas filas se evalúan por comportamiento de rechazo. Deriva del juez: un snapshot nuevo del juez mueve todos los scores a la vez, así que el gate se niega a correr contra un baseline grabado con otro juez.
6. Paso 4 — Hacé Fallar el Build en GitHub Actions
El workflow queda chico porque la lógica vive en Python. Corre cuando cambia el código de retrieval, los prompts o la propia evaluación, y de noche sobre la rama por defecto. El gate en Python escribe una tabla Markdown en el resumen del job (GITHUB_STEP_SUMMARY), emite anotaciones ::error y los resultados crudos por pregunta se suben como artifact, así un PR que falla se puede depurar sin pagar otra corrida.
YAML — .github/workflows/rag-eval.yml
name: rag-eval-gate
on:
pull_request:
paths: # solo cuando cambia algo que puede mover la calidad
- "app/rag/**"
- "prompts/**"
- "evals/**"
- "requirements*.txt"
workflow_dispatch: # un maintainer puede correrlo a mano sobre un PR de fork
schedule:
- cron: "17 3 * * *" # nocturno sobre la rama por defecto
permissions:
contents: read
concurrency:
group: rag-eval-${{ github.ref }}
cancel-in-progress: true
jobs:
rag-eval:
runs-on: ubuntu-latest
timeout-minutes: 25
# los PRs de forks no reciben secrets: omitilos en vez de ponerlos en rojo
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RAGAS_JUDGE_MODEL: ${{ vars.RAGAS_JUDGE_MODEL }} # un snapshot fechado, verificado contra el baseline
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt "ragas==0.4.3"
- name: Integridad del golden set (sin llamadas a LLM)
run: python -m evals.check_golden
- name: RAG eval gate
run: python -m evals.gate
- uses: actions/upload-artifact@v7
if: always()
with:
name: rag-eval-results
path: results/Los PRs de forks no reciben secrets: omitilos, no los hagas fallar
Los docs de GitHub dicen que los secrets no se pasan al runner cuando un workflow se dispara desde un repositorio fork, y que GITHUB_TOKEN es de solo lectura ahí. El guard if: omite los PRs de forks en vez de ponerlos en rojo. No lo ’arregles’ con pull_request_target: corre en el contexto del repositorio base y, según los mismos docs, hay que evitarlo cuando necesitás compilar o ejecutar código del pull request. Dejá que un maintainer dispare workflow_dispatch. Cuando lo revisé, los majors vigentes eran v7 para checkout, setup-python y upload-artifact; fijá el major que verificaste o un commit SHA.
Registrá el baseline a propósito, nunca dentro del job del PR: cinco corridas completas con el caché apagado, desde un main limpio, commiteadas mediante un PR revisado. El desvío estándar entre esas corridas es el ruido que tu gate tiene que tolerar — incluye varianza del juez y del generador, porque todo el pipeline se re-ejecuta cada vez. El modelo juez viene de una variable del repositorio y se compara con el guardado en el baseline, así no puede cambiar sin que lo notes.
Python — evals/baseline.py: medí el ruido una vez y commitealo
# evals/baseline.py - correlo a propósito, caché APAGADO; commiteá el resultado en un PR revisado
import asyncio
import json
import os
import statistics
from pathlib import Path
from evals.gate import summarize
from evals.run_ragas import JUDGE_MODEL, run_once
REPEATS = 5
assert os.environ.get("RAGAS_CACHE") != "1", "el caché esconde el ruido del juez: apagalo para baselines"
runs = [asyncio.run(run_once("evals/golden.jsonl")) for _ in range(REPEATS)]
per_run = [summarize(r) for r in runs]
metrics = {}
for m in per_run[0]:
means = [s[m][0] for s in per_run] # una media por corrida completa del pipeline
metrics[m] = {"mean": statistics.fmean(means), "noise": statistics.stdev(means)}
Path("evals/baseline.json").write_text(
json.dumps({"judge_model": JUDGE_MODEL, "repeats": REPEATS, "metrics": metrics}, indent=2),
encoding="utf-8",
)
print("baseline escrito - revisalo y abrí un PR")¿Preferís pytest? DeepEval es la alternativa
Si tu equipo ya vive en pytest, DeepEval cubre el mismo terreno. Sus métricas de RAG son AnswerRelevancyMetric, FaithfulnessMetric, ContextualPrecisionMetric y ContextualRecallMetric; armás objetos LLMTestCase con input, actual_output y retrieval_context (más expected_output para las dos métricas contextual), llamás assert_test() y corrés deepeval test run. La estructura de este post — golden set revisado, separación por etapa, umbrales con ruido — se traslada sin cambios, y la guía de evals de agentes enlazada al final recorre DeepEval en profundidad.
7. Una Cadencia Que Mantiene Confiable al Gate
Un gate se degrada si nadie lo alimenta. Esta es la rutina que evita que el mío termine siendo un check silenciado:
🔁 En cada pull request (minutos)
- 1. Primero el chequeo de integridad: ids únicos, campos requeridos, expected_doc_ids todavía en el índice — no gasta tokens y atrapa filas podridas antes que el juez
- 2. Una corrida completa; ante cualquier WARN o FAIL, dos más y decidí con la mediana de tres — nunca con la mejor corrida
- 3. Leé las clases de falla antes que los promedios: retrieval_miss y generation_unfaithful piden arreglos distintos
🧪 Nocturno (corrida completa, tokens gastados a propósito)
- Caché apagado: las corridas nocturnas sobre main vuelven a medir el ruido real del juez y del generador; si la dispersión crece, el valor de ruido de tu baseline quedó viejo
- Vigilá la deriva: una pendiente lenta entre corridas nocturnas sin ningún PR al que culpar suele ser deriva del corpus o del modelo upstream, no un cambio de código
- Porción reservada: dejá unas diez preguntas fuera del gate de PR y puntualas solo de noche, para que ajustar al gate no lo sobreajuste en silencio
🧹 En cada cambio de corpus, juez o modelo
- Rehacé el baseline: un snapshot nuevo del juez, un modelo de embeddings nuevo o una actualización grande de docs significa cinco corridas nuevas y un PR de baseline revisado — no un retoque de umbral
- Leé filas juzgadas a mano: muestreá diez por métrica; si no coincidís con el juez, arreglá la referencia o el prompt de la métrica, no el umbral. Ragas tiene una guía para alinear un juez LLM con etiquetas humanas, y sugiere idealmente 100–200 ejemplos etiquetados por expertos — más de lo que tiene un gate de 50 filas, así que etiquetá primero las fallas
- Crecé desde incidentes: cada bug de producción que reporta un usuario se vuelve una fila golden, revisada y fechada, antes de que el fix se mergee
8. Errores Comunes (Todos Evitables)
Casi todo gate de RAG roto que debuggueé cae en uno de estos baldes. La columna izquierda es lo que hace que la gente siga confiando en un gate; la derecha es lo que lo termina silenciado.
✅ Hacé esto
- • Fijá ragas==0.4.3 y un snapshot fechado del juez; rehacé el baseline cuando cambie cualquiera
- • Pasá como retrieved_contexts los chunks exactos que entraron al prompt
- • Tratá NaN y excepciones del juez como datos faltantes, contalos y hacé fallar el harness sobre una tolerancia
- • Decidí por movimiento desde un baseline con margen de ruido; sumá pisos duros desde números observados
- • Que una persona revise cada fila golden y guardá revisor, fecha y doc ids fuente
- • Juzgá con una familia de modelos distinta a la del generador cuando puedas
❌ No esto
- • Un gate sobre un ’RAG score’ mezclado que no separa bugs de índice de bugs de prompt
- • Dejar que un LLM escriba todo el golden set y nunca leerlo
- • Re-correr el job hasta que se ponga verde, o decidir con la mejor de N corridas
- • Usar pull_request_target para que los PRs de forks lean tus secrets
- • Copiar umbrales de un blog — este incluido — en vez de medir tu corpus
- • Puntuar con context recall las preguntas de ’no sé’
Regla de oro
Un gate en el que no confiás termina silenciado, y un gate silenciado es peor que ninguno. Mantenelo lo bastante barato para correr en cada cambio relevante, estricto con los errores del harness, honesto con el ruido, y que una persona sea dueña del golden set.
Conclusión
Ya tenés el loop completo: un golden set revisado por humanos, métricas de collections de Ragas 0.4.3 puntuadas por pregunta, un clasificador que culpa al retrieval antes que a la generación, un gate relativo al baseline con banda de ruido y un job de GitHub Actions que hace fallar el build. Ningún número de este post es un resultado — son espacios para los tuyos.
Empezá chico esta semana: tomá 20 preguntas reales de tus logs, escribí las referencias a mano, corré el harness local con el caché prendido y leé las clases de falla antes de mirar cualquier promedio. La primera salida útil rara vez es un score; es la lista de preguntas que tu pipeline obviamente falla — y esa lista es tu backlog.
Machete del Tutorial
Ragas 0.4.3
- • llm_factory + ascore(...).value
- • ContextPrecision / ContextRecall
- • Faithfulness / AnswerRelevancy
Diseño del gate
- • Baseline + banda de ruido
- • Mediana de 3 en borderline
- • NaN falla, nunca pasa
Golden set
- • ~50 filas revisadas por humanos
- • Reales + desprolijas + sin respuesta
- • Versionado, con doc ids
Fuentes
- Docs de Ragas — Métricas disponibles (nombres de la API collections)
- Docs de Ragas — Faithfulness (fórmula, API collections)
- Docs de Ragas — Context Precision
- Docs de Ragas — Context Recall
- Docs de Ragas — Response / Answer Relevancy (requiere embeddings)
- Docs de Ragas — Migración de v0.3 a v0.4 (API collections, evaluate() deprecado)
- Docs de Ragas — Generar un test set de RAG (TestsetGenerator)
- Docs de Ragas — Alinear un juez LLM con etiquetas humanas
- PyPI — ragas (historial de releases, 0.4.3)
- Ragas en GitHub — código y tags (v0.4.3)
- Zheng et al. — Judging LLM-as-a-Judge with MT-Bench (sesgos de posición, verbosidad y autopreferencia)
- Docs de GitHub — Eventos que disparan workflows (PRs de forks, secrets, pull_request_target)
- Docs de GitHub — Workflow commands (resumen del job, anotaciones de error)
- DeepEval — Evaluación de RAG en CI (assert_test, deepeval test run)



