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:
| Mecanismo | Pregunta | Cuá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étrica | Stdev típica | Tolerance recomendado |
|---|---|---|
| Faithfulness | ~0.01 | 0.02 |
| Answer Relevancy | ~0.015 | 0.03 |
| Context Precision | ~0.025 | 0.04 |
| Context Recall | ~0.012 | 0.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:
- 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"
- Después de cambio de dataset → no se puede comparar contra baseline viejo. Crea nueva línea base.
- 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
| Criterio | Sin quality gates | Con quality gates |
|---|---|---|
| Riesgo de regresión silenciosa | Alto | Bajo |
| Velocidad de detección | Días/semanas (cuando alguien nota) | Inmediata (en PR) |
| Confianza para refactor agresivo | Baja: temor a romper | Alta: red de seguridad |
| Onboarding de nuevos devs | Riesgoso: pueden romper sin saber | Seguro: el sistema avisa |
| Discusiones sobre calidad | Subjetivas | Objetivas con números |
| Mantenimiento del sistema | Reactivo | Proactivo |
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
- Pytest Assertions - Patrón de tests para Python.
- Continuous Integration - Martin Fowler - CI como control de calidad.
- Statistical Process Control - Origen de los z-scores en thresholds.
- ML Model Monitoring - Patrones para detección de degradación.
- OpenAI Evals - Framework alternativo con quality gates.
Creado: Marzo 13, 2026
Versión: 2.0