Quién redacta la respuesta en un RAG agéntico: el query engine que pagas dos veces
Nota para el equipo
Caso real del proyecto Avicena. Trata de una trampa sutil cuando mezclas un framework de RAG (LlamaIndex) con un agente que tiene su propio paso de síntesis: acabas redactando la respuesta dos veces, una de ellas a la basura, y encima la buena tapa un bug en la otra.
TL;DR
Nuestro agente ya redacta la respuesta en su nodo synthesize, pero la herramienta de
RAG usaba el query engine de LlamaIndex, que también redacta. Pagábamos la síntesis
dos veces (~50 s vs ~6 s) y, al quitar la redundante, las respuestas se volvieron pobres:
destapamos un bug que la síntesis "gratis" venía tapando.
El arreglo
Si el agente ya sintetiza, la herramienta debe recuperar, no redactar: usa
as_retriever, no as_query_engine. Y alimenta al sintetizador con el texto
completo del chunk, no con un recorte de 150 caracteres.
El confound que se llevó por delante un veredicto
El recorte de 150 caracteres contaminaba además el A/B de "denso vs híbrido": el camino denso colaba una síntesis con texto completo (vía LlamaIndex) y el híbrido no. Gran parte de "el híbrido responde peor" era este bug, no el ranking. Hubo que re-medir. Ver el artículo hermano sobre búsqueda híbrida.
El montaje
Nuestro agente (grafo tipo LangGraph) tiene un nodo final synthesize: coge los
resultados de las herramientas y redacta la respuesta en lenguaje natural. Una de esas
herramientas, search_documents, recuperaba documentos con LlamaIndex usando un wrapper
nuestro, get_query_engine:
# tools.py: la herramienta del agente (versión inicial)
engine = get_query_engine(collection="avicena_docs", top_k=top_k, filters=...)
response = engine.query(query)
return {"answer": str(response), "sources": [...]}
Y el wrapper, por dentro, no es más que el constructor de LlamaIndex:
# core/rag.py: nuestros wrappers sobre LlamaIndex
def get_query_engine(...):
return _build_index(...).as_query_engine(...) # recupera Y redacta con el LLM
def get_retriever(...):
return _build_index(...).as_retriever(...) # solo recupera, sin LLM
Parece inocente. No lo es. El problema está en la diferencia entre as_query_engine y
as_retriever.
Trampa 1: as_query_engine redacta con el LLM, y tú no lo necesitas
as_query_engine devuelve un query engine. Cuando lo invocas con .query(consulta),
ese objeto hace tres cosas en cadena: (1) embebe la consulta, (2) recupera los fragmentos
relevantes de la base vectorial y (3) llama al LLM para redactar una respuesta a
partir de ellos. Esa respuesta redactada es lo que obtienes con str(response).
Pero nuestro agente ya redacta en su nodo synthesize. Así que por cada pregunta
hacíamos dos llamadas de síntesis al LLM: la de LlamaIndex (que metíamos en answer) y
la del agente. La de LlamaIndex era trabajo tirado... casi.
Coste medido: el camino con el query engine tardaba ~50 s; el mismo retrieval con un
retriever (as_retriever, que solo embebe y recupera, sin LLM) baja a ~6 s. La diferencia
no era "buscar en Qdrant es lento" (eso son milisegundos), era la síntesis de más.
El arreglo: cambiar el wrapper que usa la herramienta, de get_query_engine a
get_retriever, y dejar que sintetice solo el agente.
# tools.py: versión arreglada
retriever = get_retriever(collection="avicena_docs", top_k=top_k, filters=...)
nodes = retriever.retrieve(query)
return {"answer": "", "sources": [...]} # que redacte el agente
Regla simple
Si tu agente ya sintetiza, usa un retriever (as_retriever), no un query engine
(as_query_engine). El query engine es para cuando quieres que LlamaIndex te dé la
respuesta final ya redactada.
Trampa 2: al quitar la síntesis "gratis", afloró un bug que tapaba
Aquí está lo interesante. Al cambiar a retriever (answer=""), las respuestas se
volvieron pobres. Resultó que las respuestas ricas de antes no las hacía el agente:
venían del answer de LlamaIndex, que arrastrábamos en el mensaje.
¿Por qué el agente no redactaba igual de bien? Porque el material que le llegaba al nodo
synthesize estaba recortado. El paso que serializa las fuentes para el LLM hacía esto:
Y antes, al formatear la fuente, el texto del chunk ya se había recortado a 300. O sea, el sintetizador del agente intentaba redactar a partir de fragmentos de 150 caracteres. Imposible dar una respuesta rica con eso.
Mientras LlamaIndex metía su síntesis completa (hecha con el texto íntegro de los chunks)
en answer, el agente se limitaba a reformatearla y nadie notaba que su propio
sintetizador estaba hambriento de contexto. Al retirar esa muleta, el bug quedó al
descubierto.
El arreglo: pasar el texto completo del chunk al sintetizador (chunks de ~1000 caracteres, 5 fuentes ≈ 5 KB, nada para el contexto del modelo).
Los tres estados, con la misma pregunta
La pregunta "¿Cuáles son los puntos fuertes de Qlik?" deja ver el recorrido entero. Mismo retrieval, mismo modelo; cambia solo quién redacta y con cuánto texto:
| Estado | ¿Redacta la tool? | Texto/fuente al agente | Latencia | Respuesta a "puntos fuertes de Qlik" |
|---|---|---|---|---|
as_query_engine (inicial) |
Sí (se descartaba) | irrelevante (ganaba la de LlamaIndex) | ~50 s | Rica, pero la hacía LlamaIndex |
as_retriever, recorte 150 |
No | 150 chars | ~6 s | Pobre |
as_retriever + texto completo |
No | ~1000 chars (chunk entero) | ~6 s | Rica, ahora la hace el agente |
Y las respuestas, textuales:
as_retriever con recorte a 150 (rápida pero pobre)
Qlik fue colocado en el cuartil superior para interés, según búsquedas en gartner.com y consultas de clientes [Gartner, 2017, p. 34]. No obstante, su ejecución en el mercado se ha visto moderada debido a los desafíos al soportar tanto QlikView establecido como la versión menos madura [Gartner, 2017, p. 34].
as_retriever con el texto completo (rápida y rica)
Los principales puntos fuertes de Qlik incluyen: Despliegue rápido (su motor en memoria escalable combina datos de múltiples fuentes en paneles interactivos); Facilidad de uso y visuales atractivos; Habilitación de usuarios (los usuarios de negocio se vuelven los principales desarrolladores de contenido); Momentum (cuartil superior en respuesta al mercado); Red de socios (ofrecen extensiones y servicios profesionales).
Misma búsqueda, misma latencia entre las dos últimas. Lo único que cambió fue cuánto texto recibía el sintetizador.
El bug que la doble síntesis tapaba
Este bug llevaba escondido desde antes, tapado por una redundancia (la doble síntesis). Y tiene un corolario incómodo para nuestro caso del híbrido (ver el artículo hermano sobre búsqueda híbrida):
Cómo nos contaminó el A/B del híbrido
El camino híbrido del backend siempre devolvía answer="" (solo fuentes). Así que el
híbrido siempre sintetizó desde los recortes de 150 caracteres. Parte de "el
híbrido responde peor que el denso" no era (solo) ranking: era que el denso, vía
LlamaIndex, colaba una síntesis con texto completo y el híbrido no.
Confound (variable de confusión)
Un factor que cambia a la vez que la variable que crees medir, y te impide saber a cuál atribuir el efecto. Comparábamos retrieval, pero uno de los caminos traía además una síntesis mejor alimentada. Tocaba re-medir el A/B con ambos caminos sintetizando el mismo material.
Y lo re-medimos
Re-corrida la batería con la síntesis arreglada, las tres configuraciones de retrieval (denso, híbrido+rerank, híbrido sin rerank) pasaron a dar respuestas ricas y comparables. La pregunta que antes "se rompía" con el híbrido salía bien en las tres. Conclusión: gran parte de lo que parecía un problema de retrieval era este recorte de 150 caracteres en la síntesis. Arreglar la síntesis subió la calidad de todas las configuraciones a la vez, y de paso invalidó un veredicto que ya dábamos por cerrado.
Qué nos llevamos
- Decide quién redacta. Un solo punto de síntesis. Si es el agente, las herramientas devuelven datos y fuentes, no prosa.
- Retriever vs query engine.
as_retrieverpara recuperar;as_query_enginesolo si quieres que el framework redacte la respuesta final. - Vigila los recortes de texto. Lo que se muestra al usuario (un snippet) y lo que
recibe el sintetizador (contexto suficiente) son cosas distintas; no las ates al mismo
límite. Un
[:150]puede estar decidiendo la calidad de tus respuestas sin que lo sepas. - Cuidado con las redundancias que "funcionan". Una doble síntesis que da buen resultado puede estar tapando que tu camino principal está roto. Cuando elimines la redundancia, vuelve a verificar la calidad, no solo la latencia.
- Separa retrieval de generación al medir. Fija el modelo de síntesis y el material que recibe; si no, no sabes qué estás comparando.
Anexo: cómo lo diagnosticamos (la receta)
- Mide latencia query engine vs retriever. Si el RAG tarda decenas de segundos y la
búsqueda vectorial son milisegundos, sospecha de una síntesis extra escondida en el
framework. Comparar
as_query_enginecontraas_retrieverlo confirma. - Rastrea de dónde sale la prosa. Pon
answer=""en la herramienta y mira si la respuesta empeora: si lo hace, la estaba redactando el framework, no tu agente. - Audita la serialización de fuentes. Busca recortes (
[:150],[:300]) en el paso que arma el contexto del sintetizador. Distingue el snippet de UI del material de síntesis. - Re-mide el A/B tras tocar la síntesis. Cualquier cambio en quién redacta o con cuánto texto invalida comparaciones de retrieval anteriores: vuelve a correrlas con ambos caminos sintetizando el mismo material.