Desarrollo IATutorial

Evaluación de RAG en CI: Que Falle el Build Antes de Que Tus Usuarios Encuentren la Regresión

14 de octubre de 2026
14 min de lectura
Gate de calidad para RAG con métricas de retrieval y generación en un pipeline de CI
Compartir:

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, ids

Ahora 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. 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. 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. 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)

  1. 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
  2. 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
  3. 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

  1. 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
  2. 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
  3. 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

Diego Rodriguez

Diego Rodriguez

Ingeniero Senior Full-Stack & AI

Diego tiene mas de 9 anos de experiencia construyendo aplicaciones potenciadas por IA de produccion, desde orquestacion de LLMs y pipelines RAG hasta deteccion de riesgos con ML y sistemas de trading algoritmico.

Conoce mas sobre Diego →