ruta rag en profundidad / módulo 1

M1 — Evaluación: medir antes de optimizar

Outcome del módulo: construir un eval harness reproducible sobre el corpus real de scholar-rag que, con un comando, te diga qué tan bueno es tu retrieval (recall@k, MRR) y tu generación (faithfulness). Al terminar tienes un número baseline que vas a intentar superar el resto del curso.

Este es el módulo palanca: sin él, ningún otro se puede medir.


Concepto 1 — El dataset gold: sin ground truth no hay métrica

Idea núcleo: una métrica compara lo que tu sistema devolvió contra lo que debería haber devuelto. Ese “debería” es el dataset gold, y hay que construirlo a mano una vez.

Entender (capa 1)

Texto: un dataset gold para RAG es una lista de preguntas donde, por cada una, anotaste cuál es el chunk o documento que la responde (para medir retrieval) y, opcionalmente, una respuesta de referencia (para medir generación). No necesitas miles: 50 a 100 preguntas bien elegidas sobre tu corpus real ya te dan señal confiable. La clave es que cubran los casos que importan: preguntas fáciles, preguntas con términos exactos (códigos, nombres), preguntas cuya respuesta está partida en dos chunks.

La tentación de junior es saltarse esto y “probar a ojo”. El problema: a ojo caben 5 preguntas y tu sesgo. Con 80 preguntas anotadas, un cambio que sube recall del 60% al 78% es un hecho, no una impresión.

Visual:

flowchart TB
    C[Corpus real: tesis Unicordoba] --> Q[Escribes 50-100 preguntas reales]
    Q --> A[Anotas el chunk correcto por pregunta]
    A --> G[(dataset gold: pregunta -> doc_id correcto)]
    G --> E[Eval harness: mide tu sistema contra esto]

Ejemplo:

# eval/dataset.py — un dataset gold es solo esto
GOLD = [
    {"q": "que metodologia uso la tesis sobre riego en el valle del sinu",
     "relevant_ids": ["thesis_a1_chunk_12"]},
    {"q": "codigo de error del sensor DHT22 mencionado en el capitulo 4",
     "relevant_ids": ["thesis_a1_chunk_47"]},   # caso de termino exacto
    {"q": "que autores citan el modelo de van genuchten",
     "relevant_ids": ["thesis_b3_chunk_3", "thesis_b3_chunk_9"]},  # respuesta partida
]

Fijar (capa 2)

Nota atómica:

Feynman: “El dataset gold es el examen con respuestas al final del libro. Sin las respuestas, corriges el examen a tu gusto y siempre apruebas.”

Aplicar (capa 3)

Escribe 30 preguntas reales sobre el corpus de scholar-rag y anota, abriendo el documento, cuál chunk las responde. Incluye al menos 5 con términos exactos y 5 cuya respuesta esté en dos chunks. Guárdalas en eval/dataset.py. Ese archivo es tu instrumento; el resto del módulo lo usa.


Concepto 2 — recall@k y MRR: medir el retrieval

Idea núcleo: el retrieval tiene dos preguntas medibles: ¿trajo el chunk correcto (recall)? y ¿lo trajo arriba (MRR)?

Entender (capa 1)

Texto: recall@k responde “de los documentos relevantes, cuántos aparecieron en el top-k”. Si para una pregunta el chunk correcto es thesis_a1_chunk_12 y tu retrieval lo trae en el top-5, recall@5 = 1.0 para esa pregunta; si no lo trae, 0.0. Promedias sobre todas las preguntas del gold.

MRR (Mean Reciprocal Rank) va más fino: no solo si apareció, sino en qué posición. Si el chunk correcto salió #1, aporta 1/1 = 1.0; si salió #4, aporta 1/4 = 0.25. Promedias sobre las preguntas. Un retrieval que trae lo correcto pero en la posición 8 tiene recall alto y MRR bajo, y eso importa: el LLM presta más atención a los primeros chunks del contexto.

Visual:

flowchart LR
    subgraph pregunta
    R[relevante: chunk_12]
    end
    T["top-5 devuelto: [c9, c12, c3, c1, c7]"] --> RC["recall@5 = 1.0 (esta)"]
    T --> MR["MRR = 1/2 = 0.5 (chunk_12 en pos 2)"]

Video: RAG Retrieval Evaluation Metrics: Recall@K, Precision@K, MRR, MAP, NDCG

Ejemplo:

# eval/metrics.py
def recall_at_k(relevant: set[str], retrieved: list[str], k: int) -> float:
    if not relevant:
        return 0.0
    return len(relevant & set(retrieved[:k])) / len(relevant)

def reciprocal_rank(relevant: set[str], retrieved: list[str]) -> float:
    for i, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / i
    return 0.0

def evaluate(gold, retrieve_fn, k: int = 5) -> dict:
    recalls, rrs = [], []
    for item in gold:
        got = retrieve_fn(item["q"])          # tu retrieval real
        rel = set(item["relevant_ids"])
        recalls.append(recall_at_k(rel, got, k))
        rrs.append(reciprocal_rank(rel, got))
    return {"recall@k": sum(recalls) / len(gold),
            "mrr": sum(rrs) / len(gold)}

Fijar (capa 2)

Nota atómica:

Feynman: “recall es ‘¿estaba el libro en el estante que revisé?’. MRR es ‘¿estaba a la altura de los ojos o tuve que agacharme hasta el último puesto?’.”

Aplicar (capa 3)

Conecta la función evaluate a la búsqueda actual de scholar-rag (solo-vector primero) y corre contra tu dataset.py. Anota recall@5 y mrr. Ese par de números es tu baseline oficial. Todo lo que hagas en M2-M4 se compara contra él.


Concepto 3 — faithfulness: medir que no alucina

Idea núcleo: una respuesta puede sonar perfecta y estar inventada. Faithfulness mide qué parte de la respuesta está de verdad soportada por el contexto recuperado.

Entender (capa 1)

Texto: el retrieval puede ser bueno y la respuesta igual mala si el LLM agrega cosas que no estaban en los chunks. Para medir faithfulness: descompones la respuesta en afirmaciones atómicas (“la tesis usó metodología X”, “el sensor es DHT22”, “lo publicaron en 2021”) y verificas, una por una, si el contexto recuperado las respalda. Faithfulness = afirmaciones soportadas / afirmaciones totales. Un valor de 1.0 significa que todo lo que dijo sale del contexto; 0.7 significa que un 30% se lo inventó o lo trajo de su memoria pre-entrenada, que en un corpus académico específico es justo lo que no quieres.

Se puede calcular con un LLM como juez (le pasas afirmación + contexto y respondes “¿soportada sí/no?”). Es lo que hace RAGAS por dentro. Vale entender el mecanismo antes de usar la librería.

Visual:

flowchart TB
    A[Respuesta del LLM] --> B[Partir en afirmaciones atomicas]
    B --> C{Cada afirmacion:\nel contexto la soporta?}
    C -->|si| S[soportada]
    C -->|no| N[inventada / alucinada]
    S --> F["faithfulness = soportadas / total"]
    N --> F

Ejemplo:

# eval/faithfulness.py — el juez es un LLM, el mecanismo es simple
async def faithfulness(answer: str, context: str, judge) -> float:
    claims = await judge.split_into_claims(answer)
    if not claims:
        return 0.0
    checks = [await judge.is_supported(c, context) for c in claims]
    return sum(checks) / len(checks)

Fijar (capa 2)

Nota atómica:

Feynman: “Recall es que el testigo correcto esté en la sala. Faithfulness es que el abogado solo repita lo que el testigo dijo, y no invente frases en su boca.”

Aplicar (capa 3)

Toma 10 preguntas del gold, corre el RAG completo de scholar-rag, y calcula faithfulness (a mano o con un LLM juez). Anota el número. Si es alto pero tu recall es bajo, tu problema es retrieval; si el recall es bueno pero faithfulness baja, tu problema es el prompt o la falta de un paso de verificación (eso es M4).


Cierre del módulo

Ya tienes el instrumento: un dataset gold y tres números —recall@k, mrr, faithfulness— que definen el estado actual de scholar-rag sin opiniones. Guárdalos. Ese es el “antes” contra el que vas a medir cada mejora del resto del curso.

Lo que puedes responder ahora que antes no: “¿qué tan bueno es tu RAG?” — con tres números y cómo los mediste. Esa frase, dicha con datos, ya te separa del 90% que dice “funciona bien”.


Siguiente: M2 — Embeddings e indexado vectorial, donde atacas el mayor cuello de latencia (el índice ANN que hoy falta) midiendo contra este baseline.