RAG — Módulo 11: RAG avanzado, las técnicas que sí mueven la aguja
Undécimo módulo del Curso de RAG: query rewriting, multi-query con RRF, HyDE, descomposición de preguntas, parent-document retrieval, contextual retrieval de Anthropic, multi-vector, GraphRAG y agentic RAG. Cada técnica con su costo real, su mejora esperada y un orden de adopción.
☰ Índice del contenido
- La tesis del módulo
- Bloque 1: transformar la query
- Query rewriting: reescribir para que se pueda buscar
- Query expansion: agregar sinónimos
- Multi-query: buscar varias veces y fusionar
- HyDE: embeber una respuesta inventada
- Descomposición de preguntas complejas
- Bloque 2: estructura del índice
- Small-to-big (parent-document retrieval)
- Contextual retrieval
- Resúmenes por documento como capa de routing
- Multi-vector: varios embeddings por chunk
- GraphRAG: para preguntas que ningún chunk responde
- Agentic RAG: el retrieval como decisión, no como paso
- La tabla que ordena todo
- Orden de adopción recomendado
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 10 construiste un golden dataset y un runner de métricas. Ese es el prerrequisito de este módulo, y no es una formalidad: todo lo que viene a continuación son técnicas que a veces mejoran el sistema y siempre suman latencia, costo y superficie de bugs. Sin una forma de medir, agregar RAG avanzado es agregar complejidad con la esperanza de que sea mejora. Con evaluación, cada una de estas técnicas es una hipótesis que aceptas o botas en una tarde. Vamos a ver cada una con lo mismo: qué problema resuelve, cómo funciona, cuánto cuesta y cuándo vale la pena.
La tesis del módulo
El RAG avanzado no es una lista de técnicas que hay que implementar todas: es un menú de soluciones a problemas específicos. La pregunta correcta nunca es “¿implemento HyDE?”, sino “¿mi retrieval falla porque la pregunta no se parece al documento?”. Si no sabes cuál es tu falla, ninguna técnica avanzada te va a servir — solo vas a tener un sistema lento con los mismos errores.
Bloque 1: transformar la query
Es el bloque más rentable de todos, porque ataca un supuesto que el RAG básico da por sentado: que la pregunta del usuario, tal como llegó, es una buena query de búsqueda. Casi nunca lo es. Es corta, tiene pronombres, usa el vocabulario del usuario y no el del documento, y a veces contiene tres preguntas mezcladas.
Query rewriting: reescribir para que se pueda buscar
Problema que resuelve: en un chat multiturno, las preguntas de seguimiento son incompletas.
Usuario: ¿Cuál es la política de despacho a regiones?
Sistema: Entre 3 y 5 días hábiles, gratis sobre $50.000.
Usuario: ¿Y cuánto cuesta si es menos?
Buscar “¿Y cuánto cuesta si es menos?” en tu índice devuelve ruido puro. No hay embedding ni BM25 que salve esa query: le falta el sujeto. La solución es una llamada previa a un LLM que, con el historial, produzca una query autocontenida: “costo de despacho a regiones para compras bajo $50.000”.
"""Query rewriting para chat multiturno.
Convierte una pregunta dependiente del contexto en una autocontenida.
"""
from openai import OpenAI
client = OpenAI()
PROMPT_REESCRITURA = """Dada una conversación y la última pregunta del
usuario, reescribe esa pregunta como una consulta de búsqueda
AUTOCONTENIDA: que se entienda sin leer la conversación.
Reglas:
- Reemplaza pronombres y referencias ("eso", "ahí", "y si...") por los
sustantivos concretos de la conversación.
- No respondas la pregunta. No agregues información que no esté.
- Si la pregunta YA es autocontenida, devuélvela igual.
- Devuelve solo la consulta, sin comillas ni explicación.
CONVERSACIÓN:
{historial}
ÚLTIMA PREGUNTA: {pregunta}
CONSULTA AUTOCONTENIDA:"""
def reescribir(pregunta: str, historial: list[dict]) -> str:
if not historial:
return pregunta # primer turno: no hay nada que resolver
# Solo los últimos turnos: el historial completo agrega ruido y tokens
ultimos = historial[-6:]
texto = "\n".join(f"{m['role']}: {m['content']}" for m in ultimos)
r = client.chat.completions.create(
model="gpt-4o-mini", # tarea simple: modelo chico y barato
messages=[{"role": "user", "content": PROMPT_REESCRITURA.format(
historial=texto, pregunta=pregunta)}],
temperature=0,
max_tokens=80,
)
return r.choices[0].message.content.strip()
Costo: una llamada a un modelo chico, entre 30 y 100 ms. Cuándo vale la pena: siempre que haya conversación. Es la técnica avanzada con mejor relación mejora/costo de todo el módulo, y la única que yo consideraría obligatoria en un chatbot.
Query expansion: agregar sinónimos
Problema: el usuario dice “devolver un producto” y el documento dice “ejercer el derecho a retracto”. El embedding cubre parte de esa distancia, pero BM25 no cubre nada — es coincidencia literal de términos.
La query expansion agrega términos relacionados a la query antes de buscar: sinónimos, la forma técnica del término, la sigla y su expansión. Se puede hacer con un LLM, con un diccionario de sinónimos del dominio o con ambos. El diccionario es más barato, determinista y suficiente en dominios acotados; en la práctica, mantener un archivo con 200 pares “término de usuario → término del documento” rinde más que muchas técnicas sofisticadas.
Ojo: la expansión indiscriminada degrada la precisión. Si agregas ocho sinónimos a una query de tres palabras, BM25 empieza a traer documentos que hablan de cualquiera de esos ocho términos. Expande poco (2-3 términos), y solo en la rama léxica de la búsqueda híbrida.
Multi-query: buscar varias veces y fusionar
Problema: una sola formulación de la pregunta explora una sola zona del espacio de embeddings. Si el documento correcto está redactado desde otro ángulo, no lo alcanzas.
Solución: generar 3-5 variantes de la pregunta, buscar con todas en paralelo y fusionar los rankings con RRF (Reciprocal Rank Fusion, el mismo mecanismo del módulo 7). Los chunks que aparecen alto en varias variantes suben; los que aparecen en una sola, bajan. Es un mecanismo de consenso.
"""Multi-query con fusión RRF."""
import json
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
PROMPT_VARIANTES = """Genera {n} reformulaciones distintas de esta
pregunta para buscar en una base documental. Cada variante debe atacar
la misma intención desde un ángulo distinto:
- una con vocabulario técnico/formal
- una coloquial, como la diría un usuario
- una enfocada en el dato concreto que se busca
Devuelve JSON: {{"variantes": ["...", "..."]}}
PREGUNTA: {pregunta}"""
def generar_variantes(pregunta: str, n: int = 3) -> list[str]:
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_VARIANTES.format(
n=n, pregunta=pregunta)}],
response_format={"type": "json_object"},
temperature=0.5, # queremos diversidad entre variantes
)
variantes = json.loads(r.choices[0].message.content)["variantes"]
return [pregunta] + variantes # la original SIEMPRE va incluida
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion: cada lista aporta 1/(k+posicion).
k=60 es el valor del paper original; amortigua las posiciones altas
para que un primer lugar no domine al resto."""
puntajes = defaultdict(float)
for ranking in rankings:
for pos, doc_id in enumerate(ranking, start=1):
puntajes[doc_id] += 1.0 / (k + pos)
return sorted(puntajes.items(), key=lambda x: -x[1])
def buscar_multi_query(pregunta: str, k: int = 10) -> list[str]:
variantes = generar_variantes(pregunta)
# Las búsquedas son independientes: van en paralelo, no en serie.
# Esto es lo que evita que la latencia se multiplique por 4.
with ThreadPoolExecutor(max_workers=len(variantes)) as pool:
rankings = list(pool.map(lambda v: buscar_hibrido(v, k=20),
variantes))
fusionado = rrf(rankings)
return [doc_id for doc_id, _ in fusionado[:k]]
Costo: una llamada de LLM chico (~100 ms) más N búsquedas en paralelo. Si las paralelizas bien, la latencia añadida es la del LLM, no la de las búsquedas. Cuándo vale la pena: corpus con vocabulario heterogéneo y preguntas ambiguas. En un FAQ acotado no aporta.
HyDE: embeber una respuesta inventada
HyDE (Hypothetical Document Embeddings, Gao et al., 2022) parte de una observación incómoda: la búsqueda vectorial compara el embedding de una pregunta contra el embedding de documentos, y una pregunta y un documento son objetos textuales distintos. “¿Cuánto dura la garantía?” y “La garantía legal en Chile cubre 6 meses por fallas de fábrica” no se parecen tanto como uno esperaría — uno es interrogativo y corto, el otro declarativo y expositivo.
La idea de HyDE: pedirle al LLM que invente una respuesta plausible a la pregunta, sin consultar nada, y embeber esa respuesta falsa en vez de la pregunta. El documento hipotético comparte estructura, tono y vocabulario con los documentos reales, así que cae mucho más cerca en el espacio vectorial. No importa que los datos de la respuesta inventada sean falsos: solo se usa como sonda de búsqueda y se descarta después.
BÚSQUEDA CLÁSICA
pregunta ──► ┌───────────┐ ┌──────────────┐
│ embedding │ ──► │ índice vec. │
└───────────┘ └──────────────┘
(interrogativa, corta, otro registro que el corpus)
HyDE
pregunta ──► ┌───────────┐
│ LLM │
│ inventa │
└─────┬─────┘
│ documento hipotético
▼
┌───────────┐ ┌──────────────┐
│ embedding │ ──► │ índice vec. │
└───────────┘ └──────────────┘
(declarativo, mismo registro que el corpus)
"""HyDE: generar un documento hipotético y embeberlo."""
PROMPT_HYDE = """Escribe un párrafo que responda esta pregunta como si
fuera un extracto de la documentación oficial de la empresa.
- Estilo declarativo y expositivo, como un manual, no como un chat.
- Entre 60 y 100 palabras.
- Inventa los datos concretos si no los sabes: da lo mismo que sean
falsos, este texto solo se usa como sonda de búsqueda.
- No digas que no sabes. No hagas preguntas.
PREGUNTA: {pregunta}
EXTRACTO:"""
def buscar_hyde(pregunta: str, k: int = 10) -> list[str]:
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user",
"content": PROMPT_HYDE.format(pregunta=pregunta)}],
temperature=0.3,
max_tokens=200,
)
doc_hipotetico = r.choices[0].message.content
# Truco práctico: embeber pregunta + hipótesis juntas evita que una
# alucinación muy desviada arrastre la búsqueda a otro tema.
sonda = f"{pregunta}\n\n{doc_hipotetico}"
return buscar_vectorial(sonda, k=k)
Cuándo funciona: preguntas conceptuales o abiertas (“cómo funciona el proceso de onboarding”), dominios donde el modelo tiene conocimiento general razonable, y corpus con lenguaje formal muy distinto al del usuario.
Cuándo NO funciona: preguntas sobre entidades específicas de tu empresa que el modelo desconoce (va a inventar un documento sobre otra cosa y te va a alejar del resultado correcto), preguntas con códigos o números exactos, y sistemas con presupuesto de latencia ajustado. HyDE es la técnica de este módulo con resultados más variables entre proyectos: hay evaluaciones donde sube el recall varios puntos y otras donde lo baja. Es exactamente el tipo de técnica que hay que pasar por el golden dataset antes de dejarla.
Descomposición de preguntas complejas
Problema: “¿Cuál es la diferencia entre la garantía legal y la del fabricante, y cuál conviene usar si el producto falla a los 8 meses?” son tres preguntas. Ningún chunk las responde y una sola búsqueda las mezcla.
Solución: un LLM descompone en sub-preguntas, cada una se resuelve con su propio retrieval, y la generación final recibe el contexto acumulado (o las respuestas parciales). Es la versión no agéntica de lo que después hace el agentic RAG, y sirve bien cuando las sub-preguntas son independientes entre sí. Cuando cada sub-pregunta depende de la respuesta anterior, ya estás en territorio agéntico.
Bloque 2: estructura del índice
Las técnicas anteriores arreglan la query. Estas arreglan lo que hay al otro lado.
Small-to-big (parent-document retrieval)
El módulo 3 dejó abierto un trade-off que parecía irresoluble: chunks chicos buscan mejor (el embedding es más específico, menos diluido) pero le dan al LLM contexto insuficiente; chunks grandes dan buen contexto pero buscan peor.
Small-to-big lo resuelve dejando de usar la misma unidad para las dos cosas. Indexas chunks chicos (200-300 tokens) para buscar con precisión, pero cuando uno hace match, no se lo entregas al LLM: le entregas el chunk padre al que pertenece (1000-2000 tokens, o la sección completa del documento).
ÍNDICE (chunks chicos) ENTREGA (chunk padre)
┌────────────────┐
│ hijo 1 200t │
├────────────────┤ ┌────────────────────────┐
│ hijo 2 200t │ ── match ► │ PADRE 1200 tokens │
├────────────────┤ ▲ │ secciones 1 a 6 del │
│ hijo 3 200t │ │ │ documento completo │
├────────────────┤ │ └───────────┬────────────┘
│ hijo 4 200t │ │ │
└───────┬────────┘ │ ▼
│ │ va al prompt
└─────────────┘
buscar preciso, entregar completo
"""Parent-document retrieval: indexar chico, entregar grande."""
from dataclasses import dataclass
@dataclass
class Hijo:
id: str
texto: str
padre_id: str # la clave de todo el mecanismo
def indexar_small_to_big(documentos: list[dict]) -> None:
"""El padre NO se embebe: se guarda en un docstore (Redis, SQLite,
lo que sea) y solo se recupera por id. Se embeben los hijos."""
for doc in documentos:
padres = cortar(doc["texto"], tam=1200, solape=100)
for j, padre in enumerate(padres):
padre_id = f"{doc['id']}::p{j}"
docstore.set(padre_id, padre) # texto completo
hijos = cortar(padre, tam=250, solape=40)
for i, hijo in enumerate(hijos):
indice_vectorial.add(
id=f"{padre_id}::h{i}",
vector=embed(hijo),
metadata={"padre_id": padre_id, "doc": doc["id"]},
)
def buscar_small_to_big(pregunta: str, k: int = 8) -> list[str]:
# Se buscan más hijos de los necesarios porque varios van a
# colapsar en el mismo padre al deduplicar.
hijos = indice_vectorial.search(embed(pregunta), k=k * 3)
vistos, padres = set(), []
for h in hijos:
pid = h.metadata["padre_id"]
if pid not in vistos: # deduplicar: un padre una sola vez
vistos.add(pid)
padres.append(docstore.get(pid))
if len(padres) >= k:
break
return padres
Costo de implementación: medio (hay que mantener el docstore y la relación padre-hijo). Costo por consulta: cero llamadas extra de LLM. Es una de las mejores técnicas de la relación mejora/costo y casi siempre vale la pena cuando tus documentos son largos y estructurados.
Contextual retrieval
Es la técnica que publicó Anthropic en 2024 y ataca la falla más molesta del chunking: un chunk sacado de su documento pierde el contexto que lo hacía interpretable. Un fragmento que dice “El plazo es de 30 días corridos desde la recepción” no dice de qué plazo habla, de qué contrato, ni de qué año. Al embeberlo, ese vector queda flotando sin referencia, y cuando alguien pregunta “¿cuánto tiempo tengo para reclamar en el contrato de arriendo 2025?”, el chunk no matchea.
La solución es directa: antes de embeber cada chunk, un LLM le antepone una o dos frases que lo sitúan dentro del documento completo.
"""Contextual retrieval: contextualizar cada chunk antes de indexarlo."""
import anthropic
client = anthropic.Anthropic()
PROMPT_CONTEXTO = """<documento>
{documento_completo}
</documento>
Aquí está un fragmento que queremos situar dentro del documento:
<fragmento>
{chunk}
</fragmento>
Escribe 1 o 2 frases breves que sitúen este fragmento dentro del
documento completo, para mejorar su recuperación en una búsqueda.
Menciona de qué documento y de qué sección proviene, y a qué entidad,
producto o periodo se refiere.
Responde SOLO con esas frases, sin preámbulo."""
def contextualizar(documento: str, chunk: str) -> str:
r = client.messages.create(
model="claude-haiku-4-5", # modelo chico: la tarea es simple
max_tokens=150,
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": PROMPT_CONTEXTO.format(
documento_completo=documento, chunk=chunk),
# PROMPT CACHING: el documento completo se repite en
# cada chunk del mismo doc. Cacheado, se paga una vez
# y baja el costo del pipeline en un orden de magnitud.
"cache_control": {"type": "ephemeral"},
},
],
}],
)
return r.content[0].text.strip()
def indexar_contextual(doc_id: str, documento: str) -> None:
for i, chunk in enumerate(cortar(documento, tam=400, solape=60)):
contexto = contextualizar(documento, chunk)
# El texto contextualizado se usa para AMBOS índices: el vector
# y el BM25. Ese es el punto: el contexto también aporta términos
# literales (nombre del contrato, año) que la búsqueda léxica usa.
enriquecido = f"{contexto}\n\n{chunk}"
indice_vectorial.add(id=f"{doc_id}::{i}", vector=embed(enriquecido),
metadata={"texto_original": chunk})
indice_bm25.add(id=f"{doc_id}::{i}", texto=enriquecido)
El resultado reportado por Anthropic (septiembre de 2024, sobre sus propios benchmarks): contextualizar los embeddings reduce un 35% la tasa de fallos de recuperación en el top-20; hacerlo también sobre el índice BM25 la reduce un 49%; y sumarle un reranker encima llega al 67% — una de las mejoras acumuladas más grandes documentadas públicamente para un pipeline de RAG. Es de las pocas técnicas donde la evidencia pública es fuerte y consistente.
El costo es real y hay que decirlo: una llamada de LLM por cada chunk del corpus. Con 100.000 chunks eso es 100.000 llamadas. Tres mitigaciones lo vuelven manejable: usar el modelo más chico disponible, activar prompt caching sobre el documento completo (que es el token que se repite), y procesarlo por lotes en modo batch, que suele costar la mitad. Es costo offline y por única vez por documento, no por consulta: si tu corpus cambia poco, se amortiza rapidísimo.
Resúmenes por documento como capa de routing
En corpus con fuentes muy distintas (manual técnico, base legal, notas de release), indexar un resumen por documento en un índice aparte permite un primer filtro: la query se busca primero contra los resúmenes para decidir a qué documentos o colecciones bajar, y recién ahí se buscan chunks dentro de ese subconjunto. Reduce el espacio de búsqueda y sube la precisión, a costa de un punto de falla nuevo: si el routing se equivoca, ningún chunk correcto llega, aunque exista.
Multi-vector: varios embeddings por chunk
Un solo vector tiene que representar todo lo que un chunk significa, y eso lo obliga a un promedio. Multi-vector indexa varias representaciones que apuntan al mismo chunk:
- El texto del chunk tal cual (captura el contenido literal).
- Su resumen (captura el tema, sin ruido de detalle).
- Preguntas hipotéticas que ese chunk responde (esto es HyDE al revés: en vez de convertir la pregunta en documento en tiempo de consulta, conviertes el documento en preguntas en tiempo de indexación, que es más barato porque se hace una vez).
Las tres apuntan al mismo chunk; en la búsqueda, el match por cualquiera de ellas lo trae. Sube el recall y multiplica por tres el tamaño del índice.
GraphRAG: para preguntas que ningún chunk responde
Todo lo anterior asume que la respuesta está en algún fragmento y el trabajo es encontrarlo. Hay una clase de preguntas donde ese supuesto es falso:
- “¿Cuáles son los temas principales de estos 500 documentos?”
- “¿Cómo evolucionó la posición de la empresa sobre este tema entre 2020 y 2025?”
- “¿Qué proveedores aparecen relacionados con más de un incidente?”
Son preguntas globales o agregadas: la respuesta no está en ningún chunk, está distribuida en todo el corpus. Un top-10 de fragmentos no puede responderlas, por definición.
GraphRAG construye, en tiempo de indexación, un grafo de conocimiento: un LLM recorre los documentos extrayendo entidades (personas, empresas, productos, conceptos) y relaciones entre ellas (“Empresa A es proveedor de Producto B”, “Incidente C afectó a Cliente D”). Sobre ese grafo se detectan comunidades (grupos de nodos densamente conectados) y se genera un resumen por comunidad. Al responder una pregunta global, el sistema no recupera chunks: recupera y sintetiza resúmenes de comunidades.
La honestidad del caso: es caro de construir y caro de mantener. La extracción de entidades son múltiples llamadas de LLM por documento, el grafo hay que actualizarlo cuando el corpus cambia, y la resolución de entidades (que “Banco Estado”, “BancoEstado” y “BE” sean el mismo nodo) es un problema clásico y sucio. Brilla en investigación, análisis de corpus y due diligence. Es exagerado para un FAQ de productos, y ese es el caso de la gran mayoría de los proyectos.
Agentic RAG: el retrieval como decisión, no como paso
En todo lo visto hasta aquí, el retrieval es un paso fijo del pipeline: llega una pregunta, se busca una vez, se responde. Agentic RAG invierte eso: el retrieval se convierte en una tool (herramienta) que el modelo decide cuándo usar, con qué query y cuántas veces.
Eso habilita cuatro comportamientos que un pipeline fijo no tiene:
- Decidir si buscar. A “hola” y a “resume lo que me dijiste” no hay que buscarles nada. Un pipeline fijo igual busca, gasta y mete ruido.
- Búsqueda iterativa. Buscar, leer lo que llegó, notar que falta un dato, buscar de nuevo con una query mejor informada. Es lo que hace un humano investigando.
- Routing entre fuentes. Elegir entre el índice de documentación, el de tickets, una API de estado de pedidos o una consulta SQL, según lo que pida la pregunta.
- Self-correction. Evaluar si el contexto recuperado alcanza para responder y, si no, reformular y volver a buscar antes de contestar.
START ──► ┌──────────────┐
│ pregunta │
└──────┬───────┘
│
▼
┌──────────────┐ no busca ┌────────────┐
│ ¿necesita │ ──────────► │ responde │
│ contexto? │ │ directo │
└──────┬───────┘ └─────┬──────┘
busca │ │
▼ │
┌──────────────┐ │
┌────► │ tool search │ │
│ │ query + k │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ ¿el contexto │ │
│ │ alcanza? │ │
│ └──┬────────┬──┘ │
│insuf. │ │ suficiente │
│ ▼ ▼ │
│ ┌──────────┐ ┌────────────┐ │
│ │ refinar │ │ responde │ ──────────┴──► END
│ │ query │ │ con citas │
│ └────┬─────┘ └────────────┘
│ │
└───────┘
tope 3 vueltas
Implementado, es un agente con una tool de búsqueda: exactamente el patrón de tools y ReAct y de los flujos agénticos.
"""Agentic RAG: el retrieval como tool, con búsqueda iterativa."""
import json
TOOLS = [{
"type": "function",
"function": {
"name": "buscar_documentacion",
"description": (
"Busca en la documentación interna de la empresa. Úsala "
"cuando la pregunta requiera datos concretos de políticas, "
"productos o procedimientos. Puedes llamarla varias veces "
"con queries distintas si la primera no alcanzó."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Consulta de búsqueda autocontenida y específica. "
"No copies la pregunta del usuario: escribe la "
"consulta que mejor recupere el documento."
),
},
"fuente": {
"type": "string",
"enum": ["politicas", "productos", "tickets"],
},
},
"required": ["query"],
},
},
}]
SYSTEM = """Respondes usando SOLO información recuperada con tus tools.
- Si la pregunta necesita datos de la empresa, busca antes de responder.
- Si el resultado no alcanza, reformula la query y busca de nuevo
(máximo 3 búsquedas). No busques dos veces lo mismo.
- Si después de buscar no tienes la información, dilo explícitamente.
- Cita siempre el id del fragmento del que sacaste cada dato."""
def agentic_rag(pregunta: str, max_iter: int = 3) -> str:
mensajes = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": pregunta}]
for _ in range(max_iter):
r = client.chat.completions.create(
model="gpt-4o", messages=mensajes,
tools=TOOLS, temperature=0,
)
msg = r.choices[0].message
mensajes.append(msg)
if not msg.tool_calls:
return msg.content # el modelo decidió que ya puede responder
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
chunks = buscar_hibrido(args["query"],
fuente=args.get("fuente"), k=5)
mensajes.append({
"role": "tool",
"tool_call_id": tc.id,
"content": formatear_chunks(chunks), # con ids para citar
})
# Corte duro: sin tope, un agente puede quedarse buscando para siempre
mensajes.append({"role": "user",
"content": "Responde con lo que tienes o di que no "
"encontraste la información."})
return client.chat.completions.create(
model="gpt-4o", messages=mensajes, temperature=0
).choices[0].message.content
El costo del agentic RAG es el que más se subestima: cada iteración es una llamada completa al modelo grande con todo el contexto acumulado. Una pregunta que en el pipeline fijo costaba una llamada, aquí puede costar cuatro y tardar cinco segundos. Vale la pena cuando las preguntas son genuinamente multi-paso o hay varias fuentes; es un desperdicio en un FAQ de una sola fuente.
La tabla que ordena todo
| Técnica | Mejora esperada | Costo implementación | Costo por consulta | Latencia añadida | Cuándo aplicarla |
|---|---|---|---|---|---|
| Reranking | Alta (MRR) | Bajo | Centavos | +100-200 ms | Casi siempre |
| Búsqueda híbrida | Alta (recall) | Medio | ~0 | +20-40 ms | Casi siempre |
| Query rewriting | Alta en chat | Bajo | 1 LLM chico | +50-100 ms | Si hay conversación |
| Query expansion | Media | Bajo | ~0 con diccionario | +5 ms | Vocabulario dispar |
| Multi-query + RRF | Media | Bajo | 1 LLM + N búsq. | +100-150 ms | Preguntas ambiguas |
| HyDE | Variable | Bajo | 1 LLM chico | +200-400 ms | Preguntas conceptuales |
| Descomposición | Alta en multi-parte | Medio | 1 LLM + N ciclos | +300 ms a 1 s | Preguntas compuestas |
| Parent-document | Alta | Medio | ~0 | ~0 | Documentos largos |
| Contextual retrieval | Muy alta | Medio | 0 (es offline) | 0 | Corpus estable |
| Multi-vector | Media-alta | Medio | 0 (es offline) | ~0 | Corpus heterogéneo |
| GraphRAG | Alta solo en global | Muy alto | Alto | +1-3 s | Preguntas agregadas |
| Agentic RAG | Alta en multi-paso | Alto | 2-5 llamadas grandes | +2-5 s | Multi-fuente, multi-paso |
Orden de adopción recomendado
No implementes de arriba abajo la tabla: sigue este orden y mide después de cada paso. Si un paso no mejora tu golden dataset, sáltalo y no lo agregues por completitud.
- Reranker. La mejora más grande por la menor cantidad de código. Empieza aquí siempre.
- Búsqueda híbrida. Arregla la clase de fallas que el vector solo nunca va a cubrir: códigos, siglas, nombres propios.
- Query rewriting. Obligatorio si hay chat multiturno; sin esto, el segundo turno de cada conversación está roto.
- Contextual retrieval. Costo offline por única vez, mejora sostenida. El mejor “gasta una vez, cobra siempre” del módulo.
- Parent-document retrieval. Si tus documentos son largos y las respuestas necesitan contexto alrededor.
- Multi-query o HyDE. Solo si la evaluación muestra que las fallas son de formulación de la pregunta.
- Agentic RAG. Cuando haya varias fuentes o preguntas multi-paso reales, no antes.
- GraphRAG. Solo si tu caso de uso son preguntas globales sobre el corpus. Si dudas, no es tu caso.
Ojo: cada técnica que agregas suma latencia, costo, dependencias y superficie de bugs, y ninguna sirve si no la mides con el golden dataset del módulo 10. He visto pipelines con HyDE, multi-query, GraphRAG y tres rerankers encadenados que rendían peor que un híbrido simple con reranker, tardaban ocho segundos y costaban diez veces más. El RAG “avanzado” que nadie evaluó suele ser peor que el RAG simple que sí se midió.
Errores comunes
- Implementar técnicas por lista de features. “Le pusimos HyDE, multi-query y GraphRAG” no es una arquitectura, es acumulación. Cada técnica debe entrar contestando una falla concreta que viste en tu evaluación.
- No paralelizar multi-query. Generar 4 variantes y buscarlas en serie cuadruplica la latencia sin necesidad. Las búsquedas son independientes: van en un pool de hilos.
- Usar HyDE en dominios que el modelo desconoce. Si le preguntas por un producto interno que el LLM nunca vio, el documento hipotético va a hablar de otra cosa y va a arrastrar la búsqueda lejos del chunk correcto. HyDE amplifica el conocimiento previo del modelo, para bien y para mal.
- Contextual retrieval sin prompt caching. El documento completo se repite en el prompt de cada chunk. Sin caching pagas ese documento entero N veces y el costo se dispara. Con caching, el pipeline baja de precio en un orden de magnitud.
- Agentic RAG sin tope de iteraciones. Un agente que puede volver a buscar puede volver a buscar para siempre. Tope duro de 3 y una salida forzada que obligue a responder o a admitir que no encontró.
- Olvidar deduplicar en parent-document. Si cinco hijos del mismo padre matchean y no deduplicas, le pasas el mismo texto cinco veces al LLM: pagas cinco veces y llenas la ventana con una sola sección.
- Perder la trazabilidad al transformar la query. Si reescribes, expandes y descompones, guarda la query original en los logs. Si no, cuando alguien reclame por una respuesta mala vas a estar depurando una pregunta que el usuario nunca hizo.
La frase para llevarse
“El RAG avanzado no se elige del catálogo: se receta contra un síntoma que mediste. Una técnica sin diagnóstico es solo latencia con buena prensa.”
Qué viene
Módulo 12, el último del curso: RAG en producción. Todo lo que hemos construido en once módulos funciona en tu notebook con un corpus tuyo y un solo usuario. Producción es otra cosa: la arquitectura de referencia completa, cómo actualizar el índice sin downtime cuando cambia el modelo de embeddings, el filtrado por permisos que evita que un RAG mal hecho se convierta en una fuga de datos, prompt injection a través de documentos que alguien subió, el presupuesto de latencia etapa por etapa, el desglose de costo por consulta y sus palancas, qué loggear para detectar que el sistema se está degradando, y un checklist de producción para pegar en el Jira antes de salir a la calle.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.