Retrieval avanzado
M4 · Retrieval avanzado y operaciones sobre la query
Módulo 4 del curso RAG & Agentic AI — RAGorbit Nodos cubiertos:
retrieval.*,query.*,store.neo4j,store.multi-indexTemplates ancla: 05-legal, 07-telecom, 08-manufacturing, 03-healthcare Semana 4 · ~32 h (lectura + ejercicios + taller)
Índice
- El problema de recuperar lo correcto
- Búsqueda densa (vectorial)
- Búsqueda por keyword: BM25
- Búsqueda híbrida
- Reranking con cross-encoder
- Parent-child retrieval
- Filtros duros como guardrail de seguridad
- Multi-index routing
- Query rewriting e intent detection
- GraphRAG y knowledge graphs (Neo4j)
- Comparativa de tecnologías
- Los nodos de RAGorbit
- La capa ③ explicada: retrievers de LangChain desde cero
- Checkpoint
1. El problema de recuperar lo correcto
RAG tiene una cadena de dependencias: si la recuperación falla, el LLM no puede generar una respuesta correcta aunque sea el mejor modelo del mundo. Puedes tener el embedding más preciso y el LLM más caro, pero si los chunks recuperados son irrelevantes o pertenecen al dominio equivocado, la respuesta será incorrecta o, peor, plausiblmente incorrecta.
La recuperación tiene tres dimensiones de fallo:
FALLO POR SEMÁNTICA — el usuario escribe "baja de plan"
el embedding no asocia con "cancelación de servicio"
→ BM25 o query rewriting lo resuelven
FALLO POR DOMINIO — el técnico de A320 recibe un límite de torque del 787
porque el embedding es similar
→ filtros duros lo resuelven
FALLO POR RANKING — el top-5 tiene fragmentos relevantes pero en posición 4 y 5
el LLM usa los primeros dos (más ruidosos)
→ reranking lo resuelve
Este módulo cubre las herramientas para atacar cada tipo de fallo.
2. Búsqueda densa (vectorial)
Qué es
La búsqueda densa convierte la query y cada documento en vectores densos de alta dimensión (p.ej. 1536 dimensiones con text-embedding-3-large) y mide similitud coseno o producto punto. Fue cubierta en profundidad en M3. Aquí la recordamos como punto de comparación.
Query: "procedimiento inspección tren de aterrizaje"
↓ modelo de embeddings
[0.23, -0.11, 0.87, ...] ← vector de 1536 dims
Corpus:
doc_A: [0.21, -0.09, 0.85, ...] sim=0.97 ← muy relevante
doc_B: [0.70, 0.45, 0.10, ...] sim=0.31 ← poco relevante
doc_C: [0.22, -0.10, 0.86, ...] sim=0.95 ← muy relevante
Cuándo funciona bien
- La query y los documentos están en el mismo dominio semántico.
- El vocabulario no es altamente especializado o con jerga no vista durante el entrenamiento del modelo de embeddings.
- Los chunks son de tamaño moderado (200–1000 tokens).
Cuándo falla
- Jerga técnica muy específica: "ATA 32-11-00" o "RRF" no tienen buenas representaciones en embeddings de uso general.
- Queries cortas y exactas: "GDPR artículo 17" recupera mejor con keyword que con vectores.
- Términos internos de empresa: el glosario corporativo no está en los datos de entrenamiento.
Nodo RAGorbit
retrieval.vector — topK: 4 por defecto. Acepta hardFilters[] (ver §7).
3. Búsqueda por keyword: BM25
Qué es y de dónde viene
BM25 (Best Match 25) es la función de ranking probabilístico que usa Elasticsearch internamente y que ha sido el estado del arte en recuperación de información durante décadas antes del auge de los embeddings. Su nombre viene de una serie de experimentos en los años 70-90 (Okapi BM11, BM15… hasta BM25).
La fórmula BM25
Para una query q con términos q_1 ... q_n y un documento d:
n IDF(q_i) · f(q_i, d) · (k1 + 1)
BM25 = Σ ────────────────────────────────────────
i=1 f(q_i, d) + k1 · (1 - b + b · |d|/avgdl)
Donde:
| Símbolo | Significado |
|---|---|
f(q_i, d) |
Frecuencia del término q_i en el documento d (term frequency) |
IDF(q_i) |
Inverse Document Frequency: log((N - n_i + 0.5) / (n_i + 0.5) + 1) |
N |
Total de documentos en el corpus |
n_i |
Número de documentos que contienen q_i |
| ` | d |
avgdl |
Longitud media de los documentos |
k1 |
Parámetro de saturación de TF (típico: 1.2–2.0) |
b |
Parámetro de normalización de longitud (típico: 0.75) |
Intuición de la fórmula
IDF: Un término que aparece en pocos documentos es muy discriminativo. "Tren de aterrizaje" aparece en pocos documentos del corpus → IDF alto → ese término pesa mucho. "El" aparece en todos → IDF ≈ 0 → ese término no discrimina.
TF con saturación (k1): La relevancia no crece linealmente con la frecuencia. Si "mantenimiento" aparece 1 vez vs 2 veces, hay diferencia. Si aparece 50 vs 51 veces, la diferencia es casi nula. El parámetro k1 controla esa saturación.
Normalización por longitud (b): Un documento de 1000 palabras naturalmente tendrá más repeticiones de cualquier término que uno de 100 palabras. El parámetro b penaliza los documentos largos para evitar que dominen el ranking simplemente por ser extensos. Con b=0.75 se aplica una normalización parcial (no total).
Por qué BM25 complementa a los embeddings
Query: "ATA 32-11-00"
BM25: ← recupera "ATA 32-11-00" exacto, score alto
Vector: ← "landing gear chapter 32" puede ser más cercano semánticamente
pero "ATA 32-11-00" como cadena exacta tiene mejor BM25
Query: "procedimiento para revisar sistemas hidráulicos antes de vuelo"
BM25: ← puede fallar si el doc dice "inspección pre-vuelo de actuadores"
Vector: ← captura la semántica aunque las palabras sean distintas
Los modelos de embeddings capturan intención semántica pero pierden coincidencias exactas de términos técnicos. BM25 hace exactamente lo contrario. La combinación es más robusta que cualquiera de los dos solos.
Nodo RAGorbit: retrieval.hybrid
Internamente combina un retriever vectorial y uno BM25 con parámetro alpha que controla el peso relativo.
4. Búsqueda híbrida
Estrategia de fusión: Reciprocal Rank Fusion (RRF)
RRF es el método de fusión más común para combinar listas de resultados de distintos retrievers. La idea: en lugar de combinar scores directamente (que tienen escalas distintas), se usa el rango de cada documento en cada lista.
1
RRF(d) = Σ ────────────
r∈R k + r(d)
Donde r(d) es la posición del documento d en la lista del retriever r, y k es una constante de suavizado (típico: 60).
Ejemplo concreto:
BM25 retorna: doc_A (rank 1), doc_C (rank 2), doc_B (rank 3)
Vector retorna: doc_C (rank 1), doc_A (rank 2), doc_D (rank 3)
RRF(doc_A) = 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
RRF(doc_C) = 1/(60+2) + 1/(60+1) = 0.01613 + 0.01639 = 0.03252
RRF(doc_B) = 1/(60+3) + 0 = 0.01587
RRF(doc_D) = 0 + 1/(60+3) = 0.01587
Resultado fusionado: doc_A, doc_C (empate), doc_B, doc_D
Suma ponderada de scores normalizados
Alternativa a RRF cuando los scores están en la misma escala:
score_final(d) = alpha * score_vector(d) + (1 - alpha) * score_bm25(d)
Con alpha=0.5 da igual peso a ambos. Ajustar alpha según el dominio.
Cuándo usar híbrido
| Situación | alpha recomendado |
|---|---|
| Dominio técnico con muchos identificadores exactos | 0.3 (más BM25) |
| Dominio conversacional / lenguaje natural | 0.7 (más vectorial) |
| Sin saber a priori | 0.5 (punto de partida) |
| Con retroalimentación de usuarios | ajustar con A/B testing |
Template 07 (Telecom)
El copilot de call center usa retrieval.hybrid porque los agentes mezclan jerga técnica ("roaming internacional EE.UU.") con lenguaje natural ("¿qué le digo al cliente?"). BM25 captura los términos exactos del glosario; el vector captura la intención de la pregunta.
Template 08 (Manufactura)
Los manuales AMM tienen identificadores exactos (ATA, números de sección, números de parte). BM25 es muy preciso para "Task 32-11-00-581-001". El vector captura "procedimiento inspección tren morro" aunque el documento diga "nose landing gear inspection procedure".
5. Reranking con cross-encoder
El problema que resuelve
Los retrievers (tanto vectorial como BM25) codifican la query y cada documento por separado y luego calculan similitud. Esto es eficiente pero impreciso: el modelo no ve la query y el documento juntos al producir la representación.
Un cross-encoder (reranker) es un modelo que recibe la query y el documento juntos como entrada y produce un score de relevancia. Es mucho más preciso pero también más lento — por eso se usa solo en el top-K de los retrievers, no en el corpus completo.
PIPELINE DE DOS ETAPAS (retrieve + rerank)
Paso 1 — Retrieve rápido (recall alto)
BM25 + Vector → top-20 candidatos
[rápido, escala a millones de docs, pero impreciso]
Paso 2 — Rerank preciso (precision alta)
Cross-encoder puntúa query ↔ cada candidato juntos
→ retiene top-3 más relevantes
[lento, solo aplica a los 20 candidatos, muy preciso]
Por qué mejora la precisión
El bi-encoder (vectores separados) comprime la query en un vector sin saber qué documentos va a comparar. El cross-encoder, al ver los dos juntos, puede capturar interacciones sutiles:
Query: "límite de torque del actuador del tren de morro"
doc_A: "El torque máximo del actuador del tren principal es 45 Nm" ← menciona torque pero del tren PRINCIPAL
doc_B: "Para el tren de morro, el torque del actuador es 32 Nm" ← exactamente lo que se busca
Bi-encoder: doc_A puede puntuar similar a doc_B (ambos hablan de torque y tren)
Cross-encoder: doc_B puntúa mucho más alto (tren de morro + actuador + torque juntos)
Latencia trade-off
| Componente | Latencia típica | Por qué |
|---|---|---|
| Retrieval vectorial (HNSW, top-20) | 10-50 ms | índice aproximado en memoria |
| BM25 (top-20) | 5-20 ms | índice invertido en memoria |
| Reranker BGE sobre 20 docs | 50-150 ms | forward pass del modelo por cada par |
| Reranker Cohere API sobre 20 docs | 100-300 ms | llamada de red + modelo grande |
El reranker agrega ~100-200 ms al pipeline, pero la mejora en relevancia suele valer la pena en dominios donde la precisión es crítica (legal, salud, aviación).
Modelos reranker disponibles
| Modelo | Tipo | Ventaja | Cuándo usar |
|---|---|---|---|
bge-reranker-v2-m3 (BAAI) |
Cross-encoder local | Gratuito, sin API, rápido | Producción sin dependencias externas |
rerank-english-v3.0 (Cohere) |
API cloud | Alta calidad, muy fácil de integrar | Prototipado rápido, inglés |
ColBERT |
Late interaction | Balance latencia/calidad, permite pre-computar | Millones de docs |
FlashRank |
Cross-encoder muy ligero | Ultra rápido, para edge/mobile | Latencia < 50 ms crítica |
Template 05 (Legal) y 07 (Telecom)
Ambos usan retrieval.reranker con topN: 3. En legal, el reranker distingue entre un fragmento del playbook sobre "indemnización" en contratos de software y el fragmento sobre "indemnización" en contratos de infraestructura — que semánticamente son similares pero legalmente relevantes de forma distinta. En telecom, ajusta el ranking según el feedback de los agentes (feedbackRef).
Nodo RAGorbit: retrieval.reranker
{
"type": "retrieval.reranker",
"config": {
"model": "bge-reranker",
"topN": 3,
"feedbackRef": "feedback_store" // opcional: mejora con señales de uso
}
}
6. Parent-child retrieval
El dilema del tamaño de chunk
Chunks pequeños (100-200 tokens): más precisos para recuperar el fragmento exacto, pero pierden contexto (una oración sin su párrafo).
Chunks grandes (800-1200 tokens): tienen más contexto, pero la representación vectorial promedia el significado de todo el chunk y puede diluir la señal relevante.
Parent-child resuelve este dilema con una estrategia de dos niveles:
NIVEL PADRE (chunks grandes, 800+ tokens)
Sección 32-11-00 completa (procedimiento de 900 tokens)
Sección 32-11-01 completa (variante del procedimiento, 850 tokens)
NIVEL HIJO (chunks pequeños, 100-200 tokens)
Paso 1: Coloca la aeronave en jack... (hijo de 32-11-00)
Paso 2: Verifica el juego lateral... (hijo de 32-11-00)
Paso 3: Inspecciona visualmente... (hijo de 32-11-00)
Paso 4: Registra los resultados... (hijo de 32-11-00)
RETRIEVAL:
1. Se indexan y recuperan los HIJOS (precisión alta)
2. Se devuelven los PADRES al LLM (contexto completo)
Cuándo usar parent-child
- Documentos con estructura jerárquica clara: manuales técnicos, contratos, guías clínicas.
- Cuando los chunks del índice son semánticamente densos pero necesitas el contexto ampliado para que el LLM responda bien.
- Template 08: cada paso del procedimiento AMM es un hijo; la sección ATA completa es el padre.
Cuándo no vale la pena
- Corpus de fragmentos independientes (tweets, FAQ individuales, posts de blog).
- Chunks ya moderados (400-600 tokens) donde el contexto es suficiente.
- Cuando la latencia extra de recuperar el padre (segundo lookup) no se puede asumir.
Nodo RAGorbit: retrieval.parent-child
{
"type": "retrieval.parent-child",
"config": {
"parentField": "parent_id"
}
}
El parent_id se establece en ingest.metadata al indexar, vinculando cada chunk hijo con su documento padre.
7. Filtros duros como guardrail de seguridad
La diferencia entre filtro blando y filtro duro
Un filtro blando (o soft hint) instruye al LLM a "preferir" documentos de cierto tipo. Ejemplo: "Responde solo usando información del plan PPO-Gold". El problema: el LLM puede ignorarlo, "olvidarlo" en prompts largos, o razonar que otro documento es "suficientemente relevante".
Un filtro duro se aplica en la capa de recuperación, antes de que cualquier documento llegue al LLM. Es una cláusula WHERE en SQL, un filtro de metadata en el vector store. El LLM simplemente nunca ve los documentos que no pasan el filtro.
SIN FILTRO DURO:
Query: "criterios de RM de rodilla"
Vector store devuelve: chunks de PPO-Gold, PPO-Basic, PPO-Platinum mezclados
LLM puede usar criterios de PPO-Platinum para un paciente PPO-Basic → ERROR CLÍNICO
CON FILTRO DURO (hardFilter: plan = "PPO-Basic"):
Query: "criterios de RM de rodilla"
Vector store aplica WHERE plan = 'PPO-Basic' antes de buscar
Solo llegan al LLM chunks de PPO-Basic → correcto por diseño
Por qué es un guardrail, no solo un filtro
En dominios de alta consecuencia, el filtro duro actúa como un guardrail de seguridad estructural:
Salud (03-healthcare): El paciente con plan PPO-Basic no puede recibir criterios del plan PPO-Platinum (más permisivos). Un "se aprueba" incorrecto es un problema legal y clínico.
Aviación (08-manufacturing): El técnico de A320 no puede recibir límites de torque del 787. La confusión de aeronave es un hallazgo FAA/EASA.
Aviación civil (01-airline): Un pasajero con tarifa Economy no puede ver políticas de Business en el contexto del LLM, porque podría recibir upgrades o beneficios no contratados.
En todos estos casos, la instrucción de prompt "usa solo los datos del plan/aeronave correcto" no es suficiente. El filtro duro es determinista e inviolable.
Implementación en el nodo retrieval.vector
{
"type": "retrieval.vector",
"config": {
"topK": 5,
"hardFilters": ["aircraft_type", "ata_chapter"]
}
}
En producción, el nodo convierte esto en una consulta filtrada:
# Pseudocódigo del nodo generado por RAGorbit
results = pgvector_store.similarity_search(
query_embedding,
k=5,
filter={
"aircraft_type": {"$eq": session.aircraft_type},
"ata_chapter": {"$eq": session.ata_chapter}
}
)
Los valores de los filtros vienen del contexto de sesión, no del LLM.
El filtro duro como patrón de diseño transversal
Este patrón aparece en M3, M4, M5 y M9. En RAGorbit, hardFilters[] está disponible en retrieval.vector y retrieval.hybrid. Los campos que se pueden filtrar son los que se etiquetaron en ingest.metadata. La regla es: cualquier dimensión que determine qué información es permisible para un usuario específico debe ser un filtro duro, no una instrucción de prompt.
8. Multi-index routing
Por qué no un solo índice
La solución "simple" es indexar todo en un solo vector store y buscar ahí. El problema:
Ruido cross-dominio: una query sobre "indemnización" en el contexto de un contrato de software puede recuperar fragmentos de indemnización de contratos de construcción, que son semánticamente similares pero jurídicamente irrelevantes.
Latencia: buscar en un índice de 1 millón de documentos es más lento que en tres índices de 100k cada uno.
Control de versiones: actualizar el playbook legal no debería afectar el índice de normativa regulatoria.
Multi-index routing: arquitectura
ÍNDICES:
policy ← regulaciones, tarifas, condiciones legales
procedure ← procedimientos internos paso a paso
faq ← preguntas frecuentes
REGLAS DEL ROUTER:
keyword "facturacion" → index: policy
keyword "procedimiento" → index: procedure
keyword "cómo puedo" → index: faq
fallback → index: faq
QUERY: "¿Cuánto me cobran por superar mi límite de datos?"
Router detecta "cobran" → keyword de facturación → ruta a policy
Solo se busca en policy → 0 ruido de procedure o faq
Latencia: 30ms (1 índice) vs 90ms (3 índices en paralelo)
Dos estrategias de routing
1. Keyword matching (determinista)
for rule in rules:
if rule.keyword in query.lower():
return rule.index
return fallback
Ventajas: microsegundos, predecible, debuggeable. Desventajas: requiere mantenimiento manual del glosario de keywords.
2. Intent-based routing (ML ligero)
Usa el clasificador model.intent (embeddings ligeros, ~5-10ms) para detectar la intención de la query y rutear según la etiqueta:
intent("¿cuánto me cobran?") → "facturacion" → policy
intent("cómo configuro el router?") → "soporte_tecnico" → procedure
Ventajas: captura variantes semánticas ("¿cuánto es la tarifa?" → facturacion aunque no diga "cobran"). Desventajas: requiere entrenamiento, puede fallar en queries ambiguas.
Nodos RAGorbit
store.multi-index → agrupa varios Retrievers nombrados
retrieval.router → selecciona el índice correcto por rules[] o intent
Template 05 (Legal): tres índices, routing por keyword
indexes: [playbook, regulations, precedent]
rules:
"indemniz" → playbook
"regulacion" → regulations
"precedente" → precedent
fallback: playbook
Template 07 (Telecom): tres índices, routing por intent
indexes: [policy, procedure, faq]
rules:
facturacion → policy
soporte_tecnico → procedure
fallback: faq
9. Query rewriting e intent detection
Query rewriting
El rewriter normaliza la query del usuario antes de enviarla al retriever. Sus dos funciones principales:
1. Normalización de jerga interna
"baja de plan" → "cancelación de servicio"
"roaming gringo" → "roaming internacional EE.UU."
"batería de la laptop" → "bateria litio portatil equipaje cabina"
Esto es un mapeo de términos internos/coloquiales a los términos canónicos que aparecen en la documentación indexada. Sin este paso, BM25 falla (no hay coincidencia de términos) y el vector puede fallar (el embedding del término coloquial es distinto al del técnico).
2. Expansión de la query (query expansion)
Añade términos relacionados para mejorar el recall de BM25:
Query original: "RM rodilla"
Query expandida: "resonancia magnética rodilla menisco cartílago articulación"
Esto es especialmente útil en dominios médicos o legales donde los usuarios formulan queries cortas y los documentos usan terminología completa.
Intent detection como compuerta del RAG
La detección de intención no es solo para routing: su primera función es ser la compuerta que decide si la query merece activar el pipeline RAG en absoluto.
FRAGMENTOS DE AUDIO EN CALL CENTER:
"Oiga, y si viajo a Cancún..." → intent: facturacion (score 0.71) → RAG
"Sí, claro, aja... un momento" → intent: no_accionable (score 0.82) → DESCARTAR
"¿Cuánto cuesta el plan familiar?" → intent: facturacion (score 0.88) → RAG
Sin esta compuerta, el 30-50% de los fragmentos de audio activan el RAG innecesariamente, generando ruido en el panel del agente y consumiendo recursos.
Nodos RAGorbit
query.rewrite → normaliza jerga, expande términos
query.intent → detecta intención, filtra no-accionables, ruta
model.intent → clasificador ligero (embeddings o small-LLM)
La diferencia entre query.intent y model.intent en RAGorbit es que query.intent está orientado a la compuerta del RAG (produce Decision y Query), mientras que model.intent es el modelo de clasificación subyacente que puede usarse en contextos más generales.
Pipeline completo de query ops (Template 07)
Audio → STT → model.intent → [si no_accionable: descartar]
→ [si accionable: query.rewrite → retrieval.router → ...]
Este pipeline elimina el ruido antes de la primera llamada al vector store, con una latencia de solo ~15 ms (intent: 10ms + rewrite: 5ms).
10. GraphRAG y knowledge graphs (Neo4j)
Cuándo los vectores no son suficientes
Los embeddings capturan semántica de texto, pero no capturan relaciones estructurales. Considera:
"¿Qué procedimientos están afectados por la Directiva de Aeronavegabilidad AD-2024-0023?"
Con vectores:
La query se convierte en vector
Se buscan chunks similares → puede encontrar algunos procedimientos
Pero NO puede navegar: AD-2024-0023 → afecta a → SB-2023-32-001 → requiere → Task 32-11-001
Con knowledge graph:
AD-2024-0023 es un nodo
Tiene relaciones tipadas: AFECTA_A → [SB-2023-32-001, SB-2023-32-002]
Cada SB tiene: REQUIERE → [Task 32-11-001, Task 32-11-002]
Una consulta de vecindario devuelve todo el subgrafo en 1-2 hops
Conceptos fundamentales del knowledge graph
Nodo: Una entidad del dominio. En un AMM: un procedimiento, una directiva de aeronavegabilidad, una parte, un técnico certificado.
Relación (arista tipada): Una conexión con semántica. No solo "A está relacionado con B", sino "AFECTA_A", "REQUIERE", "REEMPLAZA_A", "ES_PREREQUISITO_DE".
Vecindario: El conjunto de nodos y relaciones a 1 o más hops (saltos) de un nodo dado. La recuperación "por vecindario" es lo que diferencia GraphRAG de vector RAG.
GRAFO (vista parcial — dominio AMM):
[AD-2024-0023] --AFECTA_A--> [SB-2023-32-001]
|
REQUIERE
|
[Task 32-11-001] --ES_PARTE_DE--> [Seccion 32-11-00]
|
PREREQUISITO
|
[Task 07-11-001] (jack de mantenimiento)
QUERY: "qué tareas requiere AD-2024-0023?"
GRAPH TRAVERSAL: AD-2024-0023 → AFECTA_A → SBs → REQUIERE → Tasks
RESULTADO: [Task 32-11-001, Task 07-11-001 (transitivo)]
Neo4j y el nodo store.neo4j
Neo4j es la base de datos de grafos más usada en producción. Sus dos grandes ventajas:
- Cypher: lenguaje de query declarativo para grafos, muy legible.
- Embeddings en nodos: Neo4j soporta almacenar embeddings en los nodos y hacer búsqueda vectorial sobre ellos, combinando búsqueda vectorial y traversal de grafo.
-- Cypher: encontrar todos los documentos relacionados con una directiva
MATCH (ad:Directive {id: "AD-2024-0023"})-[:AFECTA_A*1..2]->(doc:Document)
RETURN doc.text, doc.section, doc.revision
El nodo store.neo4j en RAGorbit:
- Crea nodos de tipo
Chunkcon su texto, metadata y embedding. - Crea relaciones tipadas entre chunks según la estructura del documento (
entitySchema). - Con
buildRelations: true, el nodo infiere relaciones automáticamente (padre-hijo de secciones, co-ocurrencia de entidades). - El
retrieval.graphrecupera por similitud vectorial en los nodos Y por traversal de vecindario.
Recuperación híbrida grafo + vector
El flujo de GraphRAG combina las dos capacidades:
1. Vector search en nodos del grafo
→ encuentra los 3 nodos más similares a la query
2. Graph traversal desde esos nodos
→ expande 1-2 hops siguiendo relaciones tipadas
→ recolecta el subgrafo de contexto
3. Devuelve: los nodos del vector search + el vecindario
Esto es especialmente potente cuando la respuesta a una pregunta no está en un solo chunk sino en la estructura de relaciones entre múltiples chunks.
Cuándo usar grafos vs vectores
| Situación | Usar |
|---|---|
| Corpus con relaciones explícitas y complejas entre entidades | Grafo |
| Preguntas de tipo "¿qué afecta a qué?", "¿qué requiere qué?" | Grafo |
| Recuperación basada en texto y semántica | Vector |
| Corpus sin estructura de relaciones clara | Vector |
| Cuando el mantenimiento del grafo es demasiado costoso | Vector |
| Cuando la precisión extra vale el overhead de Neo4j | Grafo |
Microsoft GraphRAG
Microsoft Research publicó en 2024 un framework llamado GraphRAG que lleva el concepto más lejos: usa un LLM para extraer entidades y relaciones del corpus (construyendo el grafo automáticamente), y luego usa el grafo para responder preguntas de nivel global ("¿cuáles son los temas principales del corpus?") que los RAG vectoriales no pueden responder bien.
La diferencia clave con el store.neo4j de RAGorbit es que Microsoft GraphRAG usa "comunidades" (clustering de entidades relacionadas) para responder preguntas holísticas. RAGorbit usa el grafo principalmente para traversal de vecindario en queries específicas.
11. Comparativa de tecnologías
Retrievers
| Método | Precisión | Recall | Latencia | Cuándo |
|---|---|---|---|---|
| BM25 puro | Alta (exacta) | Bajo (semántica limitada) | Muy baja | IDs exactos, términos técnicos |
| Vector puro | Media-alta | Alto | Baja | Lenguaje natural, semántica |
| Híbrido | Alta | Alto | Media | Caso general |
| GraphRAG | Muy alta (estructura) | Medio | Alta | Relaciones complejas |
Rerankers
| Modelo | Calidad | Latencia | Costo | Cuándo |
|---|---|---|---|---|
| BGE-reranker-v2 | Muy alta | 50-150ms local | Gratuito | Producción sin cloud |
| Cohere Rerank v3 | Muy alta | 100-300ms API | Pago por uso | Prototipado, inglés |
| ColBERT | Alta | 20-80ms | Gratuito | Escala grande |
| FlashRank | Media-alta | 5-20ms | Gratuito | Edge, latencia crítica |
Estrategias de fusión
| Método | Cuándo preferir |
|---|---|
| RRF (Reciprocal Rank Fusion) | Scores de distintas escalas (BM25 y coseno) |
| Suma ponderada normalizada | Scores en misma escala, control fino de alpha |
| Cross-encoder (reranker) | Precisión máxima, latencia tolerable |
Frameworks: LangChain vs LlamaIndex para retrieval
| Aspecto | LangChain | LlamaIndex |
|---|---|---|
| Retrievers built-in | EnsembleRetriever, BM25Retriever, ContextualCompressionRetriever | SparseTopKRetriever, HybridFusion, RankGPT |
| Rerankers | ContextualCompressionRetriever + Cohere/BGE | CohereRerank, SentenceTransformerRerank, RankLLM |
| Graph RAG | Integración con Neo4j Graph RAG Toolkit | NebulaGraphStore, Neo4jGraphStore |
| Multi-index | MultiVectorRetriever, MergerRetriever | RouterRetriever, MultiIndexRetriever |
| Cuándo preferir | Cuando el resto del stack ya usa LangChain/LCEL | Cuando el foco es retrieval avanzado con muchas estrategias |
12. Los nodos de RAGorbit
Categoría retrieval
| Nodo | Descripción | Cuándo |
|---|---|---|
retrieval.vector |
Búsqueda por similitud con filtros duros opcionales | Caso base, dominio semántico |
retrieval.hybrid |
Vector + BM25 fusionados (parámetro alpha) |
Dominio con jerga técnica + lenguaje natural |
retrieval.graph |
Recuperación por similitud + traversal de vecindario (Neo4j) | Relaciones complejas entre entidades |
retrieval.router |
Selecciona índice por keyword/intent | Multi-índice, reducir ruido y latencia |
retrieval.parent-child |
Recupera hijos para precisión, devuelve padres para contexto | Documentos jerárquicos largos |
retrieval.reranker |
Reordena y recorta con cross-encoder | Siempre después de retrieve en dominios críticos |
Categoría query
| Nodo | Descripción | Cuándo |
|---|---|---|
query.rewrite |
Normaliza jerga, expande términos | Dominio con vocabulario corporativo/técnico |
query.intent |
Detecta intención, filtra no-accionables, ruta | Compuerta del RAG, reducir llamadas innecesarias |
Pipeline recomendado para producción
Usuario
↓ Message
query.intent ← compuerta: ¿es accionable?
↓ Query (si accionable)
query.rewrite ← normaliza jerga, expande
↓ Query
retrieval.router ← selecciona índice correcto
↓ Chunks (top-K ruidoso)
retrieval.reranker ← reordena, retiene top-3
↓ Chunks (precisos)
logic.prompt + logic.citations
↓ Message (con citas)
io.output
13. La capa ③ explicada: retrievers de LangChain desde cero
Prerrequisito: haber completado la capa ② del taller (
lab/solucion_scratch.py) — BM25, coseno, RRF, rerank y filtro duro implementados a mano. Sin eso, esta sección parecerá magia.Recordatorio LangChain (no lo reexplicamos aquí): En M1, §11, aprendiste qué es LangChain, el objeto
Document,HuggingFaceEmbeddings/OpenAIEmbeddings,Chroma.from_documents, la abstracciónRetriever(as_retriever,.invoke) y el patrón LCEL. Esta sección enseña solo lo nuevo de M4: retrievers especializados para búsqueda híbrida, fusión, reranking y filtro duro.Entorno: en la máquina de estudio del curso no hay
pipni red. No ejecutarás este código aquí. El objetivo es que, conpip install langchain langchain-community rank-bm25 sentence-transformers chromadb, puedas escribirlab/solucion_framework.pytú mismo.
13.1 El problema que resuelve esta capa
En solucion_scratch.py escribiste ~300 líneas para: tokenizar, calcular BM25, embedear con bag-of-words, fusionar con RRF, rerankear por intersección de tokens y filtrar por fare_class. Funciona y es determinista. Pero en producción necesitas:
- BM25 optimizado (no un bucle Python sobre 50k docs).
- Embeddings semánticos reales (no bag-of-words).
- Fusión híbrida sin reimplementar RRF.
- Un cross-encoder real (BGE-reranker), no intersección de tokens.
- Todo cableado con la misma interfaz para poder intercambiar piezas.
LangChain te da retrievers componibles: cada uno implementa la misma interfaz y los encadenas como bloques LEGO.
SCRATCH (M4 lab) LANGCHAIN (M4 lab)
──────────────────── ────────────────────────────────────
tokenizar() + BM25 manual ────────▶ BM25Retriever.from_documents(docs)
embed BoW + coseno manual ────────▶ Chroma + HuggingFaceEmbeddings + as_retriever
rrf_fusion() manual ────────▶ EnsembleRetriever(retrievers=[...], weights=[...])
rerank por intersección ────────▶ CrossEncoderReranker + ContextualCompressionRetriever
filtrar lista Python ────────▶ crear_retriever_filtrado() o filter en Chroma
13.2 Tabla puente: scratch → LangChain (M4)
| Lo que hiciste a mano (capa ②) | Pieza LangChain (capa ③) | Sección de concepto |
|---|---|---|
bm25_score() + ranking sobre corpus |
BM25Retriever.from_documents(docs) + atributo .k |
§3 BM25 |
embed_bow() + similitud_coseno() |
HuggingFaceEmbeddings + Chroma.from_documents + as_retriever(search_kwargs={"k":...}) |
§2 Búsqueda densa + M1 §11 |
rrf_fusion(bm25_rank, vector_rank, k=60) |
EnsembleRetriever(retrievers=[...], weights=[...]) |
§4 Híbrido / RRF |
rerank_interseccion() (proxy cross-encoder) |
CrossEncoderReranker + ContextualCompressionRetriever |
§5 Reranking |
Filtrar CORPUS por fare_class antes de buscar |
crear_retriever_filtrado() o search_kwargs={"filter": {...}} en Chroma |
§7 Filtros duros |
main() imprime top-3 sin/con filtro |
.get_relevant_documents(query) o .invoke(query) |
M1 §11 (interfaz Retriever) |
13.3 Retriever como interfaz componible
En M1 aprendiste que un Retriever es cualquier objeto que, dada una query (string), devuelve list[Document]. La interfaz mínima:
docs = retriever.invoke("¿puedo hacer cambios sin cargo?")
# docs: list[Document] con page_content y metadata
También existe el alias legacy .get_relevant_documents(query) — hace lo mismo. En código nuevo prefiere .invoke().
La idea clave de M4: puedes apilar retrievers. Un retriever puede contener otros retrievers:
┌─────────────────────────────────────┐
│ ContextualCompressionRetriever │
│ (reranker encima del ensemble) │
└──────────────────┬──────────────────┘
│ base_retriever
┌──────────────────▼──────────────────┐
│ EnsembleRetriever │
│ (fusión RRF de BM25 + vector) │
└──────────┬─────────────┬────────────┘
│ │
┌──────────▼──┐ ┌──────▼──────────┐
│ BM25Retriever│ │ vector_retriever │
│ (keyword) │ │ (Chroma/denso) │
└─────────────┘ └─────────────────┘
Cada caja habla list[Document] hacia arriba. Tú solo llamas .invoke(query) al retriever más externo.
13.4 Document con metadata de filtro (recordatorio breve)
En el lab, cada política del JSON se convierte en un Document:
from langchain.schema import Document
documentos = [
Document(
page_content=item["texto"],
metadata={
"id": item["id"],
"fare_class": item["metadata"]["fare_class"], # ← clave para filtro duro
"route_type": item["metadata"]["route_type"],
"categoria": item["metadata"]["categoria"],
},
)
for item in raw
]
page_content= el texto que indexan BM25 y el vector store.metadata["fare_class"]= la dimensión que usas en el filtro duro (§7). Sin metadata correcta, el filtro no puede funcionar.
Detalle completo de Document: M1 §11.3.
13.5 BM25Retriever — tu BM25 manual, empaquetado
Qué hace: construye un índice BM25 en memoria sobre una lista de Document usando la librería rank-bm25 (la misma fórmula de §3, optimizada).
from langchain_community.retrievers import BM25Retriever
bm25_retriever = BM25Retriever.from_documents(documentos)
bm25_retriever.k = 9 # cuántos documentos devolver por query (equivalente a tu top-k)
docs = bm25_retriever.invoke("cambios sin cargo adicional")
# docs[0].metadata["id"] → probablemente pol_008 (Top) sin filtro
| Parámetro / atributo | Qué controla | Equivalente scratch |
|---|---|---|
.from_documents(docs) |
Construye el índice BM25 | Tu bucle IDF + TF sobre CORPUS |
.k |
Top-k a devolver | El [:9] de tu ranking BM25 |
Cuándo usar: dominios con términos exactos ("cambios", "sin cargo", códigos ATA). Cuándo NO: si solo necesitas semántica y no hay identificadores exactos — un vector retriever solo puede bastar.
Gotcha: BM25Retriever no acepta filter de metadata. Si necesitas filtro duro, debes pasarle solo los Document ya filtrados (ver §13.10).
13.6 Vector retriever — Chroma + embeddings locales
Qué hace: indexa los Document con embeddings densos y expone un retriever de similitud coseno.
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
# Modelo local ~80MB; primera ejecución descarga de Hugging Face
vector_store = Chroma.from_documents(documentos, embeddings)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 9})
docs = vector_retriever.invoke("cambios de vuelo sin pagar")
| Pieza | Rol | Recordatorio M1 |
|---|---|---|
HuggingFaceEmbeddings |
Convierte texto → list[float] |
M1 §11.6 — interfaz Embeddings |
Chroma.from_documents |
Persiste vectores + metadata | M1 §11.7 — vector store |
as_retriever(search_kwargs={"k": N}) |
Devuelve top-N por similitud | M1 §11.8 — Retriever |
Diferencia con tu scratch: tu embedding BoW no captura que "modificación de fecha" y "cambio de vuelo" son semánticamente cercanos. all-MiniLM-L6-v2 sí — por eso el ranking vectorial del framework puede diferir del scratch, pero el patrón (sin filtro → ruido de otras tarifas) se mantiene.
Cuándo usar: lenguaje natural, sinónimos, queries largas. Cuándo NO: solo búsqueda por ID exacto sin variación semántica.
13.7 EnsembleRetriever — tu RRF manual, automático
Qué hace: ejecuta varios retrievers en paralelo, fusiona sus rankings con Reciprocal Rank Fusion (RRF, §4) usando c=60 por defecto — el mismo k=60 de tu rrf_fusion().
from langchain.retrievers import EnsembleRetriever
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6], # 40% BM25, 60% vectorial
)
docs = ensemble_retriever.invoke(QUERY)
Cómo funciona internamente (mapeo a tu scratch):
Tu scratch: EnsembleRetriever:
───────────────── ─────────────────────
for rank, doc in bm25_results: Ejecuta retriever[0].invoke(query)
score += 1/(60+rank) Ejecuta retriever[1].invoke(query)
for rank, doc in vector_results: Fusiona con RRF (c=60)
score += 1/(60+rank) Aplica weights como desempate
sort by score desc Devuelve list[Document]
Sobre weights: NO son multiplicadores de score BM25 vs coseno (escalas incompatibles — por eso RRF usa rangos, §4). En EnsembleRetriever, los weights influyen cuando un documento aparece en solo una de las listas: un doc que solo el vector retriever encontró recibe un boost proporcional a weights[1]. Si aparece en ambas listas, RRF ya le dio score por las dos posiciones.
| weights | Interpretación práctica |
|---|---|
[0.5, 0.5] |
Empate BM25 / vectorial |
[0.4, 0.6] |
Más confianza en semántica (dominio conversacional) |
[0.7, 0.3] |
Más confianza en keywords (dominio técnico con IDs exactos) |
Cuándo usar: siempre que quieras híbrido BM25+vector (caso general, §4). Cuándo NO: si uno de los retrievers es claramente inútil en tu dominio — mejor quitarlo que darle weight 0.01.
Gotcha: EnsembleRetriever no tiene hardFilter. El filtro debe aplicarse antes (§13.10).
13.8 Reranking — CrossEncoderReranker + ContextualCompressionRetriever
Qué hace: toma la salida del retriever base (ensemble), la reordena con un cross-encoder (§5) y recorta a top_n.
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
cross_encoder = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")
reranker = CrossEncoderReranker(model=cross_encoder, top_n=3)
compression_retriever = ContextualCompressionRetriever(
base_compressor=reranker, # el "compresor" reordena y recorta
base_retriever=ensemble_retriever, # de dónde vienen los candidatos
)
docs = compression_retriever.invoke(QUERY) # máximo 3 docs, reordenados
Patrón "compression/reranking sobre retriever base":
Query
│
▼
base_retriever (Ensemble) ──▶ top-9 candidatos (recall alto, §5)
│
▼
base_compressor (Reranker) ──▶ reordena query+doc JUNTOS (precision alta)
│
▼
top-3 finales
El nombre "ContextualCompression" es histórico: originalmente comprimía documentos largos. En la práctica de RAG, el uso más común es reranking — por eso casi siempre el base_compressor es un reranker.
Alternativa cloud (sin modelo local):
# from langchain_cohere import CohereRerank
# reranker = CohereRerank(model="rerank-multilingual-v3.0", top_n=3)
CohereRerank sigue el mismo patrón: lo pasas como base_compressor a ContextualCompressionRetriever. Requiere COHERE_API_KEY pero evita descargar BGE (~400MB).
| Opción | Ventaja | Cuándo |
|---|---|---|
CrossEncoderReranker + BGE |
Local, gratis, multilingüe razonable | Producción sin cloud |
CohereRerank |
Muy fácil, alta calidad en inglés | Prototipado rápido con API |
Cuándo usar reranker: dominios críticos (legal, salud, aerolínea) donde el top-5 del retriever tiene ruido en posiciones 1-2. Cuándo NO: latencia < 100ms o corpus < 500 docs donde el retriever ya es preciso.
Gotcha: el reranker recibe la salida del base_retriever. Si el ensemble devuelve k=3, el reranker solo puede reordenar 3 docs — configura k del ensemble alto (9-20) y deja que el reranker recorte a top_n=3.
13.9 Filtro duro — por qué no está en EnsembleRetriever
EnsembleRetriever fusiona listas de BM25 y vector. No tiene parámetro hardFilter porque:
- BM25 no soporta filtros de metadata nativamente.
- Chroma sí soporta
filterensearch_kwargs, pero ese filtro solo aplica al retriever vectorial — BM25 seguiría devolviendo docs de otras tarifas.
Estrategia A (recomendada en el lab): filtrar el corpus antes de construir los retrievers:
def crear_retriever_filtrado(fare_class: str):
docs_filtrados = [d for d in documentos if d.metadata["fare_class"] == fare_class]
bm25_filtrado = BM25Retriever.from_documents(docs_filtrados)
# ... reconstruir vector store, ensemble y compression retriever
Estrategia B (solo vector): filtro en Chroma sin reconstruir el índice:
vector_retriever = vector_store.as_retriever(
search_kwargs={"k": 9, "filter": {"fare_class": "Basic"}}
)
Esto filtra el retriever vectorial, pero BM25 del ensemble seguiría sin filtrar — ruido garantizado. Por eso la estrategia A es la robusta (igual que tu scratch: filtras CORPUS al inicio).
Ver justificación completa en §7.
13.10 Recorrido bloque a bloque: lab/solucion_framework.py
Abre lab/solucion_framework.py y sigue este mapa. Cada bloque corresponde a algo que ya escribiste a mano.
BLOQUE 1 — Cargar corpus → Documents
─────────────────────────────────────
JSON → list[Document] con metadata fare_class, route_type, categoria
¿Por qué? BM25Retriever y Chroma consumen Document, no dicts sueltos.
BLOQUE 2 — BM25Retriever
────────────────────────
BM25Retriever.from_documents(documentos); bm25_retriever.k = 9
¿Por qué k=9? Corpus de 9 políticas; queremos todos los candidatos
para que el ensemble tenga material para fusionar.
BLOQUE 3 — Vector store + retriever
────────────────────────────────────
HuggingFaceEmbeddings + Chroma.from_documents + as_retriever(k=9)
¿Por qué all-MiniLM-L6-v2? Modelo local ligero; suficiente para el lab.
BLOQUE 4 — EnsembleRetriever
─────────────────────────────
retrievers=[bm25, vector], weights=[0.4, 0.6]
¿Por qué? Replica tu rrf_fusion() de scratch con RRF c=60 interno.
BLOQUE 5 — Reranker + Compression retriever
────────────────────────────────────────────
CrossEncoderReranker(BGE, top_n=3) envuelto en ContextualCompressionRetriever
¿Por qué? Replica tu rerank por intersección, pero con cross-encoder real.
BLOQUE 6 — crear_retriever_filtrado()
──────────────────────────────────────
Filtra docs → reconstruye BM25 + Chroma + Ensemble + Compression
¿Por qué reconstruir todo? EnsembleRetriever no filtra; BM25 no tiene filter.
BLOQUE 7 — Ejecución sin/con filtro
────────────────────────────────────
compression_retriever.invoke(QUERY) vs crear_retriever_filtrado("Basic").invoke(QUERY)
¿Por qué? Demostrar el mismo patrón que expected.md del scratch.
Resultado esperado (mismo patrón que scratch):
| Modo | Top-3 fare_class | Ruido |
|---|---|---|
| Sin filtro | Top, Plus, Basic mezclados | Sí — pol_008 (Top) probablemente primero |
Con filtro Basic |
Solo Basic | No — pol_002, pol_003, pol_001 |
El framework puede rankear ligeramente distinto al scratch (embeddings reales vs BoW), pero el check de ruido debe ser el mismo: sin filtro hay tarifas incorrectas; con filtro, solo Basic.
13.11 Cuándo usar / NO usar cada pieza
| Pieza | Usar cuando | NO usar cuando | Gotcha principal |
|---|---|---|---|
BM25Retriever |
IDs exactos, jerga técnica, términos raros | Solo semántica conversacional | Sin filtro de metadata nativo |
| Vector retriever (Chroma) | Lenguaje natural, sinónimos | Solo búsqueda por código exacto | filter en Chroma no afecta a BM25 del ensemble |
EnsembleRetriever |
Caso general híbrido | Un retriever claramente dominante | weights ≠ alpha de suma ponderada (§4) |
CrossEncoderReranker |
Precisión crítica, top-k ruidoso | Latencia < 100ms, corpus pequeño | Necesita k alto en el retriever base |
ContextualCompressionRetriever |
Siempre que añadas reranker | — | El nombre confunde; es un wrapper de rerank |
| Filtro duro pre-corpus | Guardrail de seguridad (§7) | Filtro "blando" en prompt basta (raro) | Post-filtrar después del LLM es tarde |
13.12 Diagrama del pipeline completo (framework)
politicas.json
│
▼
list[Document] ──────────────────────────────────────────────┐
│ │
│ corpus completo (9 docs) │ docs_filtrados (3 Basic)
▼ ▼
┌─────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────────┐
│ BM25 │ │ Chroma+HF │ │ BM25 │ │ Chroma+HF │
│ k=9 │ │ Embeddings │ │ k=3 │ │ k=3 │
└────┬────┘ └──────┬───────┘ └────┬────┘ └──────┬───────┘
│ │ │ │
└──────┬───────┘ └──────┬───────┘
▼ ▼
EnsembleRetriever EnsembleRetriever
weights=[0.4,0.6] weights=[0.4,0.6]
RRF c=60 RRF c=60
│ │
▼ ▼
ContextualCompressionRetriever ContextualCompressionRetriever
+ BGE reranker top_n=3 + BGE reranker top_n=3
│ │
▼ ▼
SIN FILTRO: Top, Plus, Basic CON FILTRO: solo Basic
(ruido — §7) (correcto — §7)
13.13 Siguiente paso: escribe el framework tú mismo
- Lee
lab/enunciado.md— la sección Capa ③ tiene pistas escalonadas que apuntan aquí. - Intenta escribir
lab/solucion_framework.pysin mirar la solución. - Compara con
lab/solucion_framework.pyylab/solucion.md. - Cuando tengas
pipy red, ejecútalo y verifica que el patrón sin/con filtro coincide conlab/expected.md.
Cross-links útiles:
- Conceptos BM25: §3
- Fusión RRF: §4
- Cross-encoder: §5
- Filtro duro: §7
- Taller scratch:
lab/enunciado.md·lab/expected.md - LangChain base: M1 §11
Más allá de Lang*: los retrievers y el pipeline RAG completo también se construyen con LlamaIndex (query engines/retrievers), con Haystack y con el SDK nativo + Chroma — ver
../referencia/rag-sin-langchain.md.Panorama de estrategias: más allá de híbrido + rerank, existe un catálogo entero de arquitecturas RAG (HyDE, RAG-Fusion, RAPTOR, Contextual Retrieval, ColBERT, Self-RAG, CRAG, Adaptive/Agentic RAG…). Cuándo aplicar cada una en
../referencia/panorama-estrategias-rag.md.
14. Checkpoint
Lo sabes si puedes:
- Explicar la fórmula BM25 y por qué cada parámetro (IDF, k1, b) existe.
- Describir RRF y calcular manualmente el score fusionado de 3 documentos.
- Explicar por qué un cross-encoder es más preciso que un bi-encoder y cuándo no vale la latencia extra.
- Diseñar un filtro duro para un dominio de alta consecuencia y argumentar por qué no basta con una instrucción de prompt.
- Diseñar un multi-index con routing para el caso de telecom (3 índices, reglas de keyword + intent).
- Explicar cuándo un knowledge graph supera a los vectores y cuándo no.
- Mapear los 6 nodos de
retrievaly los 2 dequerya sus casos de uso. - Explicar qué hace cada retriever de LangChain (
BM25Retriever,EnsembleRetriever,ContextualCompressionRetriever) y mapearlo a lo que implementaste en scratch. - Escribir un pipeline LangChain híbrido + rerank + filtro duro sin copiar la solución del lab.
Qué repasar si algo no está claro:
- BM25: releer §3 con un corpus de 5 documentos de ejemplo y calcular los scores a mano.
- Cross-encoder: releer §5, ejecutar el scratch del taller para ver la diferencia con y sin reranker.
- Filtros duros: leer los §9 de los templates 03 y 08 para ver la justificación en contexto real.
- GraphRAG: explorar
examples/05-legal-contract-review/flow.jsony el nodostore.neo4jendocs/02-node-catalog.md. - LangChain retrievers: releer §13, escribir
lab/solucion_framework.pyguiado porlab/enunciado.md.