Al final de 04-04 dejamos tres preguntas incómodas sin responder: ¿recupera el pipeline los chunks correctos?, ¿el umbral de 0.45 es sabio o casualidad?, ¿las respuestas son fieles a lo recuperado? En 02-04 aprendimos a evaluar prompts con un conjunto de 30-50 preguntas, una rúbrica binaria y un CSV — pero un sistema RAG añade una dificultad nueva: tiene dos motores que pueden fallar por separado. Si DocuBot responde mal, ¿fue porque la recuperación trajo chunks equivocados o porque la generación ignoró unos chunks correctos? Sin separar las dos preguntas, cada respuesta mala es un misterio y cada "mejora" es una apuesta a ciegas. Esta lección extiende nuestra infraestructura de evaluación al mundo RAG: anotaremos qué chunks debería recuperar cada pregunta del conjunto, implementaremos las métricas clásicas de recuperación (recall@k, precision@k, MRR) en Python llano, extenderemos la rúbrica de 02-04 con los criterios específicos de la generación con contexto, y montaremos la tabla de diagnóstico y los experimentos (k, tamaño de chunk, solape) que convierten las decisiones de diseño de 04-03 y 04-04 en decisiones con datos.

Contenido

  1. Dos motores, dos evaluaciones
  2. Ampliar el conjunto de 02-04: anotar los chunks esperados
  3. Métricas de recuperación: recall@k, precision@k y MRR
  4. Métricas de generación en RAG: la rúbrica extendida
  5. Diagnóstico por componentes: la tabla de síntomas
  6. Experimentos: k, tamaño de chunk y solape

Dos motores, dos evaluaciones

Sigamos una respuesta mala hasta su origen. Un usuario pregunta "¿cuántos miembros admite el plan Pro?" y DocuBot responde algo vago sobre invitaciones. Hay dos historias posibles:

  • Falló la recuperación: el top-4 no incluía el chunk de "Planes y facturación" con el límite de 25 miembros. El LLM hizo lo correcto con lo que tenía — el problema está en el chunking, el modelo de embeddings, k o los filtros.
  • Falló la generación: el chunk correcto estaba en el bloque <documentacion>, pero el modelo lo ignoró, lo malinterpretó o lo adornó. El problema está en el prompt, el modelo o el formato del bloque.

Las curas son disjuntas: rehacer el chunking no arregla un problema de prompt, y afinar el prompt no arregla un top-k que no trae la respuesta. De ahí el principio de esta lección:

Evalúa la recuperación sin el LLM, y la generación con recuperación conocida. Solo después evalúa el conjunto de punta a punta.

La buena noticia es que la mitad del trabajo ya está hecho: el conjunto de 02-04 (30-50 preguntas con sus 4 tipos de casos), la rúbrica binaria, el registro CSV y la disciplina de 3 ejecuciones por caso siguen siendo válidos. Solo hay que extenderlos.

Ampliar el conjunto de 02-04: anotar los chunks esperados

Para medir la recuperación necesitamos la verdad terreno: para cada pregunta, qué chunks del corpus contienen su respuesta. Es trabajo manual y es la inversión más rentable de la lección — se hace una vez y sostiene todos los experimentos posteriores.

El proceso práctico: para cada pregunta del conjunto, buscar (a mano, leyendo la doc, ayudándose de recuperar() como sugerencia pero verificando a ojo) los ids de los chunks que responden. Los ids deterministas de 04-03 hacen esto posible: son estables entre re-ingestas.

# conjunto_rag.py — el conjunto de 02-04, extendido con anotación de recuperación
CONJUNTO_RAG = [
    {
        "id": "caso-01",
        "pregunta": "¿Cómo restablezco mi contraseña?",
        "tipo": "facil",                     # los 4 tipos de 02-04 se conservan
        "chunks_esperados": ["wiki-administracion-seguridad-y-acceso--restablecer-contrasena--00"],
    },
    {
        "id": "caso-07",
        "pregunta": "No puedo entrar en mi cuenta",
        "tipo": "ambiguo",                   # puede ser contraseña olvidada O cuenta bloqueada
        "chunks_esperados": [
            "wiki-administracion-seguridad-y-acceso--restablecer-contrasena--00",
            "wiki-administracion-seguridad-y-acceso--bloqueo-de-cuenta--00",
        ],
    },
    {
        "id": "caso-19",
        "pregunta": "¿Puedo pagar con criptomonedas?",
        "tipo": "sin_respuesta",
        "chunks_esperados": [],              # lo correcto es NO recuperar nada relevante
    },
    # ... resto del conjunto (30-50 casos)
]

Tres notas de anotación que evitan disgustos:

  • Los casos "sin respuesta" (~20% del conjunto) llevan lista vacía — y son los que calibran el UMBRAL_RELEVANCIA de 04-04: en ellos, la similitud del mejor chunk debería quedar por debajo del umbral.
  • Los ambiguos suelen tener varios chunks esperados: cualquier subconjunto razonable puede bastar para responder; las métricas lo tendrán en cuenta.
  • La anotación caduca con el chunking: si cambias chunking.py (y por tanto los ids), hay que revisar la anotación. Otro motivo para versionar la estrategia de chunking como hicimos con PROMPT_VERSION.

Métricas de recuperación: recall@k, precision@k y MRR

Con la verdad terreno anotada, evaluar la recuperación no necesita LLM: se ejecuta recuperar() y se comparan ids. Las tres métricas estándar, cada una respondiendo una pregunta distinta:

Métrica Pregunta que responde Rango Cuándo preocuparse
recall@k De los chunks que debían salir, ¿qué fracción salió en el top-k? 0–1 Es LA métrica de RAG: si el chunk no sale, el LLM no puede usarlo. Bajo recall = techo duro de calidad
precision@k De los k chunks que salieron, ¿qué fracción era relevante? 0–1 Baja precisión = ruido en el contexto: coste extra y distracción del modelo
MRR ¿Cómo de arriba sale el primer chunk relevante? (media de 1/posición) 0–1 Importa porque ordenamos "mejor primero" (04-04): MRR alto = lo bueno llega en cabeza

Implementación en Python simple, sin dependencias:

# eval_rag.py — métricas de recuperación sobre el conjunto anotado
from rag import recuperar
from conjunto_rag import CONJUNTO_RAG

def recall_at_k(esperados: list[str], recuperados: list[str]) -> float:
    """Fracción de los chunks esperados que aparecen entre los recuperados."""
    if not esperados:
        return 1.0 if not recuperados else 1.0  # los casos sin respuesta se miden aparte
    encontrados = sum(1 for e in esperados if e in recuperados)
    return encontrados / len(esperados)

def precision_at_k(esperados: list[str], recuperados: list[str]) -> float:
    """Fracción de los chunks recuperados que eran esperados."""
    if not recuperados:
        return 0.0
    relevantes = sum(1 for r in recuperados if r in esperados)
    return relevantes / len(recuperados)

def reciprocal_rank(esperados: list[str], recuperados: list[str]) -> float:
    """1/posición del primer chunk relevante (0 si no aparece ninguno)."""
    for posicion, r in enumerate(recuperados, start=1):
        if r in esperados:
            return 1.0 / posicion
    return 0.0

def evaluar_recuperacion(k: int = 4) -> dict:
    recalls, precisiones, rrs = [], [], []
    for caso in CONJUNTO_RAG:
        if not caso["chunks_esperados"]:
            continue                      # los 'sin_respuesta' se evalúan con el umbral
        chunks = recuperar(caso["pregunta"], k=k)
        ids = [c["id"] for c in chunks]   # requiere exponer el id en recuperar()
        recalls.append(recall_at_k(caso["chunks_esperados"], ids))
        precisiones.append(precision_at_k(caso["chunks_esperados"], ids))
        rrs.append(reciprocal_rank(caso["chunks_esperados"], ids))
    n = len(recalls)
    return {
        "k": k,
        "recall@k": sum(recalls) / n,
        "precision@k": sum(precisiones) / n,
        "MRR": sum(rrs) / n,
        "casos": n,
    }

if __name__ == "__main__":
    print(evaluar_recuperacion(k=4))
    # {'k': 4, 'recall@k': 0.91, 'precision@k': 0.47, 'MRR': 0.86, 'casos': 32}

Cómo leer esos números de ejemplo:

  • recall@4 = 0.91: en el 91% de los casos (ponderado por chunks) lo necesario llegó al contexto. Los casos fallidos son la lista de trabajo: mirarlos uno a uno suele revelar patrones (¿todos de la misma página? ¿todos con vocabulario de usuario muy alejado de la doc?).
  • precision@4 = 0.47 parece bajo, pero es normal: si una pregunta necesita 1 chunk y recuperamos 4, la precisión máxima es 0.25. La precisión se compara entre configuraciones (¿sube al bajar k?), no contra un ideal de 1.0.
  • MRR = 0.86: el primer chunk relevante suele salir 1º o 2º — bien para nuestro orden "mejor primero".
  • Y los casos sin respuesta tienen su propia medición: qué fracción de ellos queda por debajo del UMBRAL_RELEVANCIA (deberían: todos) frente a qué fracción de los casos fáciles queda por encima (deberían: todos). Esas dos cifras son la calibración del umbral que 04-04 dejó pendiente.

Métricas de generación en RAG: la rúbrica extendida

Supongamos recall alto: lo correcto llega al contexto. Falta evaluar qué hace el modelo con ello. Los tres criterios específicos de la generación con contexto recuperado:

  1. Fidelidad al contexto (groundedness): ¿todo lo afirmado en la respuesta está respaldado por los chunks del bloque <documentacion>? Una respuesta puede ser verdadera y aun así infiel (el modelo lo sabía "de memoria" — inaceptable para DocuBot, regla 2 de SYSTEM_DOCUBOT).
  2. Relevancia de la respuesta: ¿responde a esta pregunta, o resume correctamente los chunks sin contestar lo que se preguntó?
  3. Uso correcto de fuentes: ¿el campo fuentes apunta a los chunks realmente usados y la respuesta no atribuye a una fuente algo que no dice? (Desde 04-04 las URLs las pone el sistema, así que aquí se vigila la atribución del contenido, no la existencia de la URL.)

En lugar de inventar una rúbrica nueva, extendemos la de 02-04: a sus 6 criterios binarios se añaden estos 3, y la evaluación sigue el mismo protocolo — juicio humano, binario (cumple/no cumple, sin medias tintas), 3 ejecuciones por caso por el no determinismo (01-04), registro en CSV:

# Criterio Origen
1–6 Los 6 criterios de 02-04 (formato JSON válido, categoría correcta, tono, frase anti-alucinación cuando toca, etc.) 02-04
7 Fiel al contexto: nada afirmado sin respaldo en los chunks nuevo (RAG)
8 Relevante: responde la pregunta formulada nuevo (RAG)
9 Fuentes bien usadas: atribución correcta del contenido nuevo (RAG)

Truco de evaluación que ahorra horas: como el pipeline registra qué chunks recuperó (el consejo final de 04-04), el evaluador humano puede juzgar la fidelidad leyendo solo esos chunks, no toda la documentación. Evaluar el criterio 7 de un caso pasa de minutos a segundos.

Este proceso es manual y no escala a cientos de casos — lo sabemos. En 06-03 veremos cómo automatizarlo usando un LLM como juez (LLM-as-judge) con esta misma rúbrica como base; la versión manual de hoy no es trabajo desechable sino el patrón oro contra el que se validará ese juez automático.

Diagnóstico por componentes: la tabla de síntomas

Con métricas de recuperación y de generación separadas, cada patología apunta a su componente. Esta tabla es la herramienta de trabajo diario:

Síntoma (métricas) Diagnóstico probable Dónde actuar
recall@k bajo, generación fiel La recuperación no trae lo necesario Chunking (04-03): ¿secciones mal cortadas?; k mayor; revisar modelo de embeddings; ¿filtros demasiado agresivos (04-02)?
recall@k alto, criterio 7 falla El contexto llega pero el modelo no se ciñe Prompt (02-01/02-02): reforzar la regla 2; ¿bloque <documentacion> confuso?; ¿modelo demasiado "creativo"? (temperature, 01-02)
recall@k alto, criterio 8 falla Recupera y resume, pero no contesta prompt_respuesta(): pedir respuesta directa; ¿chunks relevantes pero tangenciales? — revisar anotación
MRR bajo con recall aceptable Lo bueno llega, pero al final del top-k Orden y k (04-04); considerar reranking (mencionado en 04-04) si persiste
precision@k muy baja + coste alto Mucho ruido en el contexto Bajar k; subir umbral; mejorar chunking (chunks más enfocados)
Casos sin_respuesta responden con contenido Umbral demasiado bajo (o chunks "parecidos" engañosos) Calibrar UMBRAL_RELEVANCIA con las dos cifras del apartado 3
Casos fáciles reciben la frase anti-alucinación Umbral demasiado alto, o recall roto para esos casos Ídem, mirando primero el recall de esos casos concretos

La disciplina que la tabla impone: ante una respuesta mala, mira primero las métricas de recuperación del caso. Es un vistazo al CSV (¿estaban los chunks esperados en el top-k?) y bifurca el diagnóstico en dos caminos que no se mezclan. Sin esta disciplina, el instinto es siempre "tocar el prompt" — que solo cura la mitad de las enfermedades.

Experimentos: k, tamaño de chunk y solape

Las decisiones de 04-03 y 04-04 (chunks de ~450 tokens, solape de 300 caracteres, k=4, umbral 0.45) fueron puntos de partida razonados. Ahora pueden ser conclusiones. El método experimental es el de 02-04 — cambiar una variable cada vez, medir sobre el conjunto completo, registrar en CSV:

# experimentos_rag.py — barrido de k con registro CSV (patrón de 02-04)
import csv
from datetime import date
from eval_rag import evaluar_recuperacion
from prompts import PROMPT_VERSION

CHUNKING_VERSION = "encabezados-v1-1800-300"   # tamaño y solape actuales (04-03)

with open("experimentos_rag.csv", "a", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    # fecha, versiones, variable, y las tres métricas
    for k in (2, 3, 4, 6, 8):
        m = evaluar_recuperacion(k=k)
        w.writerow([date.today(), PROMPT_VERSION, CHUNKING_VERSION, f"k={k}",
                    round(m["recall@k"], 3), round(m["precision@k"], 3), round(m["MRR"], 3)])

Resultados típicos de un barrido de k (tus números variarán):

k recall@k precision@k Lectura
2 0.72 0.61 Barato pero se pierde el 28% de lo necesario
4 0.91 0.47 El codo: casi todo el recall a coste razonable
6 0.94 0.35 +3 puntos de recall a cambio de +50% de contexto (y coste, 03-04)
8 0.95 0.28 Rendimientos decrecientes claros

El patrón es casi universal: el recall crece con k pero se aplana (el "codo"), la precisión cae monótonamente, y el coste por pregunta crece linealmente con k. La decisión es un compromiso informado — y con esta tabla, k=4 deja de ser "lo que dijo el curso" para ser "el codo de nuestra curva".

Los otros dos experimentos canónicos siguen la misma mecánica, con una diferencia operativa importante:

  • Tamaño de chunk (TAM_MAX: probar p. ej. 1000 / 1800 / 3000) y con/sin solape (SOLAPE: 0 / 300): cada variación exige re-ejecutar ingesta.py (nuevo troceo → nuevos embeddings → re-poblar la colección) y, si cambian los ids, revisar la anotación. Son experimentos caros — por eso se hacen menos a menudo y se registran con CHUNKING_VERSION, para no repetirlos por olvido.
  • La variable de generación (prompt, temperature) se experimenta con la rúbrica extendida, no con recall/precision — cada motor con sus métricas.

Errores Comunes y Consejos

  • Evaluar solo de punta a punta. "La respuesta es mala" sin saber si falló recuperación o generación lleva a arreglar el componente equivocado. Las métricas separadas existen para bifurcar el diagnóstico.
  • Optimizar precision@k en abstracto. Con 1 chunk relevante y k=4, la precisión máxima es 0.25 y no significa que nada vaya mal. Compárala entre configuraciones; el recall es quien manda en RAG.
  • Anotar los chunks esperados usando solo recuperar() como oráculo. Si anotas lo que el sistema ya devuelve, medirás que el sistema devuelve lo que devuelve: recall de 1.0 garantizado y falso. La anotación exige leer la documentación.
  • Cambiar dos variables a la vez (k y el prompt, o chunk y umbral): la mejora —o el empeoramiento— quedará sin causa asignable. Una variable, una medición, una fila de CSV. La lección la aprendimos en 02-04 y en RAG duele el doble porque hay el doble de mandos.
  • Olvidar los casos sin_respuesta al evaluar. Un sistema que responde algo al 100% de las preguntas tiene métricas de recuperación estupendas y un problema grave de honestidad. Las dos cifras de calibración del umbral son parte del cuadro de mando, no un extra.
  • Consejo: fija umbrales de alarma sobre el conjunto (p. ej. "recall@4 ≥ 0.85 y cero casos sin_respuesta respondidos con contenido") y re-ejecuta la evaluación tras cada re-ingesta o cambio de versión. Es la versión RAG del presupuesto con alerta de 03-04 — y en 06-04 veremos cómo vigilar estas señales en producción de forma continua.

Ejercicios

  1. Completa la medición del umbral. Escribe evaluar_umbral(umbral) que recorra el conjunto y devuelva dos cifras: fracción de casos sin_respuesta correctamente rechazados (mejor chunk por debajo del umbral) y fracción de casos facil correctamente aceptados (por encima). Ejecútala para umbrales 0.30, 0.45 y 0.60 y elige con datos.
  2. MRR a mano. Sin ejecutar código: tres preguntas cuyos chunks relevantes aparecen en las posiciones 1, 3 y (no aparece) del top-4. Calcula el MRR y explica qué acción de la tabla de diagnóstico dispararías si ese fuera tu valor global.
  3. El caso que engaña. La pregunta "¿cuántas tareas admite el plan Pro?" obtiene: recall@4 = 1.0 (el chunk de límites está en el top-4, posición 2) pero la respuesta dice "hasta 500 tareas y 50 miembros" cuando la doc dice 25 miembros. ¿Qué criterios de la rúbrica fallan, cuál es el diagnóstico según la tabla, y qué dos acciones concretas probarías primero?

Soluciones

  1. Estructura de la función:
def evaluar_umbral(umbral: float, k: int = 4) -> dict:
    rechazados_ok, aceptados_ok, n_sin, n_facil = 0, 0, 0, 0
    for caso in CONJUNTO_RAG:
        mejor = 0.0
        chunks = recuperar(caso["pregunta"], k=k)
        if chunks:
            mejor = chunks[0]["similitud"]
        if caso["tipo"] == "sin_respuesta":
            n_sin += 1
            rechazados_ok += (mejor < umbral)
        elif caso["tipo"] == "facil":
            n_facil += 1
            aceptados_ok += (mejor >= umbral)
    return {"umbral": umbral,
            "sin_respuesta_rechazados": rechazados_ok / n_sin,
            "faciles_aceptados": aceptados_ok / n_facil}

Resultado típico: con 0.30 se aceptan todos los fáciles pero se "cuelan" varios sin_respuesta; con 0.60 se rechaza todo lo dudoso pero empiezan a caer fáciles legítimos; 0.45 suele dejar ambas cifras altas. Si ningún umbral separa bien ambas poblaciones, el problema no es el umbral: es que hay chunks engañosamente parecidos a preguntas sin respuesta — vuelve al chunking o añade a la doc una página explícita ("Métodos de pago admitidos") que dé al sistema algo correcto que recuperar.

  1. MRR = (1/1 + 1/3 + 0) / 3 = 0.444. Es bajo, pero la tabla exige mirar el porqué antes de actuar: el tercer caso no es un problema de orden sino de recall (el chunk no aparece en absoluto), así que la acción correcta empieza en la fila "recall@k bajo" (chunking, k, embeddings) para ese caso; el segundo caso (posición 3) sí es de orden y apuntaría a reranking solo si el patrón se repite. Moraleja: MRR nunca se diagnostica solo — siempre junto al recall.

  2. Fallan el criterio 7 (fidelidad: "50 miembros" no está respaldado por el chunk, que dice 25) y posiblemente el 9 (atribuye a la fuente algo que no dice); el 8 se cumple (responde la pregunta). Con recall perfecto y fidelidad rota, la tabla señala generación: el contexto llegó y el modelo lo alteró. Dos primeras acciones: (a) reforzar en prompt_respuesta() la instrucción de copiar cifras y límites literalmente de la documentación (y re-evaluar sobre el conjunto completo, no solo este caso — regla de 02-04); (b) comprobar la temperature de la llamada (decisión de 01-03: baja para DocuBot) por si algún cambio la subió. Si persistiera, mirar si el chunk mezcla los límites de varios planes en una tabla que el modelo lee mal — lo que devolvería el problema, curiosamente, al chunking: los diagnósticos son probables, no infalibles, y por eso se registra todo.

Conclusión

Con esta lección, el módulo 4 queda completo — y DocuBot deja de ser un pipeline que funciona en las demos para ser un sistema RAG que sabemos medir y diagnosticar: el conjunto de 02-04 tiene ahora anotados los chunks esperados de cada pregunta (con los casos sin_respuesta como calibradores del umbral), eval_rag.py mide la recuperación sin gastar un token de LLM (recall@k como métrica reina, precision@k y MRR como acompañantes que se leen juntas), la rúbrica de 6 criterios creció a 9 con la fidelidad al contexto, la relevancia y el uso de fuentes, la tabla de síntomas bifurca cada respuesta mala hacia su verdadero culpable —recuperación o generación, cada uno con sus curas—, y los experimentos con registro CSV convirtieron k=4, el tamaño de chunk y el solape de valores heredados en el codo medido de nuestra propia curva. Mirando atrás, el módulo 4 cumplió la promesa que cerraba el módulo 3: los embeddings pasaron de intuición a herramienta (04-01, con el caché semántico de regalo), la colección docs_nubelia dio memoria persistente y filtrable a la búsqueda (04-02), ingesta.py convirtió cientos de páginas en chunks con oficio y fuentes trazables (04-03), y responder_con_rag() llenó por fin, automáticamente, aquel bloque <documentacion> que el SYSTEM_DOCUBOT exigía desde 02-01 — enterrando al DocuBot ingenuo que inventaba exportaciones a PDF. Pero fíjate en el verbo que define todo lo construido: DocuBot sabe. Sabe lo que dice la documentación y lo cuenta con fuentes. Lo que todavía no puede es hacer: si un usuario pregunta "¿cuántas tareas tiene ahora mismo mi proyecto Atlas?", la respuesta no está en ninguna wiki — está en la API de Nubelia, a una llamada de distancia que DocuBot no puede realizar. Darle manos además de memoria es el salto del módulo 5: empezando por el function calling (05-01) — el mecanismo por el que el LLM deja de solo redactar respuestas y empieza a invocar nuestras funciones —, y siguiendo hasta convertir a DocuBot en un agente que razona, actúa y observa en bucle. La documentación ya la domina; ahora va a aprender a usar el producto.

© Copyright 2026. Todos los derechos reservados