Saltar a contenido

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:

f"... {s.get('texto', '')[:150]}"   # 150 caracteres por fuente

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

  1. Decide quién redacta. Un solo punto de síntesis. Si es el agente, las herramientas devuelven datos y fuentes, no prosa.
  2. Retriever vs query engine. as_retriever para recuperar; as_query_engine solo si quieres que el framework redacte la respuesta final.
  3. 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.
  4. 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.
  5. 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)

  1. 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_engine contra as_retriever lo confirma.
  2. 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.
  3. 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.
  4. 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.