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:
- Fijo por caracteres (lo actual): simple, pero corta a mitad de frase o de idea. La respuesta puede quedar partida entre dos chunks y ninguno alcanzar el umbral.
- Por tokens: corta según las unidades que el modelo realmente cuenta (un chunk de 1000 caracteres puede ser 200 o 400 tokens según el idioma). Alinea el chunk con el límite de contexto del modelo.
- Semántico: corta en fronteras naturales (párrafos, secciones), a veces usando el propio embedding para detectar cambios de tema. Da los chunks más coherentes, cuesta más de implementar.
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:
- Front: ¿por qué el chunking puede hacer imposible una buena respuesta aunque el índice sea perfecto?
- Back: porque si la información que responde la pregunta queda partida entre dos chunks (o mezclada con otro tema en uno muy grande), ningún chunk individual la contiene bien y el retrieval no puede traerla completa. El corte precede a la búsqueda.
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:
- solo-vector: bueno en preguntas parafraseadas, malo en términos exactos (códigos, nombres, IDs).
- solo-léxico (
ts_rank): lo contrario, atrapa términos exactos, se pierde con sinónimos. - híbrido: casi siempre gana a ambos, porque cada uno cubre el punto ciego del otro.
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:
- Front: ¿qué es una ablación y para qué sirve en retrieval?
- Back: apagar una pieza (por ejemplo la búsqueda léxica) y medir cuánto cae el recall, para cuantificar cuánto aportaba. Sirve para probar con datos que el híbrido vale su complejidad, en vez de asumirlo.
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:
- Front: ¿por qué RRF usa la posición y no el puntaje de cada resultado?
- Back: porque los puntajes de la búsqueda vectorial (distancia coseno) y la léxica (
ts_rank) están en escalas distintas y no son comparables; la posición sí lo es. RRF suma1/(k+posición)de cada lista, así fusiona sin normalizar puntajes.k(≈60) suaviza el peso de las primeras posiciones.
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:
- Front: ¿cuándo un reranker justifica su costo de latencia?
- Back: cuando el retrieval ya trae lo correcto pero mal ordenado (recall alto, MRR bajo). El cross-encoder reordena mirando pregunta y chunk juntos, subiendo la precisión del top-k. Si el MRR ya es alto, aporta poco y no compensa la latencia extra.
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.