Módulo 4: Recuperación agéntica en el bucle

Recuperación multi-hop

Descripción

La Lección 03 resolvió un caso: la misma pregunta, dos intentos, la segunda query mejor formulada que la primera. Esta lección resuelve un caso distinto, aunque parecido a primera vista: una pregunta que en realidad son dos preguntas sobre temas independientes, empaquetadas en una sola frase. "¿Cuál es la política de cancelación de Reservo, y todas las salas incluyen wifi de alta velocidad sin costo extra?" no es una pregunta que una reformulación pueda arreglar — es una pregunta que necesita dos búsquedas separadas, con dos queries distintas, porque cada mitad vive en un documento distinto del corpus.

A esto se le llama recuperación multi-hop: en vez de una sola llamada a search_docs que intenta cubrir todo, el agente hace varios "saltos" (hops), cada uno con su propia query enfocada en una sola necesidad de información. Vas a ver, con evidencia ejecutada, algo que no es obvio hasta que lo mides: meter las dos preguntas en una sola query no solo no ayuda — entierra una de las dos señales tan profundo en el ranking que, para fines prácticos, desaparece.

Conexión con el módulo

Esta lección generaliza el patrón de "una segunda búsqueda" de la Lección 03 a preguntas compuestas por temas genuinamente distintos, no una reformulación del mismo tema. La Lección 05 retoma esta misma idea de descomponer una pregunta compuesta, pero cuando una de las dos partes necesita una tool estructurada (get_quote) en vez de una segunda búsqueda.


Analogía: dos preguntas al mismo mostrador, dos viajes al archivo

El investigador de la introducción del módulo recibe una pregunta compuesta: "¿cuál es la política de cancelación, y hay wifi en todas las salas?" Podría intentar hacer un solo pedido al archivo con las dos partes juntas — pero el archivista, al escuchar una pregunta larga que mezcla dos temas, no sabe cuál de los dos priorizar, y es fácil que la carpeta que traiga responda bien a uno de los dos temas y deje al otro completamente afuera. Un investigador metódico separa la pregunta en dos pedidos concretos, uno por vez: primero "tráigame la política de cancelación", después "tráigame información sobre wifi en las salas". Dos viajes al archivo, sí — pero cada uno vuelve con la carpeta correcta, en vez de un viaje que vuelve con una sola carpeta a medias.


El problema, medido: una sola query combinada

Antes de ver la solución, hay que ver el problema con números reales. Esta es la pregunta compuesta, convertida en una sola query de search_docs:

from rag_index import INDEX

combined_query = "What is the cancellation policy and is wifi included in every room?"
hits = INDEX.search(combined_query, k=57)  # k=57: el corpus entero, solo para medir

for rank, (chunk, score) in enumerate(hits[:6], start=1):
    print(f"  #{rank}  score={score:<7} {chunk.chunk_id}")

# ¿en que posicion aparece el chunk que de verdad responde "hay wifi en
# todas las salas" (wifi-and-equipment-faq-000, no cualquier chunk del doc)?
for rank, (chunk, score) in enumerate(hits, start=1):
    if chunk.chunk_id == "wifi-and-equipment-faq-000":
        print(f"\nwifi-and-equipment-faq-000: #{rank}  score={score}")
        break

Qué esperar:

  #1  score=12.158  cancellation-policy-003
  #2  score=10.113  no-show-policy-000
  #3  score=9.466   cancellation-policy-002
  #4  score=6.9     no-show-policy-001
  #5  score=6.086   operations-manual-raw-003
  #6  score=5.724   focus-room-manual-000

wifi-and-equipment-faq-000: #14  score=3.816

Esto es contundente: la mitad de la pregunta sobre cancelación domina los primeros lugares —tres chunks distintos de cancellation-policy, uno de no-show-policy, y ninguno de wifi-and-equipment-faq a la vista— y el chunk que de verdad responde la mitad sobre wifi (wifi-and-equipment-faq-000, el que dice "Yes. All Reservo rooms include high-speed wifi at no extra charge") recién aparece en el puesto #14 de 57, con un score (3.816) menos de un tercio del top-1. Con k=3 —el valor típico que usarías en producción— la mitad sobre wifi no aparece en absoluto. No es que BM25 "ignore" la pregunta de wifi: es que las palabras de "cancellation policy" son, en este corpus, más numerosas y más discriminantes que las de "wifi included every room", así que su suma de contribuciones domina el ranking completo, ahogando la otra mitad de la pregunta.


La solución: dos hops, cada uno con su propia query

En vez de una query combinada, dos llamadas independientes a search_docs, cada una enfocada en un solo tema:

from search_docs_tool import search_docs

hop1 = search_docs("What is Reservo's cancellation policy?", k=3)
hop2 = search_docs("Does every room include high-speed wifi at no extra charge?", k=3)

print("--- hop 1: politica de cancelacion ---")
for r in hop1:
    print(f"  score={r['score']:<7} doc_id={r['doc_id']:<24} {r['chunk_id']}")

print("--- hop 2: wifi en todas las salas ---")
for r in hop2:
    print(f"  score={r['score']:<7} doc_id={r['doc_id']:<24} {r['chunk_id']}")

Qué esperar:

--- hop 1: politica de cancelacion ---
  score=10.734  doc_id=cancellation-policy       cancellation-policy-003
  score=7.217   doc_id=refund-policy             refund-policy-000
  score=6.364   doc_id=no-show-policy            no-show-policy-000
--- hop 2: wifi en todas las salas ---
  score=25.4    doc_id=wifi-and-equipment-faq    wifi-and-equipment-faq-000
  score=5.717   doc_id=operations-manual-raw     operations-manual-raw-000
  score=3.652   doc_id=focus-room-manual         focus-room-manual-000

Compáralo con la query combinada: en hop2, wifi-and-equipment-faq-000 no solo aparece — es el top-1, con un score (25.4) más de cuatro veces el segundo lugar. Ese mismo chunk, en la query combinada, estaba enterrado en el puesto #14 con un score de apenas 3.816 — casi siete veces menos. Nada cambió en el índice ni en el corpus entre las dos búsquedas: lo único que cambió es que la query de hop2 tiene todos sus términos enfocados en el tema del wifi, en vez de compartir espacio con los términos de la pregunta de cancelación.

El hop1, como ya sabes de la Lección 03, trae de nuevo la nota de remisión (cancellation-policy-003) — este módulo no repite aquí la reformulación completa (eso ya lo viste), pero vale la pena notarlo: multi-hop y reformular no son mutuamente excluyentes. Un guion real podría reformular hop1 con la query específica de la Lección 03 antes de combinar ambos resultados en la respuesta final — la Lección 08 (mini-proyecto) hace exactamente eso.


Por qué pasa esto: la aritmética de BM25, en una frase

Recuerda la fórmula de BM25 del Módulo 2: el score de un chunk es la suma, término por término, de la contribución de cada palabra de la query que aparece en el chunk. Cuando la query tiene ocho o diez términos repartidos entre dos temas distintos, cada chunk solo puede sumar la contribución de los términos de su tema — un chunk sobre wifi no gana nada de las palabras "cancellation" o "policy". Mientras tanto, los chunks del tema con más términos compartidos (en este caso, cancelación, porque la query combinada tiene más palabras de ese lado) acumulan más puntos en términos absolutos, simplemente porque hay más términos de su tema en la query para sumar. No es que BM25 "prefiera" la cancelación — es aritmética de suma, aplicada a una query que le da más oportunidades de sumar a un tema que al otro.


Frontera: recuperar dos conjuntos de chunks no es lo mismo que combinarlos en el prompt

Este módulo se detiene exactamente donde termina la recuperación: dos llamadas a search_docs, dos listas de chunks, cada una con su propio doc_id y score. Cómo se combinan esas dos listas en el texto que finalmente ve el modelo —¿van una después de la otra? ¿se intercalan? ¿qué pasa si juntas suman más texto del que cabe en la ventana de contexto?— es el trabajo de context-engineering-guide, no de este módulo. Esta guía recupera los chunks candidatos de cada hop; esa guía hermana decide cómo entran al prompt. Un runner de producción real necesita las dos piezas trabajando juntas, pero son decisiones distintas, con distinta ingeniería detrás.


Errores comunes

  1. Combinar dos temas en una sola query "para ahorrar una llamada". Como acabas de medir, ahorrar una llamada a search_docs puede costar la mitad de la respuesta — la señal del tema más débil puede quedar completamente enterrada. El costo de una segunda llamada (una consulta más al índice, instantánea en este corpus) es mucho menor que el costo de perder información relevante.

  2. Asumir que multi-hop siempre significa "dos llamadas secuenciales, una esperando a la otra". En este caso, hop1 y hop2 son completamente independientes entre sí —ninguno necesita el resultado del otro para ejecutarse— así que, como vas a ver en la Lección 05, podrían pedirse en el mismo turno, en paralelo, no uno después del otro. Multi-hop describe cuántas búsquedas hacen falta, no necesariamente en qué orden.

  3. Usar k alto en la query combinada "para compensar" en vez de separar los hops. Subir k en la query combinada (por ejemplo, a k=57 como hicimos para medir el problema) expone más resultados, pero no arregla el orden — seguirías teniendo que revisar catorce posiciones para encontrar el primer chunk de wifi, cuando MAX_K=5 de la tool real ni siquiera te dejaría llegar tan lejos.

  4. Olvidar que cada hop necesita su propia lectura, no solo su propio score. Igual que en la Lección 03, un doc_id correcto en el top-1 de un hop no garantiza que el chunk sea el ideal — cada hop se evalúa con el mismo criterio que cualquier otra llamada a search_docs.


Ejercicios

Ejercicio 1: Predice antes de ejecutar (Fácil)

Sin ejecutar nada: para la pregunta compuesta "¿Cuánto descuento tiene el tier pro, y qué equipo tiene la sala Boardroom?", ¿esperas que una sola query combinada encuentre bien las dos mitades, o que una domine a la otra? ¿Cuáles dos queries separadas usarías? Confirma ejecutando las tres (la combinada y las dos separadas) con k=3.

Ver solución

Predicción razonable: es probable que una de las dos mitades domine, por la misma razón aritmética de esta lección — a menos que ambos temas compartan muy poco vocabulario con el resto del corpus y cada uno tenga un chunk con una señal muy fuerte y específica.

combined = search_docs("How much discount does the pro tier get and what equipment does the Boardroom have?", k=3)
hop_a = search_docs("How much discount does the pro tier get?", k=3)
hop_b = search_docs("What equipment does the Boardroom have?", k=3)

for label, results in [("combinada", combined), ("hop A (descuento)", hop_a), ("hop B (equipo)", hop_b)]:
    print(f"--- {label} ---")
    for r in results:
        print(f"  {r['score']:<7} {r['doc_id']}")

Salida esperada:

--- combinada ---
  12.298  membership-tiers-faq
  9.312   cancellation-policy
  5.981   boardroom-room-manual
--- hop A (descuento) ---
  11.998  membership-tiers-faq
  8.936   cancellation-policy
  5.217   membership-tiers-faq
--- hop B (equipo) ---
  4.897   boardroom-room-manual
  4.632   cancellation-policy
  3.371   cancellation-policy

Explicación: en este caso puntual, la query combinada sí trae boardroom-room-manual en el top-3 (puesto #3, score 5.981) — mejor, incluso, que el hop B por sí solo, donde su propio top-1 (4.897) queda con un margen mínimo sobre cancellation-policy (4.632), casi empatado. Esto muestra algo más matizado que "combinar siempre entierra": la dilución no es garantía de fallo en cada caso puntual — a veces el ruido de un tema ayuda por casualidad al otro, como aquí. Lo que sí es constante es que no puedes saber de antemano cuál de los dos efectos vas a tener sin medir cada caso — el ejemplo trabajado (cancelación + wifi) mostró un entierro catastrófico; este caso (descuento + equipo) no. Confiar en que "esta vez la combinada va a alcanzar" es apostar; separar en hops elimina la apuesta.

Ejercicio 2: Confirma que ni un k generoso rescata el hop enterrado (Medio)

Retomando la query combinada del ejemplo trabajado ("What is the cancellation policy and is wifi included in every room?"), confirma que ni siquiera con k=10 —el doble del MAX_K=5 real de la tool— aparece wifi-and-equipment-faq-000 entre los resultados. Compara con el hop enfocado, donde alcanza con k=1.

Ver solución
from rag_index import INDEX

combined_query = "What is the cancellation policy and is wifi included in every room?"
hits10 = INDEX.search(combined_query, k=10)
print("combinada k=10, doc_ids:", [c.doc_id for c, s in hits10])
print("wifi-and-equipment-faq-000 presente?",
      any(c.chunk_id == "wifi-and-equipment-faq-000" for c, s in hits10))

focused_hits1 = INDEX.search("Does every room include high-speed wifi at no extra charge?", k=1)
print("hop enfocado k=1:", [(c.chunk_id, s) for c, s in focused_hits1])

Salida esperada:

combinada k=10, doc_ids: ['cancellation-policy', 'no-show-policy', 'cancellation-policy', 'no-show-policy', 'operations-manual-raw', 'focus-room-manual', 'wifi-and-equipment-faq', 'boardroom-room-manual', 'studio-room-manual', 'phonebooth-room-manual']
wifi-and-equipment-faq-000 presente? False
hop enfocado k=1: [('wifi-and-equipment-faq-000', 25.4)]

Explicación: con k=10 la query combinada trae un chunk de wifi-and-equipment-faq (puesto #7, según viste en el ejemplo trabajado), pero no el chunk específico que responde la pregunta completa (-000, que recién aparece en el puesto #14) — ni duplicando el MAX_K real de la tool alcanza para rescatarlo. El hop enfocado, en cambio, lo encuentra con el k más chico posible: k=1 ya alcanza, porque no tiene que competir con ningún término del tema de cancelación. Subir k en una query combinada expone más ruido, no necesariamente la señal que falta.

Ejercicio 3: Diseña una función multi_hop_search (Difícil)

Escribe multi_hop_search(queries: list[str], k: int = 3) -> dict[str, list[dict]] que ejecute search_docs una vez por cada query de la lista y devuelva un diccionario query -> resultados. Ejecútala con las dos queries del ejemplo trabajado (cancelación y wifi) y confirma que el resultado es idéntico a llamar search_docs dos veces por separado.

Ver solución
def multi_hop_search(queries, k=3):
    """Ejecuta un hop de search_docs por cada query independiente.
    No decide QUE queries usar -- eso es la reformulacion/descomposicion
    del modelo (concepto); esta funcion solo ejecuta los hops ya decididos."""
    return {query: search_docs(query, k=k) for query in queries}


hops = multi_hop_search([
    "What is Reservo's cancellation policy?",
    "Does every room include high-speed wifi at no extra charge?",
])

for query, results in hops.items():
    print(f"--- {query!r} ---")
    for r in results:
        print(f"  {r['score']:<7} {r['doc_id']}")

# Confirmar equivalencia con llamadas sueltas
single_1 = search_docs("What is Reservo's cancellation policy?", k=3)
single_2 = search_docs("Does every room include high-speed wifi at no extra charge?", k=3)
assert hops["What is Reservo's cancellation policy?"] == single_1
assert hops["Does every room include high-speed wifi at no extra charge?"] == single_2
print("\nmulti_hop_search coincide con las llamadas sueltas")

Salida esperada:

--- "What is Reservo's cancellation policy?" ---
  10.734  cancellation-policy
  7.217   refund-policy
  6.364   no-show-policy
--- "Does every room include high-speed wifi at no extra charge?" ---
  25.4    wifi-and-equipment-faq
  5.717   operations-manual-raw
  3.652   focus-room-manual

multi_hop_search coincide con las llamadas sueltas

Explicación: multi_hop_search no necesita ninguna lógica nueva de recuperación — es una capa fina que ejecuta search_docs una vez por hop y organiza los resultados por query, igual que search_multi en el Módulo 2 organizaba resultados por query de evaluación. La función no decide cuáles queries usar ni cuántos hops hacen falta —esa es la parte de concepto, la descomposición de la pregunta compuesta que hace el modelo—; solo ejecuta, de forma determinista y reproducible, los hops que ya se decidieron.


Resumen y siguiente paso

  • Una pregunta compuesta por dos temas independientes necesita dos búsquedas separadas, no una query combinada — medimos, ejecutando, que la query combinada entierra wifi-and-equipment-faq-000 en el puesto #14 de 57, mientras que un hop enfocado lo trae al top-1 con un score más de cuatro veces mayor.
  • La causa es aritmética: BM25 suma la contribución de cada término de la query, y una query con más palabras de un tema le da a los chunks de ese tema más oportunidades de sumar que a los del otro.
  • multi_hop_search ejecuta los hops ya decididos — la decisión de qué queries separar (la descomposición de la pregunta compuesta) sigue siendo del modelo, concepto, no de esta función.
  • Frontera: este módulo recupera los chunks de cada hop; cómo se combinan esas listas en la ventana de contexto es trabajo de context-engineering-guide.

Siguiente lección: 05 — Combinar search_docs con las tools de Reservo. Cuando una de las dos mitades de una pregunta compuesta no es un segundo hop de búsqueda, sino una tool estructurada como get_quote, en el mismo turno.


Recursos adicionales

  1. agent-fundamentals-and-tool-calling-guide, Módulo 4, Lección 05 (Encadenar tools en varios pasos) — el patrón general de resolver una pregunta con varias llamadas a tools, aplicado aquí a dos llamadas de la misma tool con queries distintas.
  2. context-engineering-guide — dónde se decide cómo combinar los chunks de varios hops en la ventana de contexto; la frontera exacta de dónde termina este módulo.
  3. Manning, Raghavan & Schütze — Introduction to Information Retrieval, cap. 9: "Relevance feedback and query expansion" — la literatura formal detrás de dividir una necesidad de información compuesta en varias consultas.
  4. Python — Comprehensions de diccionario — la construcción {query: search_docs(query, k=k) for query in queries} usada en multi_hop_search.