← Todo el material
Curso Avanzado 23 de agosto de 2026

RAG — Módulo 10: evaluación, medir el RAG en vez de adivinar

Décimo módulo del Curso de RAG: cómo evaluar un sistema RAG en serio. Separar retrieval de generación, construir un golden dataset, métricas de recuperación (recall@k, MRR, nDCG), métricas de generación con RAGAS y LLM-as-a-judge, y evals corriendo en CI como quality gate de cada PR.

☰ Índice del contenido

Con el módulo 9 cerramos el pipeline: ya tienes ingesta, chunking, embeddings, búsqueda híbrida, reranking y un prompt que obliga al modelo a citar y a decir “no sé”. El sistema funciona. Y aquí empieza el problema real, que es distinto al que llevamos nueve módulos resolviendo: no sabes qué tan bien funciona. Probaste diez preguntas, ocho salieron bien, dos regular, y esa es toda tu información. Cuando alguien pregunte si conviene subir el chunk size a 800 tokens o cambiar el modelo de embeddings, vas a responder con una opinión. Este módulo convierte esas opiniones en números.

La tesis del módulo

Sin evaluación, cada cambio que haces en un RAG es una corazonada; con evaluación, es un experimento. El chunk size, el modelo de embeddings, el reranker, el prompt: todos son hiperparámetros, y un hiperparámetro que no mides es un hiperparámetro que estás eligiendo al azar con pasos extra.

Por qué “a ojo” no funciona

Evaluar a ojo tiene tres fallas que no se arreglan probando más rato.

No es reproducible. Mañana pruebas otras preguntas, en otro orden, con otra tolerancia. Dos evaluaciones a ojo del mismo sistema dan resultados distintos, así que no puedes comparar la versión de hoy con la de la semana pasada.

Tiene sesgo de muestra. Las preguntas que se te ocurren son las que ya sabes que funcionan. El usuario real pregunta cosas raras, con faltas de ortografía, con pronombres colgando de la conversación anterior y con códigos de producto que tú nunca escribirías.

No localiza la falla. “Respondió mal” no es un diagnóstico. ¿No estaba el chunk en el índice? ¿Estaba y la búsqueda no lo trajo? ¿Lo trajo en la posición 18 y solo pasaste 5 al prompt? ¿Llegó bien y el modelo lo deformó? Son cuatro bugs distintos con cuatro arreglos distintos.

Separar las dos mitades

La decisión más importante de todo el módulo: evalúa el retrieval y la generación por separado. Un RAG es una cadena, y una métrica de punta a punta (“¿la respuesta final fue buena?”) te dice que la cadena se cortó, no dónde.

                 EVALUACIÓN EN DOS ETAPAS

START ──► ┌─────────────────┐
          │    pregunta     │
          └────────┬────────┘
                   │
                   ▼
          ┌─────────────────┐     ┌──────────────────┐
          │    RETRIEVAL    │ ──► │  recall@k  MRR   │
          │  top-k chunks   │     │  nDCG@k  hit     │
          └────────┬────────┘     └──────────────────┘
                   │
                   ▼
          ┌─────────────────┐     ┌──────────────────┐
          │   GENERACIÓN    │ ──► │  faithfulness    │
          │    respuesta    │     │  answer relev.   │
          └────────┬────────┘     └──────────────────┘
                   │
                   ▼
              respuesta final ──► END

  El recall del retrieval es el TECHO de la generación:
  lo que no se recuperó, no se puede responder.

La relación entre las dos mitades no es simétrica. Si el recall del retrieval es 0.6, el mejor sistema de generación imaginable responde bien, como máximo, el 60% de las preguntas. Ninguna mejora de prompt sube ese techo. Por eso el orden de trabajo siempre es: primero mide el retrieval, y solo cuando el recall está alto (0.9 o más en el top-10 de un corpus sano) tiene sentido invertir en la generación.

El golden dataset: el activo más valioso del proyecto

El golden dataset (o eval set) es un conjunto de casos de prueba curados donde cada caso tiene:

  1. Una pregunta realista.
  2. Los chunks relevantes que deberían recuperarse (el ground truth del retrieval, la verdad de referencia).
  3. Una respuesta ideal escrita por un humano que sabe del tema (el ground truth de la generación).

Ese archivo vale más que tu código. El código lo reescribes en un fin de semana; un golden dataset bien curado son semanas de trabajo experto y es lo que te permite cambiar de framework, de base vectorial o de modelo sin volar a ciegas.

Cuántas preguntas

50 a 100 preguntas bien elegidas superan a 1000 mal elegidas. No es una frase motivacional: 1000 preguntas triviales generadas por un LLM sobre chunks al azar te dan un número alto y estable que no correlaciona con la experiencia del usuario. Lo que necesitas es cobertura de casos:

  • Preguntas fáciles de un solo chunk (el caso mayoritario).
  • Preguntas que requieren combinar dos o tres documentos.
  • Preguntas con códigos, SKUs, nombres propios y números (el terreno de BM25).
  • Preguntas cuya respuesta no está en el corpus: el sistema debe decir “no tengo esa información”. Que sean al menos un 10% del set.
  • Preguntas ambiguas y preguntas mal escritas.
  • Preguntas de seguimiento en conversación (“¿y eso incluye IVA?”).

Cómo construirlo

Fuente 1: consultas reales. Tickets de soporte, búsquedas del sitio, preguntas del canal interno de Slack, el historial del chatbot anterior. Es la fuente de mayor valor porque contiene la distribución real de preguntas, incluidas las que a ti nunca se te ocurrirían.

Fuente 2: generación sintética a partir de tus chunks. Le pides a un LLM que, dado un chunk, escriba preguntas que ese chunk responde. Sale rápido y da cobertura amplia del corpus. El chunk de origen queda automáticamente como ground truth del retrieval, que es la parte más cara de etiquetar a mano.

"""Generación sintética de preguntas para el golden dataset.

Idea: por cada chunk, pedirle al LLM preguntas que ESE chunk responde.
El chunk queda como ground truth de retrieval sin etiquetar a mano.
IMPRESCINDIBLE: revisión humana antes de aceptar nada de esto.
"""
import json
import random
from openai import OpenAI

client = OpenAI()

PROMPT = """Eres un experto construyendo sets de evaluación para
sistemas de búsqueda. Lee el fragmento y escribe {n} preguntas que
un usuario real haría y que este fragmento responde COMPLETAMENTE.

Reglas:
- Cada pregunta debe poder responderse solo con este fragmento.
- Usa el vocabulario del usuario, no el del documento (si el doc dice
  "plazo de retracto", el usuario probablemente dice "devolver").
- Varía el estilo: una directa, una coloquial, una con un dato exacto.
- Nada de preguntas meta tipo "¿de qué habla este texto?".

Devuelve JSON: {{"preguntas": ["...", "..."]}}

FRAGMENTO:
{chunk}"""


def generar_preguntas(chunk_id: str, texto: str, n: int = 3) -> list[dict]:
    r = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user",
                   "content": PROMPT.format(n=n, chunk=texto)}],
        response_format={"type": "json_object"},
        temperature=0.7,  # algo de variedad SOLO para generar preguntas
    )
    preguntas = json.loads(r.choices[0].message.content)["preguntas"]
    return [
        {
            "pregunta": p,
            "chunks_relevantes": [chunk_id],   # ground truth retrieval
            "respuesta_ideal": None,           # lo llena un humano
            "origen": "sintetico",
            "revisado": False,                 # gate de calidad
        }
        for p in preguntas
    ]


def construir_dataset(chunks: dict[str, str], muestra: int = 60) -> list:
    """Muestrea chunks en vez de usarlos todos: 60 chunks x 3 preguntas
    son 180 casos, ya suficiente para una primera iteración."""
    ids = random.sample(list(chunks), min(muestra, len(chunks)))
    casos = []
    for cid in ids:
        casos.extend(generar_preguntas(cid, chunks[cid]))
    return casos


if __name__ == "__main__":
    corpus = {"c_001": "La garantía legal en Chile cubre 6 meses..."}
    casos = construir_dataset(corpus)
    with open("golden_v1.jsonl", "w", encoding="utf-8") as f:
        for c in casos:
            f.write(json.dumps(c, ensure_ascii=False) + "\n")
    print(f"{len(casos)} casos generados. Ahora: revisión humana.")

Ojo: la generación sintética sin revisión humana es la trampa más común de este módulo. El LLM escribe preguntas que calcan el vocabulario del chunk, así que la búsqueda vectorial las resuelve trivialmente y tu recall@5 sale 0.97. Ese 0.97 no mide tu sistema: mide que le hiciste preguntas copiadas del texto. Un humano debe leer cada caso, reescribir las preguntas en lenguaje de usuario y botar las que no tienen sentido. Es tedioso y es lo que hace que el número signifique algo.

Métricas de retrieval

Son las métricas clásicas de information retrieval, las mismas que se usan en buscadores desde hace décadas. Notación: para una pregunta, R es el conjunto de chunks realmente relevantes y K la lista de los k chunks que devolvió tu sistema, en orden.

Recall@k

Recall@k = (relevantes recuperados en el top-k) / (total de relevantes)

Responde: ¿estaba el chunk correcto entre los k que traje? Es LA métrica del RAG, porque define el techo de todo lo que viene después. Si el chunk correcto no está en el top-k, la generación no tiene forma de acertar.

Ejemplo: la pregunta tiene 2 chunks relevantes (c_12, c_40). Tu top-5 devuelve [c_07, c_12, c_31, c_02, c_19]. Recuperaste 1 de 2 → recall@5 = 0.5.

Precision@k

Precision@k = (relevantes recuperados en el top-k) / k

Responde: ¿cuánta basura le estoy metiendo al prompt? En el ejemplo anterior: 1 de 5 → precision@5 = 0.2. En RAG precision importa menos que recall (un chunk irrelevante de más el modelo lo ignora, un chunk faltante no lo inventa), pero importa: cada chunk sucio son tokens pagados y ruido que empeora el lost in the middle.

MRR (Mean Reciprocal Rank)

RR = 1 / (posición del primer resultado relevante), y MRR es el promedio de RR sobre todas las preguntas.

Responde: ¿qué tan arriba salió el primer acierto? Si el primer relevante salió en posición 1 → RR = 1.0. En posición 2 → 0.5. En posición 5 → 0.2. Si no apareció → 0.

Ejemplo con tres preguntas cuyos primeros aciertos salieron en posiciones 1, 3 y 2: MRR = (1/1 + 1/3 + 1/2) / 3 = (1 + 0.333 + 0.5) / 3 = 0.611.

MRR es la métrica que mejor refleja la experiencia cuando el usuario ve las fuentes citadas: quiere el resultado bueno arriba, no en el puesto 8.

nDCG@k

DCG (Discounted Cumulative Gain) suma la relevancia de cada resultado descontada por su posición, con un logaritmo:

DCG@k = Σ rel_i / log₂(i + 1), para i de 1 a k.

nDCG normaliza dividiendo por el DCG del orden ideal, para que quede entre 0 y 1. Su gracia es que admite relevancia graduada: un chunk puede ser 3 (responde perfecto), 2 (aporta), 1 (relacionado) o 0 (irrelevante), en vez del binario relevante/no relevante.

Ejemplo con k=3 y relevancias obtenidas [2, 0, 3]: DCG = 2/log₂(2) + 0/log₂(3) + 3/log₂(4) = 2/1 + 0 + 3/2 = 3.5. El orden ideal sería [3, 2, 0]: IDCG = 3/1 + 2/1.585 + 0 = 4.262. nDCG@3 = 3.5 / 4.262 = 0.821.

Hit rate

Hit rate@k = fracción de preguntas donde al menos un chunk relevante apareció en el top-k.

Es el recall binario y grosero: no le importa cuántos relevantes trajiste, solo si trajiste alguno. Es la métrica más fácil de explicarle a un stakeholder y la más honesta como primer termómetro.

MétricaQué mideCuándo importaValor razonable
Recall@kSi el chunk correcto está entre los kSiempre. Es el techo del sistema> 0.90 con k=10
Precision@kCuánta basura entra al promptContexto caro o ventana corta> 0.40 con k=5
MRRPosición del primer aciertoSe muestran las fuentes al usuario> 0.70
nDCG@kOrden con relevancia graduadaHay grados de relevancia etiquetados> 0.75 con k=10
Hit rate@kSi hubo al menos un aciertoReporte ejecutivo, primer diagnóstico> 0.95 con k=10

Regla práctica: mide recall@k con k grande (10, 20) para saber si el chunk existe y la búsqueda lo alcanza, y MRR o nDCG con k chico (3, 5) para saber si el reranker lo está poniendo arriba. Recall alto con MRR bajo tiene un diagnóstico único: te falta reranking.

Evaluación de retrieval a mano

Antes de invocar cualquier librería, conviene ver que esto son cuarenta líneas. Lo que hacen los frameworks por debajo no es magia.

"""Evaluación de retrieval sin frameworks: recall@k, MRR y hit rate."""
import json
from statistics import mean


def cargar(path: str) -> list[dict]:
    with open(path, encoding="utf-8") as f:
        return [json.loads(l) for l in f if l.strip()]


def evaluar_retrieval(casos: list[dict], buscar, k: int = 10) -> dict:
    """`buscar(pregunta, k)` debe devolver una lista de chunk_id
    ORDENADA de más a menos relevante, tal como llega al prompt."""
    recalls, rrs, hits, precisiones = [], [], [], []

    for caso in casos:
        esperados = set(caso["chunks_relevantes"])
        if not esperados:
            continue  # los casos "no responde" se miden aparte
        obtenidos = buscar(caso["pregunta"], k)

        # recall@k: qué fracción de los relevantes logré traer
        aciertos = esperados & set(obtenidos)
        recalls.append(len(aciertos) / len(esperados))

        # precision@k: qué fracción de lo que traje era relevante
        precisiones.append(len(aciertos) / k)

        # hit rate: ¿al menos uno?
        hits.append(1.0 if aciertos else 0.0)

        # reciprocal rank: 1 / posición (1-indexada) del primer acierto
        rr = 0.0
        for pos, cid in enumerate(obtenidos, start=1):
            if cid in esperados:
                rr = 1.0 / pos
                break
        rrs.append(rr)

    return {
        f"recall@{k}": round(mean(recalls), 3),
        f"precision@{k}": round(mean(precisiones), 3),
        f"hit_rate@{k}": round(mean(hits), 3),
        "mrr": round(mean(rrs), 3),
        "n_casos": len(recalls),
    }


if __name__ == "__main__":
    casos = cargar("golden_v1.jsonl")
    # Compara SIEMPRE contra un baseline. El baseline honesto es BM25.
    from mi_rag import buscar_bm25, buscar_hibrido_rerank

    print("baseline BM25 :", evaluar_retrieval(casos, buscar_bm25))
    print("híbrido+rerank:", evaluar_retrieval(casos, buscar_hibrido_rerank))
    # baseline BM25 : {'recall@10': 0.71, 'mrr': 0.52, ...}
    # híbrido+rerank: {'recall@10': 0.93, 'mrr': 0.81, ...}

Ese script corre en segundos, no cuesta un peso (no llama a ningún LLM) y ya te deja tomar decisiones. Muchos equipos que dicen “no evaluamos porque es caro” no evalúan el retrieval, que es gratis.

Métricas de generación: el enfoque RAGAS

Para la segunda mitad no hay fórmula cerrada: hay que juzgar texto. RAGAS (Retrieval-Augmented Generation Assessment) popularizó cuatro métricas que se volvieron el vocabulario común.

Faithfulness (fidelidad). ¿Cada afirmación de la respuesta se deduce del contexto entregado? Se calcula descomponiendo la respuesta en afirmaciones atómicas y verificando una por una contra el contexto. Es la métrica anti-alucinación: una respuesta correcta pero no deducible del contexto puntúa bajo, y está bien que así sea, porque significa que el modelo respondió de memoria y mañana va a inventar.

Answer relevancy (relevancia de la respuesta). ¿La respuesta contesta lo que se preguntó? Detecta el caso del modelo que responde algo verdadero, fiel al contexto y completamente al lado de la pregunta.

Context precision. De los chunks que llegaron al prompt, ¿los útiles estaban arriba? Es precision@k con juicio de un LLM en vez de etiquetas humanas.

Context recall. ¿El contexto contenía todo lo necesario para construir la respuesta ideal? Se calcula viendo qué fracción de las afirmaciones de la respuesta ideal está soportada por el contexto. Es la puerta de entrada al diagnóstico: context recall bajo = problema de retrieval, no de prompt.

Answer correctness. Compara la respuesta generada contra la respuesta ideal del golden dataset, combinando parecido semántico y coincidencia factual. Es la más cercana a “¿está buena?” y la que más depende de que la respuesta ideal esté bien escrita.

"""Evaluación de generación con RAGAS (API de ragas >= 0.2)."""
from ragas import EvaluationDataset, evaluate
from ragas.metrics import (
    Faithfulness,
    AnswerRelevancy,
    ContextPrecision,
    ContextRecall,
)

# RAGAS espera cuatro campos por caso. `retrieved_contexts` son los
# chunks TAL COMO llegaron al prompt (texto, no ids) y `reference` la
# respuesta ideal. (En ragas 0.1 se llamaban question / answer /
# contexts / ground_truth, y las métricas eran instancias en minúscula.)
filas = []

for caso in cargar("golden_v1.jsonl"):
    chunks = buscar_hibrido_rerank(caso["pregunta"], k=5)
    respuesta = responder(caso["pregunta"], chunks)
    filas.append({
        "user_input": caso["pregunta"],
        "response": respuesta,
        "retrieved_contexts": [c.texto for c in chunks],
        "reference": caso["respuesta_ideal"],
    })

resultado = evaluate(
    EvaluationDataset.from_list(filas),
    metrics=[Faithfulness(), AnswerRelevancy(),
             ContextPrecision(), ContextRecall()],
)
print(resultado)
# {'faithfulness': 0.91, 'answer_relevancy': 0.88,
#  'context_precision': 0.79, 'context_recall': 0.94}

# El df por fila es lo que de verdad sirve: ordena por la peor métrica
# y lee los 10 peores casos a mano. Ahí están los bugs.
df = resultado.to_pandas()
print(df.nsmallest(10, "faithfulness")[["user_input", "faithfulness"]])

El último bloque es el que más se salta la gente. El promedio te dice si mejoraste; los diez peores casos te dicen qué arreglar. Leer esos diez a mano después de cada corrida es el hábito que separa a los equipos que mejoran de los que solo miden.

LLM-as-a-judge y sus límites

Todas las métricas de generación se calculan con LLM-as-a-judge: un modelo lee la pregunta, el contexto y la respuesta, y emite un puntaje. Funciona sorprendentemente bien y tiene cuatro sesgos documentados que hay que administrar.

ProblemaQué pasaMitigación
Sesgo de posiciónEn comparaciones A/B prefiere la primera opciónEvaluar en ambos órdenes y promediar
Sesgo de longitudPuntúa mejor las respuestas largas y elaboradasRúbrica explícita que penalice relleno
No determinismoLa misma entrada da 0.7 y luego 0.9temperature=0 y promediar 3 corridas
Sesgo de auto-preferenciaPrefiere el texto generado por su propia familiaJuez de otra familia que el generador
Costo y latencia2 a 5 llamadas por caso, x 200 casos, x cada PRMuestrear casos; retrieval gratis en cada PR

La mitigación que más rinde es la rúbrica explícita. Un prompt de juez que dice “califica de 1 a 5 la calidad” produce ruido; uno que define qué significa cada nivel produce señal:

RUBRICA_FIDELIDAD = """Eres un evaluador estricto. Compara la RESPUESTA
contra el CONTEXTO y asigna un puntaje según esta rúbrica:

1 = Contiene afirmaciones que CONTRADICEN el contexto.
2 = Contiene afirmaciones que no están en el contexto (inventadas),
    aunque no lo contradigan.
3 = Todo se deduce del contexto, pero con inferencias discutibles.
4 = Todo se deduce del contexto de forma directa; falta algún matiz.
5 = Cada afirmación es trazable a una frase concreta del contexto.

Antes del puntaje, lista las afirmaciones de la respuesta y marca cada
una como SOPORTADA o NO SOPORTADA, citando la frase del contexto.

Devuelve JSON: {"afirmaciones": [...], "puntaje": N, "razon": "..."}"""

Dos detalles de ese prompt hacen la diferencia: obligar al juez a enumerar las afirmaciones antes de puntuar (razonamiento antes que veredicto, que sube la consistencia) y pedir la cita textual (hace verificable el juicio del juez).

Y el paso que casi nadie da: calibrar el juez contra humanos. Toma 30 casos, que dos personas los puntúen, y mide la correlación con el juez. Si la correlación es baja, tu métrica automática no está midiendo lo que crees. Ese ejercicio se hace una vez por proyecto y ahorra meses de optimizar un número inventado.

Herramientas del ecosistema

HerramientaOpen sourceFoco principalIntegración
RAGASSíMétricas de RAG (retrieval + generación)Librería Python, HF Datasets
DeepEvalSíEvals estilo pytest, asserts con umbralpytest, CI nativo
TruLensSíFeedback functions y traza de la cadenaLangChain, LlamaIndex
LangSmithNo (SaaS)Trazas, datasets, evals sobre producciónLangChain / LangGraph
Phoenix / ArizePhoenix síObservabilidad, drift y análisis de embeddingsOpenTelemetry, agnóstico
BraintrustNo (SaaS)Experimentos, comparación de versiones, UISDK propio, agnóstico

Recomendación pragmática: RAGAS o DeepEval para las métricas, más una herramienta de trazas (Phoenix si quieres self-hosted, LangSmith si ya vives en LangChain). No necesitas seis herramientas; necesitas un golden dataset versionado y una sola forma de correrlo.

Evals en CI: la evaluación como quality gate

Una evaluación que se corre cuando alguien se acuerda no protege nada. La evaluación tiene que correr sola, en cada pull request, y bloquear el merge si el sistema empeoró. Es exactamente la idea de evals como tests aplicada al RAG, montada sobre el pipeline de CI con GitHub Actions.

"""tests/test_rag_evals.py — evals como tests, con umbrales duros.
Corre en cada PR. Si baja de los umbrales, el PR no pasa.
"""
import json
import pytest
from mi_rag import buscar_hibrido_rerank, responder
from evaluacion import evaluar_retrieval

UMBRALES = {
    "recall@10": 0.90,     # techo del sistema: el más innegociable
    "mrr": 0.70,           # el reranker está haciendo su pega
    "faithfulness": 0.85,  # anti-alucinación
}


@pytest.fixture(scope="session")
def casos():
    with open("evals/golden_v3.jsonl", encoding="utf-8") as f:
        return [json.loads(l) for l in f if l.strip()]


def test_retrieval_no_regresiona(casos):
    """Barato y determinista: corre en TODOS los PRs."""
    m = evaluar_retrieval(casos, buscar_hibrido_rerank, k=10)
    assert m["recall@10"] >= UMBRALES["recall@10"], (
        f"recall@10 cayó a {m['recall@10']} "
        f"(umbral {UMBRALES['recall@10']})"
    )
    assert m["mrr"] >= UMBRALES["mrr"], f"MRR cayó a {m['mrr']}"


@pytest.mark.caro  # se corre en PRs que tocan prompts o el generador
def test_generacion_es_fiel(casos):
    """Usa LLM-as-a-judge: cuesta plata y tarda. Muestrea 60 casos."""
    from evaluacion import evaluar_generacion

    m = evaluar_generacion(casos[:60], buscar_hibrido_rerank, responder)
    assert m["faithfulness"] >= UMBRALES["faithfulness"], (
        f"faithfulness cayó a {m['faithfulness']}: el modelo está "
        f"inventando fuera del contexto"
    )


def test_sabe_decir_que_no_sabe(casos):
    """Los casos sin respuesta en el corpus son los más importantes:
    aquí se detecta el RAG que alucina en vez de abstenerse."""
    sin_respuesta = [c for c in casos if not c["chunks_relevantes"]]
    assert sin_respuesta, "El golden dataset necesita casos negativos"

    abstenciones = sum(
        "no tengo esa información" in responder(
            c["pregunta"], buscar_hibrido_rerank(c["pregunta"], k=5)
        ).lower()
        for c in sin_respuesta
    )
    tasa = abstenciones / len(sin_respuesta)
    assert tasa >= 0.90, f"Solo se abstuvo en {tasa:.0%} de los casos"

La separación entre el test barato (retrieval, sin LLM, corre siempre) y el caro (generación, con juez, corre marcado) es lo que hace sostenible la práctica. El de retrieval tarda segundos y atrapa la mayoría de las regresiones.

El método: una variable a la vez

Con el golden dataset y el runner listos, el trabajo cambia de naturaleza. Deja de ser “probar cosas” y pasa a ser un protocolo:

  1. Fija un baseline. El baseline honesto es BM25 solo, sin embeddings ni reranker. Es barato, es sorprendentemente bueno y si tu sistema sofisticado no le gana claramente, tienes un problema de diseño, no de tuning.
  2. Cambia UNA cosa. Chunk size 512 → 800. Nada más.
  3. Mide contra el mismo dataset. El mismo archivo, la misma versión.
  4. Registra el resultado en una tabla: variante, métricas, costo, latencia.
  5. Acepta o descarta, y vuelve al paso 2.
Varianterecall@10MRRfaithfulnessLatencia p95
BM25 solo (baseline)0.710.520.8390 ms
Vectorial solo0.790.610.86140 ms
Híbrido RRF0.890.680.88165 ms
Híbrido + reranker0.930.810.91320 ms
Híbrido + reranker + chunks 8000.940.800.89335 ms

Una tabla así responde en diez segundos preguntas que en un equipo sin evals generan una reunión de una hora. Fíjate en la última fila: subir el chunk size mejoró el recall un punto y empeoró faithfulness, porque entra más texto irrelevante al prompt. Sin medir las dos mitades, ese trade-off es invisible.

Errores comunes

  • Evaluar “a ojo” con tres preguntas. No es reproducible, tiene sesgo de muestra y no localiza la falla. Sirve para el primer día de desarrollo y para nada más. En cuanto haya un usuario real, necesitas un golden dataset.
  • Golden dataset 100% sintético y sin revisar. El LLM escribe preguntas con el vocabulario del chunk, tu recall sale 0.97 y estás midiendo tu capacidad de copiar y pegar. La revisión humana no es opcional: es lo que le da validez al número.
  • Optimizar la generación cuando el recall es 0.4. Es el error más caro. Semanas puliendo prompts sobre un sistema que no le está pasando el documento correcto al modelo. Mide el retrieval primero; si el chunk no llega, el prompt no importa.
  • Usar el mismo modelo como generador y como juez. Hay sesgo de auto-preferencia medido: el modelo puntúa mejor su propio estilo. Usa un juez de otra familia, o al menos verifica la correlación con humanos antes de confiar en el número.
  • No versionar el golden dataset. Si el archivo cambia entre corridas, las métricas de hoy y las de la semana pasada no son comparables y toda tu tabla de experimentos es ficción. Va en git, con nombre versionado (golden_v3.jsonl) y changelog de qué se agregó.
  • Perseguir el promedio e ignorar la cola. Un faithfulness de 0.91 puede esconder un 4% de respuestas gravemente inventadas sobre temas legales. Ordena por la peor métrica y lee los diez peores casos después de cada corrida.

La frase para llevarse

“Un RAG sin evaluación no es un sistema: es una demo con suerte. La diferencia entre los dos son cien preguntas en un archivo versionado.”

Qué viene

Módulo 11: RAG avanzado, y ahora que tienes con qué medir, por fin se puede hablar en serio de técnicas. Vamos a ver query rewriting y multi-query para arreglar las preguntas antes de buscarlas, HyDE y su intuición contraintuitiva de embeber una respuesta inventada, small-to-big y contextual retrieval para arreglar el índice, GraphRAG para preguntas que ningún chunk contiene, y agentic RAG para que el retrieval sea una decisión del modelo y no un paso fijo. Cada técnica con su costo real y su mejora esperada — y con la advertencia de que ninguna de ellas vale nada si no la pasas por el golden dataset que acabas de construir.

Material relacionado

RAGEvaluaciónRAGASMétricasCI

Preguntas sobre este curso

Regístrate gratis para hacer preguntas y guardar tu progreso.

✉ Entérate de lo nuevo

Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.