Módulo 4: Recuperación agéntica en el bucle
Reformular y reintentar la query
Descripción
La Lección 02 cerró la Pregunta 2 con una observación que dejó pendiente: search_docs("What is Reservo's cancellation policy?", k=3) acierta el doc_id esperado (cancellation-policy) en el primer lugar, pero el chunk ganador —cancellation-policy-003, la sección "Related Policies"— es solo una oración de referencia cruzada ("ver refund-policy... ver no-show-policy..."), no el texto real de la ventana de cancelación. Un pipeline monolítico se conformaría con ese resultado porque el doc_id "acertó". Un agente que de verdad lee lo que recuperó no se conforma: reconoce que el chunk no responde la pregunta, reformula la query, y vuelve a intentar.
Esta lección construye esa segunda mitad del ciclo. Vas a escribir un chequeador —real, ejecutado— que detecta cuándo un chunk es predominantemente una referencia a otros documentos en vez de contenido propio, y vas a ver, con dos llamadas reales a search_docs, cómo una segunda query con otras palabras encuentra el chunk que la primera no encontró.
Conexión con el módulo
Esta lección construye directamente sobre la Pregunta 2 de la Lección 02: mismo caso, mismo primer resultado, pero ahora con la pieza que faltaba —qué hacer cuando ese resultado no alcanza—. Es la base de la Lección 04 (multi-hop), que generaliza este patrón a preguntas que necesitan varias búsquedas independientes, no solo un segundo intento de la misma pregunta.
Analogía: la carpeta que solo dice "ver también"
El investigador de la introducción del módulo pide, en el archivo, la carpeta de "política de cancelación". El archivista le trae una carpeta — pero al abrirla, adentro solo hay una nota: "para reembolsos, ver la carpeta de reembolsos; para no-shows, ver la carpeta de no-shows". La carpeta técnicamente correspondía a lo que pidió, pero no contiene la respuesta — es un índice de remisión, no el contenido. Un investigador que se conforma con esa nota y la reporta como si fuera la política completa está fallando, aunque haya "encontrado algo con el nombre correcto". Un investigador de verdad nota la diferencia, y vuelve al mostrador con una pregunta más específica: no "¿cuál es la política de cancelación?", sino "¿cuántas horas antes puedo cancelar sin cargo en modo pro?" — una pregunta que ya no puede responderse con una simple nota de remisión.
Un chequeador ejecutable: ¿este chunk es solo una referencia cruzada?
El corpus canónico de Reservo tiene, a propósito, chunks que mencionan otros doc_id entre comillas invertidas —viste esto desde el Módulo 2— para simular cómo los documentos reales de una empresa se referencian entre sí. Eso es útil de verdad para un lector humano que navega el archivo completo, pero es una señal de alarma cuando ese chunk específico es el único resultado que el agente va a leer. Construyamos un detector simple, basado en esa misma característica del texto:
import re
REF_RE = re.compile(r"`[a-z]+(?:-[a-z]+)+`")
def looks_like_cross_reference(text, min_refs=2):
"""True si el texto menciona 2 o mas identificadores tipo doc_id entre
comillas invertidas (`refund-policy`, `no-show-policy`, ...) -- senal de
que el chunk es una nota de remision, no contenido propio."""
return len(REF_RE.findall(text)) >= min_refs
REF_RE busca el patrón exacto con el que el corpus escribe una referencia a otro documento: una o más palabras en minúscula separadas por guiones, entre comillas invertidas — la misma forma que `refund-policy` o `no-show-policy`. min_refs=2 es una decisión deliberada: un chunk que menciona un solo doc_id de pasada (algo común, y no necesariamente un problema) no se marca; dos o más menciones en el mismo chunk corto es la señal real de que el chunk existe para apuntar a otro lado, no para responder por sí mismo.
Ejemplo trabajado: el chequeador contra el primer resultado real
Retomando exactamente la búsqueda de la Lección 02:
from search_docs_tool import search_docs
results = search_docs("What is Reservo's cancellation policy?", k=3)
for r in results:
flag = looks_like_cross_reference(r["text"])
print(f" {r['chunk_id']:28s} score={r['score']:<7} referencia_cruzada={flag}")
print(f" texto: {r['text']!r}")
Qué esperar:
cancellation-policy-003 score=10.734 referencia_cruzada=True
texto: 'See `refund-policy` for what happens to the money once a cancellation is processed, and `no-show-policy` for what happens if you simply do not show up without cancelling.'
refund-policy-000 score=7.217 referencia_cruzada=False
texto: "A refund applies when a booking is cancelled within the cancellation window for the member's tier, or when Reservo cancels a booking due to a facility issue on Reservo's side."
no-show-policy-000 score=6.364 referencia_cruzada=False
texto: 'A no-show is a booking where the member never checks in during the reserved hours and never cancelled beforehand. This is different from a late cancellation, which is covered in `cancellation-policy`.'
Ahí está la señal, ejecutada: el chunk que ganó el primer lugar (cancellation-policy-003) contiene exactamente dos referencias entre comillas invertidas —`refund-policy` y `no-show-policy`— y looks_like_cross_reference lo marca True. El tercer resultado (no-show-policy-000) menciona `cancellation-policy` una sola vez — por debajo del umbral, correctamente no marcado, porque una sola mención de pasada no convierte a todo un chunk en una simple nota de remisión.
Concepto: el agente decide reformular
Con la señal ejecutada en la mano, el siguiente paso —decidir reformular, y con qué palabras— es concepto: la reformulación en sí es una decisión del modelo, no algo que un chequeador pueda generar automáticamente.
[modelo·concepto]
El primer resultado de search_docs tiene el doc_id correcto
(cancellation-policy), pero looks_like_cross_reference lo marca como
una nota de remision, no el contenido real de la politica. La pregunta
original ("What is Reservo's cancellation policy?") es demasiado
general -- coincide con la oracion que solo NOMBRA las politicas
relacionadas. Reformulo apuntando al dato especifico que la pregunta
original necesitaba: cuantas horas antes se puede cancelar sin cargo
en modo pro.
decision -> use_tool "search_docs" con {query: "How many hours in
advance is a pro tier cancellation free of charge?", k: 3}
La reformulación no cambia de tema — sigue siendo sobre la ventana de cancelación pro — pero cambia el nivel de especificidad: de una pregunta general que un cross-reference puede "responder" superficialmente, a una pregunta concreta que solo el chunk con el número real puede satisfacer.
Ejemplo trabajado: la segunda búsqueda, ejecutada
retry_results = search_docs("How many hours in advance is a pro tier cancellation free of charge?", k=3)
for r in retry_results:
flag = looks_like_cross_reference(r["text"])
print(f" {r['chunk_id']:28s} score={r['score']:<7} referencia_cruzada={flag}")
Qué esperar:
cancellation-policy-001 score=15.138 referencia_cruzada=False
no-show-policy-000 score=8.757 referencia_cruzada=False
cancellation-policy-000 score=7.715 referencia_cruzada=False
La reformulación funcionó, de las dos formas que importan. Primero, el chunk ganador cambió: cancellation-policy-001 —"Pro Tier Cancellation Window", el texto real: "Pro members get a shorter, friendlier window: cancellations up to 4 hours before the reserved start time are free of charge..."— reemplazó a la nota de remisión del primer intento. Segundo, looks_like_cross_reference confirma False en los tres resultados: ninguno es una simple referencia cruzada esta vez. Y el margen es mucho más amplio que el de la primera búsqueda: 15.138 contra 8.757 (casi el doble), frente al 10.734 contra 7.217 del primer intento —una señal adicional, aunque no definitiva por sí sola, de que esta segunda query encontró algo más específico.
El patrón completo, visualizado
query 1: "What is Reservo's cancellation policy?"
│
▼
search_docs -> top1: cancellation-policy-003 (solo remite a otras politicas)
│
▼
looks_like_cross_reference(top1.text) -> True (senal REAL, ejecutada)
│
▼
[modelo·concepto] "esto no responde la pregunta, reformulo"
│
▼
query 2: "How many hours in advance is a pro tier cancellation free of charge?"
│
▼
search_docs -> top1: cancellation-policy-001 (el texto real, "4 hours")
│
▼
looks_like_cross_reference(top1.text) -> False (contenido real, no una nota)
Dos llamadas a search_docs, ambas reales y ambas dentro del mismo turno lógico de la conversación — desde el punto de vista del historial del agente (el messages que ya conoces de agent-fundamentals-and-tool-calling Módulo 4), son dos vueltas del bucle: una decisión de tool, un resultado, una segunda decisión de tool con una query distinta, un segundo resultado, y recién ahí la respuesta final.
Por qué esto no es "buscar hasta que salga bien"
Vale la pena ser preciso sobre qué es y qué no es este patrón. No es un while que reintenta la misma query hasta que el score supere un número mágico —eso sería fuerza bruta, no reformulación—. Tampoco es reintentar con una query aleatoria hasta acertar. Es una decisión informada: el agente lee el texto del resultado (no solo el score), identifica por qué no sirve —en este caso, con una señal ejecutable concreta: es una nota de remisión, no contenido—, y construye una segunda query que apunta específicamente al dato que faltaba. looks_like_cross_reference no decide la reformulación por el agente; le da al agente (concepto) una razón concreta, verificable, para no conformarse con el primer resultado.
Errores comunes
-
Reintentar sin cambiar la query. Ejecutar
search_docsdos veces con exactamente el mismo texto de pregunta produce, con un índice determinista como el de esta guía, exactamente el mismo resultado — cero ganancia. La reformulación tiene que cambiar el vocabulario de la query, no solo repetir la llamada. -
Confundir "el doc_id acertó" con "el chunk responde la pregunta". Como viste en el Módulo 3, un
doc_idcorrecto en el top-1 no garantiza que el chunk específico sea útil — puede ser, como en este caso, una sección de referencias cruzadas del mismo documento correcto. Revisar eldoc_idsolo no alcanza; hay que mirar eltext. -
Reformular sin límite, sin un tope de intentos. Nada en esta lección impide que un agente mal diseñado reformule indefinidamente si ninguna query encuentra nada bueno. El tope de iteraciones del bucle (
max_iterations, ya establecido enagent-fundamentals-and-tool-callingMódulo 4) sigue aplicando aquí sin cambios — reformular consume una vuelta del bucle como cualquier otra llamada a una tool. -
Pensar que
looks_like_cross_referencees una prueba general de "chunk malo". Es un chequeador específico para un patrón concreto de este corpus (menciones de otrosdoc_identre comillas invertidas). Un chunk puede ser inútil para una pregunta por muchas otras razones —tema equivocado, demasiado genérico, truncado a mitad de idea— que este chequeador puntual no detecta. Es una señal útil, no una garantía de calidad completa.
Ejercicios
Ejercicio 1: Aplica el chequeador a un resultado nuevo (Fácil)
Sin ejecutar nada: dado el texto "No-shows are never refunded, regardless of membership tier. See \no-show-policy` for the full no-show rules and the fee that applies instead."(el chunkrefund-policy-002), ¿cuántas referencias entre comillas invertidas contiene? ¿looks_like_cross_referencelo marcaríaTrueoFalse`? Confirma ejecutando.
Ver solución
Contiene una sola referencia: `no-show-policy`. Con min_refs=2, looks_like_cross_reference debería devolver False — el umbral exige dos o más.
text = "No-shows are never refunded, regardless of membership tier. See `no-show-policy` for the full no-show rules and the fee that applies instead."
print(looks_like_cross_reference(text))
Salida esperada:
False
Explicación: este chunk sí tiene contenido propio (afirma que los no-shows nunca se reembolsan, un hecho real de la política) y además menciona otra política de pasada — exactamente el caso que el umbral de 2 está diseñado para no marcar como falso positivo.
Ejercicio 2: Reformula una query distinta y confirma la mejora (Medio)
Empezando de search_docs("What fee applies if a member does not show up for a booking?", k=3), confirma que el top-1 es una referencia cruzada. Reformula con search_docs("Does a no-show get charged the full reserved price?", k=3) y confirma que el segundo intento encuentra no-show-policy-001 sin ser una referencia cruzada.
Ver solución
first = search_docs("What fee applies if a member does not show up for a booking?", k=3)
for r in first:
print(r["chunk_id"], r["score"], looks_like_cross_reference(r["text"]))
print()
retry = search_docs("Does a no-show get charged the full reserved price?", k=3)
for r in retry:
print(r["chunk_id"], r["score"], looks_like_cross_reference(r["text"]))
Salida esperada:
cancellation-policy-003 17.143 True
no-show-policy-003 11.418 False
refund-policy-002 9.844 False
no-show-policy-001 13.335 False
refund-policy-002 7.809 False
no-show-policy-000 6.636 False
Explicación: el primer intento vuelve a poner cancellation-policy-003 en el primer lugar, marcado True por el chequeador — la misma nota de remisión de siempre, ganando porque comparte con la query palabras genéricas como "if", "member", "booking". La reformulación, con vocabulario más específico ("charged the entire reserved price"), trae no-show-policy-001 —el chunk con el hecho real: "No-shows are charged the full amount of the booking... the no-show fee equals the entire reserved price"— al primer lugar, sin ninguna referencia cruzada en el top-3.
Ejercicio 3: Diseña un tope de reformulaciones (Difícil)
Escribe una función search_with_retry(query, retry_query, k=3, max_attempts=2) que llame search_docs con query; si el top-1 pasa looks_like_cross_reference, reintente una vez con retry_query; devuelva el resultado del intento que haya funcionado (o el segundo, si ninguno pasa). Ejecútala con el par de queries del ejemplo trabajado y confirma que devuelve los resultados de la segunda búsqueda.
Ver solución
def search_with_retry(query, retry_query, k=3, max_attempts=2):
"""Busca con `query`; si el top-1 es una referencia cruzada, reintenta
UNA vez con `retry_query`. max_attempts acota el numero de llamadas a
search_docs, igual que max_iterations acota el bucle completo del agente."""
attempts = 0
results = search_docs(query, k=k)
attempts += 1
if results and looks_like_cross_reference(results[0]["text"]) and attempts < max_attempts:
results = search_docs(retry_query, k=k)
attempts += 1
return results, attempts
final_results, attempts = search_with_retry(
"What is Reservo's cancellation policy?",
"How many hours in advance is a pro tier cancellation free of charge?",
)
print(f"intentos usados: {attempts}")
for r in final_results:
print(f" {r['chunk_id']:28s} score={r['score']}")
Salida esperada:
intentos usados: 2
cancellation-policy-001 score=15.138
no-show-policy-000 score=8.757
cancellation-policy-000 score=7.715
Explicación: search_with_retry ejecuta exactamente la misma lógica de esta lección, pero encapsulada en una función reutilizable: primero intenta con query, y solo si el top-1 falla el chequeador y todavía queda margen bajo max_attempts, reintenta con retry_query. El attempts=2 confirma que se usaron las dos llamadas — la primera identificó el problema, la segunda lo resolvió. max_attempts cumple aquí el mismo rol que max_iterations en el bucle completo del agente: un tope explícito que evita que la reformulación se repita sin límite si ninguna query encontrara nada útil.
Resumen y siguiente paso
- El
doc_idcorrecto en el top-1 no garantiza que el chunk responda la pregunta — el Módulo 2/3 ya lo insinuaron; esta lección lo hizo explícito con un chequeador real:looks_like_cross_reference, que detecta chunks que solo nombran otras políticas entre comillas invertidas. - Ejecutamos el patrón completo:
search_docscon la query original devuelve una referencia cruzada (True); el modelo (concepto) reformula apuntando al dato específico que faltaba; la segundasearch_docs, ejecutada, trae el chunk real (False), con un margen mucho más amplio que el primer intento. - Reformular no es fuerza bruta: es una decisión informada, basada en leer el texto del resultado, no solo su score o su
doc_id. - El tope de iteraciones del bucle (
agent-fundamentals-and-tool-callingMódulo 4) sigue aplicando: cada reformulación consume una vuelta, y elwhile/foracotado no cambia de forma por el simple hecho de que una de las tools sea de recuperación.
Siguiente lección: 04 — Recuperación multi-hop. ¿Qué pasa cuando la pregunta no necesita reformular la misma búsqueda, sino hacer dos búsquedas independientes sobre dos temas distintos?
Recursos adicionales
- Anthropic — Tool use (function calling) overview — El ciclo petición-ejecución-resultado que esta lección repite dos veces dentro del mismo turno lógico de conversación.
agent-fundamentals-and-tool-calling-guide, Módulo 4, Lección 04 (El tope de iteraciones) — el mecanismo que evita que una reformulación se repita sin límite.- Python — Expresiones regulares (
re) — la referencia dere.compile/findall, usadas porREF_REpara detectar los identificadores entre comillas invertidas. embeddings-deep-dive-guide— por qué una búsqueda semántica real podría, en principio, distinguir "ventana de cancelación" de "ver también" sin necesitar una segunda query explícita; esta guía usa BM25 léxico, que sí necesita este patrón de reformulación.