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:

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

  • query exactamente como un usuario la haría: incluye typos, abreviaciones, casual phrasing si es así tu tráfico real
  • expected_sources permite calcular precision@k y recall@k de retrieval, no solo evaluar respuesta final
  • difficulty permite reportes segmentados ("¿el sistema mejoró en queries hard?")
  • query_type permite detectar regresiones específicas ("multi_hop bajó 15% pero factoid se mantuvo")
  • annotated_by y validated_by enforces que ground truth pasó por dos pares de ojos
  • notes captura 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:

TipoCantidadEjemplo
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.json versionado en git
  • golden_dataset/META.json con la estructura del meta arriba
  • golden_dataset/PROCESS.md documentando 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_sourceslast_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_sources para 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

  1. RAGAS Test Data Generation - Generación sintética complementaria.
  2. OpenAI Evals - Building Custom Evals - Patrones de dataset.
  3. DVC - Data Version Control - Versionado de datasets grandes.
  4. Promptfoo Test Datasets - Estructura alternativa.
  5. Anthropic Evaluation Guide - Best practices.

Creado: Marzo 13, 2026
Versión: 2.0