Módulo 8: RAG Evaluation + Proyecto Integrador

Regression Testing y Thresholds de Calidad

Descripción de la cápsula

Tener un pipeline de evaluación que produce métricas no es suficiente. Las métricas son información — necesitas convertirlas en decisiones automáticas. Esa conversión sucede a través de dos mecanismos: thresholds absolutos ("faithfulness debe estar arriba de 0.85") y regression testing ("faithfulness no puede bajar más de 2% respecto al último release").

Sin estos mecanismos, alguien tiene que mirar los reportes después de cada PR y decidir manualmente si la calidad bajó. Eso no escala, falla en sutileza, y eventualmente alguien acepta una degradación porque "se ve casi igual". Con thresholds y regression tests, el sistema bloquea automáticamente cualquier cambio que cruce líneas predefinidas — y la conversación se mueve de "¿bajó la calidad?" (subjetiva) a "¿queremos mover el threshold?" (decisión explícita y documentada).

En esta cápsula vas a calibrar thresholds defendibles para tu caso, implementar regression testing contra baseline versionado, manejar el caso "tolerance" (cuánto ruido es aceptable), y diseñar mensajes de error tan claros que un developer puede actuar sin abrir el código del pipeline.

Al terminar tendrás el sistema de quality gates listo para integrar en CI/CD (cápsula 07), donde cada PR pasa o falla automáticamente con feedback inmediato.


Dos mecanismos complementarios: absolute vs relative

Hay dos tipos de checks que tu sistema debe correr:

MecanismoPreguntaCuándo dispara
Threshold absoluto¿Está arriba del piso mínimo aceptable?Sistema cruzó línea de calidad inaceptable
Regression check¿Bajó significativamente vs último release?Cambio actual degradó algo que funcionaba

Necesitas ambos:

  • Solo absolutos: aceptas degradaciones graduales (de 0.95 a 0.86 sin alertar porque ambos > 0.85)
  • Solo regression: aceptas baseline malo (si baseline es 0.50 y mantienes 0.49, "no hay regresión" pero el sistema es malo)
def quality_gate(current: dict, baseline: dict, thresholds: dict) -> dict:
    absolute_failures = check_absolute_thresholds(current, thresholds)
    regression_failures = check_no_regression(current, baseline)
    return {
        "passed": not (absolute_failures or regression_failures),
        "absolute_failures": absolute_failures,
        "regression_failures": regression_failures,
    }

Calibración de thresholds absolutos

Thresholds inventados son inútiles. Hay tres formas válidas de elegir thresholds:

Método 1: Baseline empírico

Corre la evaluación 10 veces sobre el sistema actual estable. Calcula media y desviación estándar. Threshold = media - 2 desviaciones estándar.

import statistics

def empirical_thresholds(historical_runs: list[dict], confidence: float = 0.95) -> dict:
    """historical_runs: lista de scores de runs estables consecutivos"""
    z_score = 1.96 if confidence == 0.95 else 2.58  # 99% confidence
    thresholds = {}
    for metric in ["faithfulness", "answer_relevancy", "context_precision", "context_recall"]:
        values = [r[metric] for r in historical_runs]
        mean = statistics.mean(values)
        stdev = statistics.stdev(values) if len(values) > 1 else 0
        thresholds[metric] = round(mean - z_score * stdev, 3)
    return thresholds

Ventaja: thresholds reflejan la variabilidad real de tu sistema, no opiniones.

Método 2: Business requirement

"Para soporte técnico, faithfulness debajo de 0.85 significa que > 15% de las respuestas alucinan, lo cual genera tickets falsos." Define threshold por consecuencia de negocio.

Método 3: Comparación con humano

Toma 50 queries, haz que un humano produzca respuestas. Mide RAGAS sobre las respuestas humanas. Tu threshold es algún porcentaje del score humano (típicamente 90-95%).

Anti-método: copiar thresholds de un blog post. Cada sistema tiene baseline distinto. Un threshold de 0.90 en faithfulness puede ser trivial para un sistema sobre Wikipedia y agresivo para soporte técnico con docs incompletos.


Implementación: thresholds y comparación

# eval/thresholds.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Threshold:
    metric: str
    minimum: float
    severity: str  # "blocking", "warning"

DEFAULT_THRESHOLDS = [
    Threshold("faithfulness", 0.85, "blocking"),
    Threshold("answer_relevancy", 0.80, "blocking"),
    Threshold("context_precision", 0.75, "warning"),
    Threshold("context_recall", 0.80, "blocking"),
]

def check_absolute_thresholds(scores: dict, thresholds: list[Threshold]) -> list[dict]:
    failures = []
    for t in thresholds:
        category, metric_name = ("ragas", t.metric) if t.metric in scores.get("ragas", {}) else ("retrieval", t.metric)
        actual = scores.get(category, {}).get(metric_name)
        if actual is None:
            continue
        if actual < t.minimum:
            failures.append({
                "metric": t.metric,
                "actual": actual,
                "minimum": t.minimum,
                "severity": t.severity,
                "delta": actual - t.minimum,
            })
    return failures

def check_no_regression(current: dict, baseline: dict, tolerance: float = 0.02) -> list[dict]:
    failures = []
    for category in ["ragas", "retrieval"]:
        baseline_cat = baseline.get(category, {})
        current_cat = current.get(category, {})
        for metric, base_value in baseline_cat.items():
            curr_value = current_cat.get(metric, 0)
            if curr_value < base_value - tolerance:
                failures.append({
                    "metric": metric,
                    "category": category,
                    "baseline": base_value,
                    "current": curr_value,
                    "delta": curr_value - base_value,
                    "tolerance": tolerance,
                })
    return failures

Punto clave de diseño: severity distingue "blocking" (bloquea PR) de "warning" (deja pasar pero alerta). Context precision tiende a ser más ruidoso, así que tratarlo como warning evita falsos positivos sin perder visibilidad.


Manejo de tolerance: cuánto ruido es ruido

LLM-as-judge tiene variabilidad inherente. Aún con temperature=0, scores pueden variar ±0.5-1.5% entre runs idénticos. Si tu tolerance es 0.5%, vas a tener falsos positivos constantes.

Cómo elegir tolerance:

def measure_judge_noise(rag_app, golden_records, n_runs: int = 5) -> dict:
    """Corre evaluación N veces sobre mismo dataset para medir varianza"""
    runs = []
    for _ in range(n_runs):
        samples = await execute_batch(golden_records, rag_app)
        scores = compute_ragas_metrics(samples)
        runs.append(scores)
    noise = {}
    for metric in runs[0].keys():
        values = [r[metric] for r in runs]
        noise[metric] = {
            "stdev": statistics.stdev(values),
            "range": max(values) - min(values),
        }
    return noise

Si stdev es 0.015, tu tolerance debe ser al menos 2× stdev = 0.03 para evitar falsos positivos. Para sistemas en producción típicamente:

MétricaStdev típicaTolerance recomendado
Faithfulness~0.010.02
Answer Relevancy~0.0150.03
Context Precision~0.0250.04
Context Recall~0.0120.02

Baseline versionado y rotación

El baseline es el "estado actual aceptable". Vive versionado en git como eval/baseline.json:

{
  "version": "v1.4.0",
  "commit_sha": "a3f29c1",
  "captured_at": "2026-04-15",
  "dataset_version": "v1.2.0",
  "ragas": {
    "faithfulness": 0.91,
    "answer_relevancy": 0.87,
    "context_precision": 0.82,
    "context_recall": 0.85
  },
  "retrieval": {
    "precision_at_5": 0.78,
    "recall_at_5": 0.81,
    "mrr": 0.74
  }
}

Cuándo actualizar el baseline:

  1. Después de mejora intencional que mejora métricas → nuevo baseline = release nuevo. Documentar en git: "baseline updated post-rerank improvement, faithfulness 0.87 → 0.91"
  2. Después de cambio de dataset → no se puede comparar contra baseline viejo. Crea nueva línea base.
  3. NUNCA "para que el test pase". Si un cambio degrada y actualizas baseline, perdiste el sistema.
# scripts/update_baseline.py
def update_baseline(latest_report_path: Path, baseline_path: Path, justification: str):
    latest = json.loads(latest_report_path.read_text())
    new_baseline = {
        "version": latest["version"],
        "commit_sha": latest["commit_sha"],
        "captured_at": datetime.now().isoformat(),
        "dataset_version": latest["dataset_version"],
        "justification": justification,
        "ragas": latest["ragas"],
        "retrieval": latest["retrieval"],
    }
    baseline_path.write_text(json.dumps(new_baseline, indent=2))
    print(f"Baseline updated. Justification: {justification}")

El campo justification es obligatorio y debe estar en el commit que actualiza el baseline. Sin esto, se vuelven misteriosos updates que nadie puede defender después.


Mensajes de error accionables

Un test que falla con "AssertionError: Quality gate failed" es inútil. El developer no sabe qué hacer. Compara:

Mal mensaje:

AssertionError: Quality gate failed

Mensaje accionable:

❌ Quality gate FAILED

Blocking failures (must fix to merge):
  • faithfulness: 0.78 (minimum 0.85, deficit -0.07)
    → Likely cause: prompt change accepted ungrounded inferences
    → Action: review changes to app/pipeline.py prompt template
  
Regression vs baseline v1.4.0:
  • context_precision: 0.71 (baseline 0.82, delta -0.11, tolerance ±0.04)
    → Likely cause: re-ranker change degraded ranking quality
    → Action: compare reranker config in eval_reports/

Warnings (non-blocking):
  • answer_relevancy: 0.79 (minimum 0.80, deficit -0.01)
def format_failure_report(absolute: list[dict], regression: list[dict]) -> str:
    if not absolute and not regression:
        return "✅ Quality gate PASSED"

    lines = ["❌ Quality gate FAILED", ""]

    blocking = [f for f in absolute if f["severity"] == "blocking"]
    warnings = [f for f in absolute if f["severity"] == "warning"]

    if blocking:
        lines.append("Blocking failures (must fix to merge):")
        for f in blocking:
            lines.append(f"  • {f['metric']}: {f['actual']:.3f} (minimum {f['minimum']:.3f}, deficit {f['delta']:.3f})")
        lines.append("")

    if regression:
        lines.append("Regression vs baseline:")
        for r in regression:
            lines.append(f"  • {r['metric']}: {r['current']:.3f} (baseline {r['baseline']:.3f}, delta {r['delta']:.3f}, tolerance ±{r['tolerance']:.3f})")
        lines.append("")

    if warnings:
        lines.append("Warnings (non-blocking):")
        for w in warnings:
            lines.append(f"  • {w['metric']}: {w['actual']:.3f} (minimum {w['minimum']:.3f}, deficit {w['delta']:.3f})")

    return "\n".join(lines)

Comparación: evaluar sin gates vs con gates

CriterioSin quality gatesCon quality gates
Riesgo de regresión silenciosaAltoBajo
Velocidad de detecciónDías/semanas (cuando alguien nota)Inmediata (en PR)
Confianza para refactor agresivoBaja: temor a romperAlta: red de seguridad
Onboarding de nuevos devsRiesgoso: pueden romper sin saberSeguro: el sistema avisa
Discusiones sobre calidadSubjetivasObjetivas con números
Mantenimiento del sistemaReactivoProactivo

Conexión con el proyecto final

Tu Advanced RAG System debe entregar:

eval/
├── thresholds.py              # DEFAULT_THRESHOLDS
├── baseline.json              # versionado en git
├── BASELINE_HISTORY.md        # log de actualizaciones con justificación
└── tests/
    └── test_quality_gates.py  # pytest que falla si no pasa quality gate

El test integrado:

# tests/test_quality_gates.py
import json
import subprocess
from pathlib import Path

def test_quality_gates_pass():
    """Run smoke evaluation and assert all gates pass"""
    result = subprocess.run(
        ["python", "scripts/evaluate.py", "--mode", "smoke", "--enforce-thresholds"],
        capture_output=True, text=True,
    )
    assert result.returncode == 0, f"Quality gate failed:\n{result.stdout}\n{result.stderr}"

Este test corre en cada PR. Si falla, no merge.


Troubleshooting

Problema 1: "Tests fallan demasiado, devs los ignoran"

Causa: thresholds irrealmente altos o tolerance demasiado estricto.
Solución: mide noise empírico (sección "Manejo de tolerance"). Ajusta thresholds y tolerance al 2× stdev. Si después de 1 semana siguen fallando, calibra el sistema, no los thresholds.

Problema 2: "No detectamos degradaciones reales"

Causa: thresholds muy bajos o tolerance demasiado permisivo.
Solución: revisa últimos 3 meses de reports. Identifica el run con peor calidad que pasó. Sube threshold ligeramente arriba de ese punto.

Problema 3: "Cambio legítimo de arquitectura rompe regression test"

Causa: baseline corresponde a sistema viejo, métricas cambiaron por buena razón.
Solución: actualiza baseline con justificación documentada. Si la métrica bajó pero por buena razón (más coverage de queries hard en dataset nuevo), documenta y mueve adelante.

Problema 4: "Test pasa local, falla en CI"

Causa: seed o environment variables distintos.
Solución: fija OPENAI_SEED=42 en CI. Asegura que la versión de RAGAS está pinned. Verifica que dataset_version es la misma.

Problema 5: "Demasiados warnings, ruido en cada PR"

Causa: demasiadas métricas como warning, ninguna accionable.
Solución: consolida warnings. Idealmente 1-2 métricas como warning, máximo. Si tienes 6 warnings nadie las lee.


Ejercicios

Ejercicio 1: Calcular thresholds empíricos

Dado un set de runs históricos, calcula thresholds usando z-score 95%.

Ver solución
import statistics

def calculate_empirical_thresholds(runs: list[dict], z: float = 1.96) -> dict:
    if len(runs) < 5:
        raise ValueError("Need at least 5 runs for stable thresholds")
    thresholds = {}
    for category in ["ragas", "retrieval"]:
        for metric in runs[0][category].keys():
            values = [r[category][metric] for r in runs]
            mean = statistics.mean(values)
            stdev = statistics.stdev(values)
            thresholds[f"{category}.{metric}"] = round(mean - z * stdev, 3)
    return thresholds

# Uso:
historical = [json.loads(p.read_text()) for p in Path("eval_reports").glob("*.json")]
thresholds = calculate_empirical_thresholds(historical[-10:])
print(thresholds)

Explicación: z=1.96 da 95% confianza, z=2.58 da 99%. Más estricto = menos falsos positivos pero también menos sensible a regresiones reales.

Ejercicio 2: Implementar regression check con tolerance por métrica

Cada métrica tiene tolerance distinta según su variabilidad inherente.

Ver solución
TOLERANCES = {
    "faithfulness": 0.02,
    "answer_relevancy": 0.03,
    "context_precision": 0.04,
    "context_recall": 0.02,
    "precision_at_5": 0.03,
    "recall_at_5": 0.03,
    "mrr": 0.04,
}

def check_no_regression_per_metric(current: dict, baseline: dict, tolerances: dict = TOLERANCES) -> list[dict]:
    failures = []
    for category in ["ragas", "retrieval"]:
        for metric, base_value in baseline.get(category, {}).items():
            curr_value = current.get(category, {}).get(metric, 0)
            tol = tolerances.get(metric, 0.02)
            if curr_value < base_value - tol:
                failures.append({
                    "metric": metric,
                    "current": curr_value,
                    "baseline": base_value,
                    "tolerance": tol,
                    "deficit": base_value - tol - curr_value,
                })
    return failures

Explicación: tolerance por métrica refleja que context_precision varía más que faithfulness. Tolerance única produce falsos positivos en métricas ruidosas y falsos negativos en métricas estables.

Ejercicio 3: Mensaje de error con sugerencias

Implementa formato de error que sugiera causa probable según métrica que falló.

Ver solución
SUGGESTIONS = {
    "faithfulness": [
        "Review prompt: enforce 'answer only from context'",
        "Check if model was downgraded (gpt-4o → gpt-4o-mini?)",
        "Verify context is not being truncated by token limit",
    ],
    "answer_relevancy": [
        "Check query understanding (M03 query expansion)",
        "Review prompt: be more directive about answer format",
    ],
    "context_precision": [
        "Re-ranker may be misconfigured (M04)",
        "Verify top_k after rerank is reasonable",
    ],
    "context_recall": [
        "Retriever missing relevant docs (chunking, embeddings)",
        "Hybrid search may be needed (M05)",
        "Check metadata filters not over-restrictive (M06)",
    ],
}

def format_failure_with_suggestions(failure: dict) -> str:
    suggestions = SUGGESTIONS.get(failure["metric"], [])
    lines = [
        f"  ❌ {failure['metric']}: {failure.get('current', failure.get('actual')):.3f}",
        f"     Possible causes:",
    ]
    for s in suggestions:
        lines.append(f"       • {s}")
    return "\n".join(lines)

Explicación: sugerencias preconstruidas ahorran ciclos de debug. El developer ve el error y tiene tres hipótesis para investigar inmediatamente.

Ejercicio 4: Workflow de actualización de baseline

Implementa CLI para actualizar baseline solo si las métricas mejoraron y con justificación obligatoria.

Ver solución
import argparse
import json
from datetime import datetime
from pathlib import Path

def update_baseline_safely(report_path: Path, baseline_path: Path, justification: str) -> bool:
    if not justification or len(justification) < 20:
        raise ValueError("Justification required (min 20 chars)")

    new_report = json.loads(report_path.read_text())
    if not baseline_path.exists():
        baseline_path.write_text(json.dumps({**new_report, "justification": justification}, indent=2))
        return True

    old_baseline = json.loads(baseline_path.read_text())
    degraded = []
    for category in ["ragas", "retrieval"]:
        for metric, old_val in old_baseline[category].items():
            new_val = new_report[category].get(metric, 0)
            if new_val < old_val - 0.01:
                degraded.append(f"{metric}: {old_val:.3f}{new_val:.3f}")

    if degraded and "FORCE" not in justification:
        print("Refusing to update: metrics degraded:")
        for d in degraded:
            print(f"  • {d}")
        print("To force, prefix justification with 'FORCE: <reason>'")
        return False

    new_baseline = {
        **new_report,
        "previous_baseline_commit": old_baseline.get("commit_sha"),
        "captured_at": datetime.now().isoformat(),
        "justification": justification,
    }
    baseline_path.write_text(json.dumps(new_baseline, indent=2))
    return True

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--report", required=True)
    parser.add_argument("--baseline", default="eval/baseline.json")
    parser.add_argument("--justification", required=True)
    args = parser.parse_args()
    update_baseline_safely(Path(args.report), Path(args.baseline), args.justification)

Explicación: el flag FORCE: obliga a documentar explícitamente la decisión cuando bajas baseline. Sin esto, los baselines tienden a degradar silenciosamente run a run hasta que el sistema es inservible.


Resumen

  • Threshold absoluto + regression check: dos mecanismos complementarios, necesitas ambos
  • Calibra thresholds empíricamente (z-score sobre runs históricos), por business requirement, o vs humano
  • Tolerance debe ser ≥2× stdev de la métrica para evitar falsos positivos
  • Baseline versionado en git con justificación obligatoria al actualizar
  • Severity (blocking vs warning) modula qué falla bloquea PR vs solo alerta
  • Mensajes de error accionables con sugerencias por métrica aceleran debug
  • Quality gates convierten conversaciones subjetivas en decisiones explícitas

Recursos adicionales

  1. Pytest Assertions - Patrón de tests para Python.
  2. Continuous Integration - Martin Fowler - CI como control de calidad.
  3. Statistical Process Control - Origen de los z-scores en thresholds.
  4. ML Model Monitoring - Patrones para detección de degradación.
  5. OpenAI Evals - Framework alternativo con quality gates.

Creado: Marzo 13, 2026
Versión: 2.0