ruta rag en profundidad / módulo 4

M4 — Grounding y anti-alucinación

Outcome del módulo: entender por qué existe cada nodo del grafo LangGraph de scholar-rag (en especial grade), y probar con faithfulness que reduce alucinación, no solo que “se ve bien”.

Métrica de faithfulness definida en M1. Aquí la usas para tomar decisiones de arquitectura.


Concepto 1 — El grafo por dentro: por qué existe el nodo grade

Idea núcleo: un RAG no es “buscar y responder”, es un grafo de pasos. scholar-rag tiene retrieve → grade → generate. Entender por qué está grade (y qué pasa si lo quitas) es la diferencia entre usar LangGraph y comprenderlo.

Entender (capa 1)

Texto: el retrieval trae los chunks más cercanos, pero “más cercano” no es “relevante para responder”. Puede traer chunks temáticamente parecidos que no contienen la respuesta. Si se los pasas al LLM tal cual, dos cosas malas pasan: gastan espacio de contexto, y peor, invitan al modelo a “responder con lo que hay” aunque no sirva, que es como nacen muchas alucinaciones. El nodo grade es un filtro entre retrieve y generate: evalúa cada chunk recuperado y descarta los que no son relevantes, o marca que ninguno lo es (para responder “no encontré” en vez de inventar).

La prueba de que entiendes el grafo no es dibujarlo, es poder decir qué pasa si quitas un nodo. Quitar grade debería subir la alucinación (más contexto irrelevante llega al generador). Si lo quitas y la faithfulness no cambia, entonces grade no estaba aportando y sobra complejidad. Cualquiera de las dos conclusiones, medida, es profundidad.

Visual:

flowchart LR
    Q[pregunta] --> RE[retrieve: top-k por cercania]
    RE --> GR{grade: cada chunk es relevante?}
    GR -->|si| GE[generate: responde con lo filtrado]
    GR -->|ninguno| NF[responde: no encontre en el corpus]
    GE --> A[respuesta + citas]

Ejemplo:

# services/rag_graph.py — la idea del nodo grade
async def grade_node(state: RagState) -> RagState:
    kept = []
    for chunk in state["contexts"]:
        verdict = await judge.is_relevant(state["question"], chunk)  # si/no
        if verdict:
            kept.append(chunk)
    state["contexts"] = kept
    state["grounded"] = len(kept) > 0     # si nada pasó, no inventes
    return state

Fijar (capa 2)

Nota atómica:

Feynman: “El nodo grade es el editor entre el investigador y el escritor. El investigador trae 10 documentos ‘parecidos’; el editor tacha los que no responden la pregunta antes de que el escritor los use. Sin editor, el escritor mete relleno y a veces se lo inventa.”

Aplicar (capa 3)

Localiza el nodo grade en services/rag_graph.py. Corre 10 preguntas del gold con grade activo y mide faithfulness (M1). Luego desactívalo y repite. Anota los dos números: ese delta es la justificación medida de por qué el nodo existe.


Concepto 2 — Structured output: no confíes en el dict del LLM

Idea núcleo: el LLM devuelve texto/JSON que casi siempre tiene la forma que esperas. “Casi siempre” es una bomba. Validar la salida con un schema antes de usarla convierte un crash aleatorio en un error controlado.

Entender (capa 1)

Texto: hoy qa_service accede al resultado del grafo como un dict crudo (result["answer"], result["grounded"]). Funciona hasta que el LLM devuelve algo con otra forma —una clave que falta, un tipo distinto, JSON malformado— y ahí salta un KeyError en producción, en el peor momento. La disciplina senior: definir un schema (con Pydantic) que describe exactamente qué esperas, y parsear la salida contra él. Si el modelo se sale del schema, lo atrapas ahí, con un mensaje claro, y decides qué hacer (reintentar, degradar, responder error), en vez de que reviente tres capas más abajo.

Esto conecta con el patrón general del curso: todo tercero es hostil, y el LLM es el más impredecible de todos. No es paranoia, es que su salida es probabilística por diseño.

Visual:

flowchart LR
    L[salida del LLM] --> P{parsea contra schema Pydantic}
    P -->|valida| U[usar con seguridad]
    P -->|no valida| E[error controlado: reintentar / degradar]

Ejemplo:

from pydantic import BaseModel, ValidationError

class RagAnswer(BaseModel):
    answer: str
    grounded: bool
    citations: list[str] = []

# en vez de result["answer"] crudo:
try:
    parsed = RagAnswer.model_validate(raw_result)   # falla ruidoso si no cumple
except ValidationError as e:
    logger.warning("LLM output off-schema", error=str(e))
    parsed = RagAnswer(answer="No pude generar una respuesta confiable.",
                       grounded=False)

Fijar (capa 2)

Nota atómica:

Feynman: “Confiar en el dict del LLM es firmar un contrato sin leerlo porque ‘las otras veces estaba bien’. El schema es leer el contrato cada vez: si cambió una cláusula, te enteras al firmar, no cuando te demandan.”

Aplicar (capa 3)

Define un RagAnswer Pydantic en scholar-rag y haz que qa_service valide contra él en vez de acceder al dict crudo. Fuerza un caso donde el LLM devuelva algo raro (o simúlalo) y comprueba que ahora falla controlado, no con KeyError.


Concepto 3 — Citas que de verdad anclan

Idea núcleo: una cita no es un adorno al final; es la promesa de que esa afirmación sale de esa fuente. Si la cita no apunta al chunk que respalda la frase, es decorado, y decorado que miente es peor que nada.

Entender (capa 1)

Texto: en un RAG académico, la cita es el producto. El usuario confía porque puede ir a la tesis y verificar. Para que la cita ancle de verdad, cada afirmación de la respuesta debe poder rastrearse al chunk que la sostiene. Esto empuja el diseño: el generador no solo produce texto, produce texto con referencias a los IDs de chunk que usó, y tú verificas que esos chunks realmente contienen lo afirmado (eso es faithfulness a nivel de cita). Una respuesta con tres afirmaciones y una sola cita genérica al final no es citable: no sabes cuál frase respalda qué.

El anti-patrón común: pedirle al LLM que “agregue citas” como formato, sin verificar. El modelo, servicial, inventa citas plausibles. Citas alucinadas son el peor de los mundos: dan confianza falsa.

Visual:

flowchart TB
    A[afirmacion 1] --> C1[chunk_12: la contiene? verificar]
    B[afirmacion 2] --> C2[chunk_47: la contiene? verificar]
    C1 -->|si| OK1[cita valida]
    C2 -->|no| BAD[cita alucinada: descartar]

Fijar (capa 2)

Nota atómica:

Feynman: “Una cita es decir ‘esto lo dijo el testigo en la página 40’. Si mandas a alguien a la página 40 y no dice eso, mentiste con formato de verdad. Peor que no citar, porque parecías riguroso.”

Aplicar (capa 3)

Toma 5 respuestas de scholar-rag con sus citas y verifica a mano: ¿el chunk citado contiene la afirmación? Cuenta cuántas citas son válidas. Ese porcentaje es tu “citation accuracy” actual, y es parte de la faithfulness que reportas en el capstone.


Concepto 4 — Faithfulness como criterio de decisión

Idea núcleo: faithfulness (M1) no es solo un número para el reporte; es la métrica con la que decides si un cambio en la generación sirve. Cierra el ciclo del módulo.

Entender (capa 1)

Texto: ya tienes las piezas: el nodo grade filtra, el schema valida, las citas anclan. La pregunta es cuánto suma cada una. Faithfulness es el juez: para cada cambio (activar grade, endurecer el prompt, agregar verificación de citas), mides faithfulness antes y después sobre el mismo gold. Si sube, el cambio entra; si no, sale. Esto te salva de la trampa clásica: cambios que “se sienten mejor” pero no mueven la aguja, o peor, la bajan. La generación es el área más tentada por la intuición (“este prompt suena más seguro”); la faithfulness la vuelve medible.

Es el mismo principio de M1, aplicado a la mitad de generación en vez de la de retrieval: no optimices lo que no mides.

Visual:

flowchart LR
    B["faithfulness base"] --> C1[+ grade]
    C1 --> C2[+ schema validado]
    C2 --> C3[+ verificacion de citas]
    C3 --> F["faithfulness final: cada paso, medido"]

Ejemplo:

# el ciclo de decision de la generacion
base = mean_faithfulness(gold, rag_sin_grade)
con_grade = mean_faithfulness(gold, rag_con_grade)
print("grade aporta:", con_grade - base)   # si > 0, el nodo se queda

Fijar (capa 2)

Nota atómica:

Feynman: “Faithfulness es el detector de mentiras del sistema. Cada vez que cambias cómo responde, lo pasas por el detector. No preguntas ‘¿suena mejor?’, preguntas ‘¿miente menos?’, y eso tiene número.”

Aplicar (capa 3)

Construye la tabla de faithfulness por etapa (base → +grade → +schema → +citas verificadas) sobre scholar-rag. Esa progresión es la prueba, para el capstone, de que reduces alucinación con ingeniería medida y no con fe.


Cierre del módulo

Ya no “usas LangGraph”: entiendes por qué existe cada nodo, validas lo que el LLM devuelve, y tus citas anclan de verdad, todo probado con faithfulness antes/después. Pasaste del retrieval a una respuesta confiable y medible.

Lo que puedes responder ahora: “¿tu sistema alucina, cuánto, y qué lo evita?” — con la faithfulness con y sin cada pieza.


Siguiente: M5 — Async, concurrencia y sistemas, donde bajas al nivel de cómo scholar-rag aguanta (o no) muchas peticiones a la vez.