Módulo 8: RAG Evaluation + Proyecto Integrador
Golden Dataset y Ground Truth para RAG
Descripción de la cápsula
Hay una verdad incómoda sobre evaluación de sistemas RAG que la mayoría de tutoriales no enfatiza lo suficiente: la calidad de tu evaluación depende más del dataset que del framework. Puedes usar RAGAS perfecto, GPT-4o como judge, métricas exquisitamente calibradas — y si tu golden dataset es trivial o no representa tu tráfico real, vas a obtener métricas hermosas que no predicen calidad en producción.
Un golden dataset es el contrato de calidad de tu sistema. Es el conjunto versionado de queries con respuestas correctas conocidas (ground truth) y documentos relevantes esperados, contra el cual mides cualquier cambio del sistema. Si está mal diseñado, tu CI/CD verde es teatro.
En esta cápsula vas a aprender a construir un golden dataset que efectivamente predice calidad en producción: con representatividad demográfica del tráfico real, distribución calibrada de dificultad, ground truth validado por dos personas, versionado en git y proceso de mantenimiento documentado.
Al terminar tendrás 50-100 queries de oro que son tu activo más valioso para evaluación continua. Cuesta más esfuerzo que cualquier otra parte de la guía. Vale más que cualquier otra parte.
El problema con datasets sintéticos puros
La tentación natural es generar el dataset con un LLM: "dame 100 preguntas sobre nuestro dominio". Funciona como punto de partida pero tiene tres problemas que destruyen su utilidad:
- Distribución no representativa: el LLM genera preguntas "limpias" y bien formadas. Tu tráfico real tiene typos, abreviaciones, queries de una palabra, queries en spanglish, queries pasivo-agresivas hacia tu sistema.
- Ground truth circular: si el LLM genera la pregunta y luego otro LLM la contesta basándose en docs, estás midiendo qué tan consistente es el LLM consigo mismo, no qué tan correcta es la respuesta.
- Sesgos del modelo: el LLM evita áreas donde es débil. Tu dataset sintético subrepresenta exactamente las queries donde tu sistema más necesita evaluación.
Regla práctica: mínimo 50% de tu golden dataset debe ser muestreado de queries reales (con anonimización si es necesario). El otro 50% puede ser sintético para llenar gaps de cobertura.
Anatomía de un registro de golden dataset
Un registro debe capturar todo lo necesario para evaluar retrieval Y generation. La estructura mínima:
from pydantic import BaseModel, Field
from typing import Literal
Difficulty = Literal["easy", "medium", "hard"]
QueryType = Literal["factoid", "reasoning", "multi_hop", "ambiguous", "out_of_scope"]
class GoldenRecord(BaseModel):
id: str = Field(..., description="ID estable, formato q_001")
query: str = Field(..., description="Query exactamente como un usuario la haría")
ground_truth_answer: str = Field(..., description="Respuesta correcta validada")
expected_sources: list[str] = Field(..., description="Doc IDs que deben aparecer en retrieval")
difficulty: Difficulty
query_type: QueryType
category: str = Field(..., description="Dominio: security, deployment, etc")
annotated_by: str = Field(..., description="Quién creó este registro")
validated_by: str | None = None # segundo revisor
notes: str | None = None # contexto para futuros editors
Por qué cada campo importa:
queryexactamente como un usuario la haría: incluye typos, abreviaciones, casual phrasing si es así tu tráfico realexpected_sourcespermite calcular precision@k y recall@k de retrieval, no solo evaluar respuesta finaldifficultypermite reportes segmentados ("¿el sistema mejoró en queries hard?")query_typepermite detectar regresiones específicas ("multi_hop bajó 15% pero factoid se mantuvo")annotated_byyvalidated_byenforces que ground truth pasó por dos pares de ojosnotescaptura contexto que se pierde con rotación de equipo
Distribución calibrada de dificultad
No todas las queries son iguales. Un dataset que es 90% factoid (lookup directo) infla métricas y enmascara problemas reales. Distribución sugerida para un dataset de 50 queries:
| Tipo | Cantidad | Ejemplo |
|---|---|---|
| Factoid (fácil) | 15 | "¿Cuál es el TTL default de cache?" |
| Reasoning (medio) | 15 | "¿Por qué usamos cosine en lugar de euclidean?" |
| Multi-hop (difícil) | 10 | "Compara el flujo de auth de v1 con v2" |
| Ambiguous (difícil) | 5 | "Cómo configuro esto?" (sin contexto claro) |
| Out-of-scope (medio) | 5 | "¿Cuándo es el próximo eclipse?" |
Por qué cada categoría:
- Factoid: caso fácil; si falla aquí el sistema está roto
- Reasoning: requiere combinar contexto; mide quality de chunking
- Multi-hop: requiere múltiples docs; mide hybrid search y query expansion
- Ambiguous: mide qué tan bien el sistema pide clarificación o hace inferencia razonable
- Out-of-scope: mide si el sistema sabe abstenerse ("no tengo esa información") en lugar de alucinar
Un sistema saludable no maximiza score en factoid; mantiene score balanceado en todas las categorías.
Proceso de creación del dataset
Construir 50 queries con calidad real toma aproximadamente 1-2 días de trabajo. El proceso:
Paso 1: Muestreo de queries reales (4 horas)
def sample_real_queries(logs_path: str, n: int = 100) -> list[str]:
import random
with open(logs_path) as f:
all_queries = [line.strip() for line in f if line.strip()]
# estratificación por longitud para no sesgar a queries cortas
short = [q for q in all_queries if len(q) < 50]
medium = [q for q in all_queries if 50 <= len(q) < 150]
long = [q for q in all_queries if len(q) >= 150]
sampled = (
random.sample(short, min(40, len(short)))
+ random.sample(medium, min(40, len(medium)))
+ random.sample(long, min(20, len(long)))
)
return sampled[:n]
Anonimiza nombres, IDs sensibles, datos personales antes de versionar.
Paso 2: Anotación de ground truth (8 horas)
Para cada query, una persona escribe la respuesta correcta consultando los documentos fuente. Importante:
- Cita las fuentes (
expected_sources) — no asumas, verifica - Sé conciso pero completo — la respuesta correcta no es un párrafo elaborado, es lo mínimo correcto
- Marca difficulty honestamente — "fácil para mí" puede ser "difícil para el sistema"
Paso 3: Validación cruzada (4 horas)
Una segunda persona revisa cada registro y marca validated_by. Si discrepa, se discute. Si no se llega a acuerdo, el registro se descarta o se marca como "edge_case" y se trata por separado.
Paso 4: Generación sintética para gaps (2 horas)
Si después de muestreo notas que faltan queries de cierto tipo (ej: out-of-scope), genera con LLM y revisa manualmente cada una.
def generate_out_of_scope_queries(domain: str, n: int = 5) -> list[str]:
prompt = f"""Genera {n} preguntas que un usuario podría hacer pero que están fuera
del dominio de {domain}. Deben ser plausibles pero no contestables con la documentación
del producto. Una por línea, sin numeración."""
return llm_generate(prompt).split("\n")[:n]
Versionado y mantenimiento del dataset
GOLDEN_DATASET_META = {
"name": "advanced-rag-golden",
"version": "v1.2.0",
"created_at": "2026-03-13",
"last_updated": "2026-04-15",
"size": 75,
"distribution": {
"factoid": 22,
"reasoning": 20,
"multi_hop": 15,
"ambiguous": 8,
"out_of_scope": 10,
},
"categories": ["security", "deployment", "api", "troubleshooting", "architecture"],
"schema_version": "1.0",
"annotators": ["maria@team.com", "luis@team.com"],
}
Reglas de versionado (semver adaptado):
- MAJOR (v2.0.0): cambios que invalidan comparación con versiones anteriores (ej: cambiar criterios de difficulty)
- MINOR (v1.x.0): agregar queries nuevas; comparable con cuidado
- PATCH (v1.0.x): corregir typos en queries existentes; comparable directamente
Cada release del sistema se evalúa contra una versión específica del dataset. Documentas en CHANGELOG: "Release X.Y.Z evaluado con golden v1.2.0, scores [...]".
Mantenimiento trimestral:
- 20% de queries se reemplazan con queries nuevas de tráfico real
- Queries que el sistema resuelve perfectamente ya no informan; rótalas
- Queries donde el sistema falla persistentemente se mantienen como "regression tests"
Conexión con el proyecto final
Tu Advanced RAG System debe incluir:
golden_dataset/v1.0.0.jsonversionado en gitgolden_dataset/META.jsoncon la estructura del meta arribagolden_dataset/PROCESS.mddocumentando cómo se creó y mantiene- Referencia explícita en el CI: "evaluación corre contra golden v1.0.0"
golden_dataset/
├── v1.0.0.json # 50 queries iniciales
├── v1.1.0.json # 60 queries (added 10 multi-hop)
├── v1.2.0.json # current
├── META.json
├── PROCESS.md
└── CHANGELOG.md
Versiones antiguas se mantienen para reproducir comparaciones históricas.
Troubleshooting
Problema 1: "Métricas perfectas pero sistema falla en producción"
Causa: dataset trivial, demasiado factoid, no representativo del tráfico real.
Solución: muestrea logs reales y reemplaza 30% del dataset. Si tus métricas bajan, era esto. Si se mantienen, agrega más queries hard y multi-hop.
Problema 2: "Ground truth inconsistente entre anotadores"
Causa: criterios vagos o anotadores con criterios distintos.
Solución: documenta criterios explícitos en PROCESS.md. Ejemplo: "ground truth máximo 200 palabras", "siempre cita expected_sources verificadas". Calibra con sesión de 5 queries juntos antes de empezar.
Problema 3: "Cada corrida da resultados incomparables"
Causa: dataset cambia sin versionado.
Solución: congela versiones en git. Cualquier cambio incrementa version. CI usa versión explícita, no "latest".
Problema 4: "Ground truth queda obsoleta cuando docs cambian"
Causa: la documentación fuente cambió pero ground truth no se actualizó.
Solución: trigger de revisión: cualquier PR que toque docs/ requiere validación del golden dataset. Mantén un mapping expected_sources → last_validated_at.
Problema 5: "Dataset es muy pequeño para detectar regresiones sutiles"
Causa: 20 queries no tienen poder estadístico para diferencias de 2-3%.
Solución: crece a 100-200 queries. Para diferencias <2% necesitas >300. Más allá, retornos decrecen.
Ejercicios
Ejercicio 1: Diseñar distribución para tu dominio
Para un sistema RAG sobre documentación de Kubernetes, diseña la distribución del golden dataset (60 queries totales) con justificación.
Ver solución
distribution = {
"factoid": 15, # "¿qué es un Pod?", "puerto default de kubelet"
"reasoning": 18, # "¿por qué un Deployment vs un StatefulSet?"
"multi_hop": 12, # "diferencias entre Service ClusterIP, NodePort, LoadBalancer en términos de exposición"
"ambiguous": 8, # "cómo escalo esto" (sin contexto)
"out_of_scope": 7, # "¿cómo configuro AWS RDS?" (fuera de K8s docs)
}
total = sum(distribution.values()) # 60
categories = {
"core_concepts": 15, # Pods, Deployments, Services
"networking": 12,
"storage": 8,
"security": 10,
"operations": 15,
}
Explicación: 25% factoid es razonable para K8s donde lookups directos son comunes. 30% reasoning porque K8s tiene muchas decisiones de diseño. 12 multi-hop porque comparaciones entre conceptos son frecuentes en preguntas reales.
Ejercicio 2: Validador de schema completo
Implementa una función que valide un golden dataset completo y reporte issues específicos.
Ver solución
def validate_dataset(records: list[dict]) -> dict:
issues = []
seen_ids = set()
for r in records:
if "id" not in r or not r["id"]:
issues.append(f"Missing id: {r}")
continue
if r["id"] in seen_ids:
issues.append(f"Duplicate id: {r['id']}")
seen_ids.add(r["id"])
if not r.get("validated_by"):
issues.append(f"{r['id']}: not validated by second person")
if not r.get("expected_sources"):
issues.append(f"{r['id']}: missing expected_sources")
if r.get("difficulty") not in ["easy", "medium", "hard"]:
issues.append(f"{r['id']}: invalid difficulty {r.get('difficulty')}")
if len(r.get("ground_truth_answer", "")) < 10:
issues.append(f"{r['id']}: ground truth too short, suspicious")
distribution = {}
for r in records:
qt = r.get("query_type", "unknown")
distribution[qt] = distribution.get(qt, 0) + 1
return {
"valid": len(issues) == 0,
"issues": issues,
"size": len(records),
"distribution": distribution,
}
Explicación: validación automatizada antes de versionar. Si CI corre este validador, datasets incompletos no llegan al main branch.
Ejercicio 3: Pipeline de muestreo desde logs
Implementa función que muestrea queries de logs anonimizando datos sensibles.
Ver solución
import re
import random
def anonymize(query: str) -> str:
# Email
query = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "<EMAIL>", query)
# IPs
query = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "<IP>", query)
# UUIDs
query = re.sub(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "<UUID>", query)
# Números largos (potential IDs/phones)
query = re.sub(r"\b\d{8,}\b", "<NUM>", query)
return query
def sample_for_golden(logs: list[str], n: int = 50, min_length: int = 10) -> list[str]:
filtered = [q for q in logs if len(q) >= min_length]
sampled = random.sample(filtered, min(n, len(filtered)))
return [anonymize(q) for q in sampled]
Explicación: anonimización antes de versionar evita filtrar PII al repo. La función es deliberadamente conservadora; revisa manualmente antes de commit.
Ejercicio 4: Reporte de cobertura
Implementa función que reporta gaps en el dataset (categorías subrepresentadas).
Ver solución
def coverage_report(records: list[dict], target_distribution: dict) -> dict:
actual = {}
for r in records:
key = r.get("query_type", "unknown")
actual[key] = actual.get(key, 0) + 1
gaps = {}
for query_type, target_count in target_distribution.items():
current = actual.get(query_type, 0)
if current < target_count:
gaps[query_type] = {
"current": current,
"target": target_count,
"missing": target_count - current,
}
return {
"total_records": len(records),
"actual_distribution": actual,
"gaps": gaps,
"coverage_complete": len(gaps) == 0,
}
Explicación: ejecuta este reporte antes de declarar el dataset listo. Gaps en out_of_scope o ambiguous típicamente esconden problemas reales del sistema.
Resumen
- El golden dataset es el activo estratégico más valioso de tu sistema de evaluación
- Mínimo 50% debe ser muestreado de tráfico real, no 100% sintético
- Distribución calibrada: factoid + reasoning + multi-hop + ambiguous + out-of-scope
- Cada registro requiere
expected_sourcespara evaluar retrieval, no solo respuesta final - Validación cruzada por dos anotadores antes de versionar
- Versionado semver: MAJOR rompe comparabilidad, MINOR agrega, PATCH corrige
- Mantenimiento trimestral: 20% de queries se rotan con tráfico nuevo
Recursos adicionales
- RAGAS Test Data Generation - Generación sintética complementaria.
- OpenAI Evals - Building Custom Evals - Patrones de dataset.
- DVC - Data Version Control - Versionado de datasets grandes.
- Promptfoo Test Datasets - Estructura alternativa.
- Anthropic Evaluation Guide - Best practices.
Creado: Marzo 13, 2026
Versión: 2.0