ruta rag en profundidad / módulo 3

M3 — Chunking, retrieval híbrido y RRF

Outcome del módulo: justificar con datos las decisiones de recuperación de scholar-rag que hoy están fijas sin razón (chunk 1000/150, fusión RRF con k=60), y subir el recall sin tocar el índice combinando búsqueda semántica y léxica. Todo medido contra el baseline de M1.

Complementa el módulo A2 del curso AI/Agentes, que introduce recall/precision y reranking. Aquí lo llevamos a medición sobre tu corpus real.


Concepto 1 — Chunking: la decisión que más mueve el recall

Idea núcleo: cómo partes los documentos determina qué puede encontrar el sistema. Un chunk mal cortado hace imposible recuperar la respuesta, por bueno que sea el índice.

Entender (capa 1)

Texto: scholar-rag parte en trozos fijos de 1000 caracteres con 150 de solape (corpus.py:16). Es el default más común y también el menos justificado. Tres estrategias compiten:

Dos perillas dentro de cualquier estrategia: el tamaño (grande = más contexto por chunk pero mezcla temas y baja precisión; chico = preciso pero pierde contexto) y el solape (repetir un pedazo entre chunks contiguos evita que una idea a caballo entre dos se pierda). No hay valor mágico: hay el valor que, medido sobre tu gold, da más recall.

Visual:

flowchart TB
    D[tesis completa] --> F["fijo 1000 chars: corta a mitad de idea"]
    D --> S["semantico: corta en fronteras de seccion"]
    F --> P1["respuesta partida en 2 chunks - recall baja"]
    S --> P2["respuesta completa en 1 chunk - recall sube"]

Ejemplo:

# corpus.py actual: fijo por caracteres
def chunk_text(text: str, size: int = 1000, overlap: int = 150) -> list[str]:
    ...

# el experimento: parametriza y mide, no cambies a ciegas
for size, overlap in [(500, 75), (1000, 150), (1500, 200)]:
    reindex(chunk_text(text, size, overlap))
    print(size, overlap, evaluate(gold, retrieve, k=5))   # recall@k de M1

Fijar (capa 2)

Nota atómica:

Feynman: “Chunking es cómo cortas un libro en fichas. Si cortas a mitad de la receta, ninguna ficha sirve para cocinar aunque tengas el fichero perfecto. El corte decide qué es recuperable.”

Aplicar (capa 3)

Corre el bucle del ejemplo con tres tamaños de chunk sobre scholar-rag y anota recall@5 de cada uno contra tu gold. Vas a ver que el número se mueve solo con esto. Quédate con el mejor para los siguientes conceptos.


Concepto 2 — Ablación: cuánto aporta de verdad cada búsqueda

Idea núcleo: “ablación” es apagar una pieza y medir cuánto cae el resultado, para saber cuánto aportaba. Es cómo pruebas que el híbrido vale la pena y no es solo complejidad.

Entender (capa 1)

Texto: scholar-rag ya hace búsqueda híbrida (vector + full-text en español, fusionados por RRF). Pero, ¿cuánto aporta cada mitad? La forma de saberlo es la ablación: mides recall@k en tres configuraciones — solo-vector, solo-léxico, e híbrido — sobre el mismo gold. Los resultados típicos en un corpus técnico:

Si el híbrido no le gana claramente a solo-vector, quizás tu corpus no tiene muchos términos exactos y la complejidad extra no se justifica. Esa conclusión —positiva o negativa— es ingeniería; asumir sin medir, no.

Visual:

flowchart LR
    G[dataset gold] --> V[solo-vector: recall 0.62]
    G --> L[solo-lexico: recall 0.58]
    G --> H[hibrido RRF: recall 0.79]
    V --> C{cuanto aporta cada uno?}
    L --> C
    H --> C

(números ilustrativos: los reales los mides tú)

Ejemplo:

# tres retrieve_fn distintas, mismo evaluate() de M1
print("vector", evaluate(gold, retrieve_vector_only, k=5))
print("lexico", evaluate(gold, retrieve_lexical_only, k=5))
print("hibrido", evaluate(gold, retrieve_hybrid, k=5))

Fijar (capa 2)

Nota atómica:

Feynman: “Ablación es quitarle un ingrediente a la receta y probar. Si sin sal sabe igual, la sal no aportaba. Si sin ella queda soso, ya sabes cuánto valía. Igual con vector y léxico.”

Aplicar (capa 3)

Mide las tres configuraciones sobre scholar-rag y arma la tabla de ablación. Escribe una línea con la conclusión: “el léxico aporta +X de recall en preguntas con términos exactos”. Esa es la respuesta a la pregunta 4 de las cinco, con evidencia.


Concepto 3 — RRF: fusionar rankings sin comparar puntajes

Idea núcleo: la búsqueda vectorial y la léxica dan puntajes en escalas incomparables. RRF las une usando solo la posición de cada resultado, no su puntaje. Simple y sorprendentemente robusto.

Entender (capa 1)

Texto: el problema al fusionar: la distancia coseno vive en un rango y el ts_rank de Postgres en otro; sumarlos directo no tiene sentido. RRF (Reciprocal Rank Fusion) esquiva eso: ignora los puntajes y usa el ranking. A cada documento le da, en cada lista, un aporte de 1 / (k + posición), y suma los aportes de ambas listas. Un documento que sale #1 en vector y #3 en léxico acumula 1/(60+1) + 1/(60+3). El que aparece alto en ambas listas gana; el que aparece en una sola, pero bien arriba, también compite.

La constante k (60 por convención, y el rrf_k=60 de scholar-rag) amortigua el peso de las primeras posiciones: con k grande, la diferencia entre el puesto 1 y el 2 es suave; con k chico, el puesto 1 domina. 60 es un default probado, pero es medible: barre k y observa si tu recall cambia.

Visual:

flowchart TB
    subgraph vector
    A1["#1 doc_A"] --> A2["#2 doc_B"]
    end
    subgraph lexico
    B1["#1 doc_B"] --> B2["#2 doc_C"]
    end
    A1 --> F["RRF: score = suma de 1/(k+pos)"]
    A2 --> F
    B1 --> F
    B2 --> F
    F --> R["doc_B gana: alto en ambas listas"]

Ejemplo:

def rrf(rankings: list[list[str]], k: int = 60, top: int = 5) -> list[str]:
    scores: dict[str, float] = {}
    for ranking in rankings:                 # una lista por cada buscador
        for pos, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + pos)
    return sorted(scores, key=scores.get, reverse=True)[:top]

# fusiona el ranking vectorial y el lexico sin tocar sus puntajes
final = rrf([vector_ids, lexical_ids], k=60)

Fijar (capa 2)

Nota atómica:

Feynman: “RRF es juntar dos rankings de películas de dos críticos que puntúan distinto (uno sobre 10, otro con estrellas). En vez de pelear con las escalas, solo miras en qué puesto quedó cada película para cada crítico. La que ambos pusieron arriba, gana.”

Aplicar (capa 3)

En scholar-rag, localiza el rrf_k=60 y córrelo con valores 10, 60 y 200, midiendo recall@5 en cada uno. Observa si mueve algo. Anota la sensibilidad: saber que “cambiar rrf_k casi no afecta mi recall” es tan valioso como lo contrario, y ambas son respuestas medidas.


Concepto 4 — Reranking: precisión extra cuando la latencia lo permite

Idea núcleo: un reranker (cross-encoder) reordena el top-k mirando pregunta y chunk juntos. Sube la precisión notablemente, a costa de latencia. Se usa cuando la calidad importa más que los milisegundos.

Entender (capa 1)

Texto: el retrieval híbrido te da, digamos, 20 candidatos decentes. Un reranker toma esos 20 y los reordena con un modelo cross-encoder: a diferencia del bi-encoder (M2), que codifica pregunta y chunk por separado, el cross-encoder los mete juntos al modelo y produce un puntaje de relevancia directo para el par. Es mucho más preciso, pero también mucho más caro: no puedes correrlo sobre todo el corpus, solo sobre un top-k ya reducido. El patrón es de dos etapas: retrieval barato trae 20-50 candidatos, reranker caro elige los mejores 5.

El trade-off es de latencia: cada rerank agrega tiempo. Vale la pena cuando el retrieval híbrido trae lo correcto pero mal ordenado (MRR bajo con recall alto), justo el síntoma que mediste en M1. Si tu MRR ya es alto, el reranker aporta poco y no justifica su costo.

Visual:

flowchart LR
    Q[pregunta] --> H["retrieval hibrido barato: top-20"]
    H --> RR["reranker cross-encoder: reordena"]
    RR --> T[top-5 preciso]
    RR -.costo.-> L[+ latencia]

Ejemplo:

# patron dos etapas: recuperar amplio y barato, reordenar estrecho y caro
candidates = retrieve_hybrid(query, k=20)          # barato
reranked = cross_encoder.rank(query, candidates)   # caro, solo sobre 20
top5 = reranked[:5]

Fijar (capa 2)

Nota atómica:

Feynman: “El retrieval híbrido es preseleccionar 20 candidatos rápido. El reranker es la entrevista final, cara pero precisa, solo con esos 20. No entrevistas a toda la ciudad; por eso primero filtras barato.”

Aplicar (capa 3)

Mira tu recall@5 y MRR de M1. Si el recall es alto pero el MRR bajo, prototipa un reranker sobre el top-20 y mide cuánto sube el MRR y cuánto la latencia. Decide con esos dos números si entra en scholar-rag o no. Documenta la decisión: entra al design doc.


Cierre del módulo

Subiste recall sin tocar el índice, y ahora cada decisión de recuperación de scholar-rag tiene un número detrás: el tamaño de chunk, cuánto aporta el léxico, si rrf_k importa, si el reranker vale su latencia. Eso es la tabla de ablación del capstone.

Lo que puedes responder ahora: “¿cuánto aporta el full-text sobre el vector puro, y por qué esos parámetros?” — con la ablación que lo prueba.


Siguiente: M4 — Grounding y anti-alucinación, donde pasas del retrieval a la respuesta y mides que el LLM no invente.