Módulo 3: `search_docs` como herramienta del agente
Qué devuelve la tool al modelo
Descripción
La lección anterior validó el input de search_docs — ya sabes decidir si una llamada tiene la forma correcta antes de ejecutar nada. Esta lección mira el otro extremo: qué le devuelves al modelo después de buscar. En el Ejercicio 2 de la Lección 02 ya viste el problema de frente: INDEX.search(query, k) devuelve una lista de tuplas (Chunk, score), y json.dumps sobre esa lista revienta con TypeError: Object of type Chunk is not JSON serializable. Esta lección resuelve exactamente eso, y de paso decide qué información de cada chunk vale la pena mandarle al modelo y cuál no.
Ya conoces la regla de fondo de agent-fundamentals-and-tool-calling Módulo 2, Lección 05: todo lo que una tool devuelve tiene que terminar convertido a texto, y solo hay dos formas válidas — un dict (u otra estructura) serializable con json.dumps, o una cadena de texto ya lista. Esta lección aplica esa regla a un caso nuevo: no un único valor ({"price_cents": 6000}), sino una lista de resultados de búsqueda, cada uno con su propio texto, su propia fuente y su propio score.
Conexión con el módulo
La Lección 03 cerró el lado de entrada del contrato (input_schema, validado). Esta lección abre el lado de salida, y las Lecciones 05 y 06 siguen construyendo sobre las dos: cómo se describe la tool para que el modelo la elija bien (05), y cómo se acota tanto k como el texto de cada resultado (06) para que esta forma de retorno no crezca sin control.
La forma que necesita cada resultado
get_quote devolvía un único dict con una clave. search_docs devuelve una lista de resultados —puede haber cero, uno o varios chunks relevantes—, y cada elemento de esa lista necesita cuatro piezas de información, ni una menos:
chunk_id -> identifica EXACTAMENTE que fragmento es, para poder citarlo despues
doc_id -> de que documento viene (la fuente que el agente puede mencionar al usuario)
text -> el contenido del fragmento, lo que el modelo realmente necesita leer
score -> que tan relevante lo considero el indice, para que el modelo (o un
filtro rio abajo) pueda juzgar confianza
Ninguna de las cuatro es decorativa. Sin doc_id, el modelo no puede decirle al usuario "según la política de cancelación..." — solo podría parafrasear texto sin origen, lo que rompe la trazabilidad que un sistema de documentos en producción necesita (un tema que retoma a fondo el Módulo 7). Sin score, no hay forma de distinguir un resultado fuerte de uno que apenas pasó el corte — información que se vuelve crítica en el Módulo 6, cuando midas recall y precision, y en el Módulo 7, cuando filtres resultados de baja relevancia.
Ejemplo trabajado: de Chunk crudo a dict serializable
Retomamos el fallo del Ejercicio 2 de la Lección 02, esta vez explicándolo a fondo.
import json
from rag_index import CHUNKS
chunk0 = CHUNKS[0]
print("chunk crudo:", chunk0)
try:
blob = json.dumps(chunk0)
print("serializado:", blob)
except TypeError as e:
print(f"TypeError: {e}")
Qué esperar:
chunk crudo: Chunk(chunk_id='cancellation-policy-000', doc_id='cancellation-policy', title='Cancellation Policy', section='Basic Tier Cancellation Window', position=0, text='Basic members can cancel a booking up to 24 hours before the reserved start time with no penalty. Cancellations made less than 24 hours in advance forfeit the full booking amount.')
TypeError: Object of type Chunk is not JSON serializable
json.dumps sabe convertir diccionarios, listas, strings, números y booleanos — no sabe convertir una instancia de una clase que tú definiste, ni siquiera un @dataclass, a menos que le enseñes cómo. La solución más directa es la función asdict de la propia librería dataclasses, que convierte cualquier instancia de un dataclass en un dict plano de sus campos:
from dataclasses import asdict
as_dict = asdict(chunk0)
print(json.dumps(as_dict, ensure_ascii=False)[:150], "...")
Qué esperar:
{"chunk_id": "cancellation-policy-000", "doc_id": "cancellation-policy", "title": "Cancellation Policy", "section": "Basic Tier Cancellation Window", "position": 0, "text": "Basic members can cancel a booking up to 24 ho ...
Esto ya serializa — el TypeError desaparece. Pero fíjate en algo: asdict(chunk0) te da los seis campos del Chunk (chunk_id, doc_id, title, section, position, text), y no todos le sirven al modelo de la misma forma. title, section y position son metadatos útiles para tu pipeline de ingestión —le sirvieron al Módulo 1 para reconstruir de dónde salió cada fragmento—, pero no son información que el modelo necesite ver para responder una pregunta: doc_id ("cancellation-policy") ya identifica la fuente sin ambigüedad, y repetir el título legible del documento en cada resultado es texto de más. Enviarlos de todas formas no rompe nada, pero es texto de más en un resultado que, como vas a ver en la Lección 06, ya tiene que competir por espacio con el resto de la conversación. Por eso search_docs no devuelve asdict(chunk) completo — devuelve exactamente los cuatro campos que sí importan, más el score que no vive en el Chunk en absoluto (lo calcula el índice en el momento de la búsqueda).
La función que arma la forma final
def to_result(chunk, score):
"""Convierte un (Chunk, score) del indice en el dict que search_docs
le devuelve al modelo. Deja fuera title/section/position: son
metadatos de ingestion, no informacion que el modelo necesite leer."""
return {
"chunk_id": chunk.chunk_id,
"doc_id": chunk.doc_id,
"text": chunk.text,
"score": score,
}
Probada contra un resultado real del índice:
from rag_index import INDEX
hits = INDEX.search("Is there wifi in the Lounge?", k=2)
results = [to_result(chunk, score) for chunk, score in hits]
print(json.dumps(results, ensure_ascii=False, indent=2))
Qué esperar:
[
{
"chunk_id": "lounge-room-manual-004",
"doc_id": "lounge-room-manual",
"text": "Lounge is open-plan and does not require a keypad code, unlike Focus, Phonebooth, and Boardroom. Because there is no door, members should keep calls at conversational volume.",
"score": 6.355
},
{
"chunk_id": "cancellation-policy-002",
"doc_id": "cancellation-policy",
"text": "Cancellations go through the same booking system used to reserve the room. There is no phone line for cancellations; the system timestamp is what determines whether the cancellation was made in time.",
"score": 4.688
}
]
Mira este resultado con calma, porque no es el que esperarías: el top-1 no es wifi-and-equipment-faq — es lounge-room-manual-004 (la sección "House Rules" del manual del Lounge). Ese chunk no dice una sola palabra sobre wifi; gana porque menciona "Lounge" por su nombre —el término más específico de la query— junto con un puñado de palabras funcionales compartidas ("is", "no", "does"). wifi-and-equipment-faq, el documento que sí trata el tema, ni siquiera entra al top-2 (queda en el tercer lugar, con score 3.942, fuera de este k=2). El segundo lugar que sí ves aquí, cancellation-policy-002, tampoco menciona wifi ni Lounge; entra porque comparte con la query un puñado de palabras muy comunes ("is", "in", "the") que, sobre un corpus de solo 57 chunks sin remoción de stopwords, todavía cargan algo de IDF. No es un bug — es exactamente la honestidad léxica que el Módulo 2 estableció: BM25 suma coincidencias de términos exactos, sin distinguir una palabra funcional de una palabra que de verdad importa, y sin distinguir "menciona el nombre de la sala" de "responde la pregunta sobre esa sala". Esto sí es exactamente lo que viajaría de vuelta al modelo: una lista serializable, cada elemento con las cuatro piezas que decidiste que importan — con su score incluido, para que el modelo (o un filtro río abajo) pueda notar que ninguno de los dos resultados viene con un margen que inspire mucha confianza. La forma exacta en que este JSON entra al bloque tool_result —con su tool_use_id correspondiente— es el protocolo que ya conoces de agent-fundamentals-and-tool-calling Módulo 3; aquí nos importa una capa antes, igual que en la Lección 05 de esa misma guía: qué forma tiene que tener el valor para que esa conversión sea posible.
Por qué el score honesto importa más en una tool de búsqueda
Con get_quote, el retorno era un hecho cerrado: el precio es el que es, no hay ambigüedad sobre si el resultado es "bueno" o "malo". Con search_docs es distinto: cada resultado viene con un grado de relevancia que el índice estimó, no un hecho garantizado. Devolver el score no es un adorno — es la única señal que tiene el modelo (o cualquier lógica río abajo, como el filtro de umbral del Módulo 7) para distinguir "este chunk casi seguro responde la pregunta" de "este chunk apareció en el top-k, pero con relevancia baja". Omitir el score no hace que la incertidumbre desaparezca; solo la esconde, y fuerza al modelo a tratar todos los resultados como igualmente confiables cuando, en la práctica de una recuperación léxica, casi nunca lo son.
Errores comunes
-
Devolver el
Chunkcrudo esperando que "algo" lo convierta después. Como viste,json.dumpsno sabe convertir undataclasspor sí solo. Si tu funciónsearch_docsdevuelvehitstal cual (una lista de tuplas conChunks adentro), el fallo ocurre en el momento de armar la respuesta, no antes — exactamente el mismo patrón de error tardío que viste conQuoteResultenagent-fundamentals-and-tool-callingMódulo 2, Lección 05. -
Mandar
asdict(chunk)completo sin filtrar. Funciona —no truena—, pero incluyetitle,sectionyposition, metadatos de ingestión que no aportan nada a la respuesta del modelo y ocupan espacio de más. Filtrar a los cuatro campos que sí importan (chunk_id,doc_id,text,score) es una decisión de diseño, no un accidente deasdict. -
Omitir el
score"para simplificar la respuesta". Sin score, el modelo no puede distinguir un resultado fuerte de uno débil, y tampoco puede aplicarse ningún filtro de relevancia río abajo (Módulo 7). Es la pieza que parece más prescindible y es, en la práctica, la que más información honesta aporta sobre qué tan bien buscó el índice. -
Redondear el score de forma inconsistente entre corridas. Si el redondeo no es determinista (por ejemplo, si dependiera de un orden de iteración no garantizado), dos ejecuciones de la misma query podrían mostrar el "mismo" resultado con decimales distintos. El índice de esta guía ya redondea a 3 decimales en
BM25Index.search, de forma consistente — no agregues un segundo redondeo con una precisión distinta ento_result.
Ejercicios
Ejercicio 1: Cuenta los campos (Fácil)
Sin ejecutar nada: ¿cuántos campos tiene asdict(chunk0) para cualquier Chunk del corpus, y cuáles de esos campos no aparecen en el dict que devuelve to_result? ¿Por qué se dejan fuera?
Ver solución
asdict(chunk0) tiene seis campos: chunk_id, doc_id, title, section, position, text — son los seis campos declarados en @dataclass(frozen=True) Chunk, el mismo Chunk que ya convergió entre el Módulo 1 y el Módulo 2. to_result deja fuera tres: title, section y position. Se dejan fuera porque son metadatos de ingestión (para qué le sirvieron al Módulo 1 y le siguen sirviendo al pipeline internamente), no información que el modelo necesite leer para responder una pregunta del usuario — incluirlos no rompería nada, pero agregaría texto sin ganancia real, justo el problema que la Lección 06 trata con más profundidad.
Ejercicio 2: Arma y serializa un resultado nuevo (Medio)
Usando to_result y INDEX.search, busca "What equipment is in the Focus room?" con k=2, arma la lista de resultados, y sérializa con json.dumps. No asumas cuál va a ser el primer resultado — ejecuta y lee el score de cada uno antes de decidir si el top-1 es el que esperabas.
Ver solución
import json
from rag_index import INDEX
hits = INDEX.search("What equipment is in the Focus room?", k=2)
results = [to_result(chunk, score) for chunk, score in hits]
print(json.dumps(results, ensure_ascii=False, indent=2))
Salida esperada:
[
{
"chunk_id": "cancellation-policy-002",
"doc_id": "cancellation-policy",
"text": "Cancellations go through the same booking system used to reserve the room. There is no phone line for cancellations; the system timestamp is what determines whether the cancellation was made in time.",
"score": 5.805
},
{
"chunk_id": "focus-room-manual-000",
"doc_id": "focus-room-manual",
"text": "Focus is Reservo's single-occupancy room, designed for calls and deep work that needs a closed door. It is the smallest and least expensive room in the building.",
"score": 5.762
}
]
Explicación: el top-1 no es focus-room-manual — es cancellation-policy-002 ("How to Cancel"), y por un margen mínimo (5.805 contra 5.762). Ese chunk no tiene nada que ver con equipamiento; gana porque comparte con la query un puñado de palabras muy comunes ("is", "in", "the", "room") que, sin remoción de stopwords, siguen aportando algo de IDF sobre un corpus de 57 chunks. focus-room-manual queda segundo, a menos de 0.05 puntos de distancia — prácticamente un empate. Esto no es un error del índice: es la consecuencia honesta de dos decisiones de diseño que ya conoces del Módulo 2 (BM25 sin stopwords, corpus chunkeado fino) y es exactamente la razón por la que la Lección 07 insiste en leer el score, no solo la posición: un top-1 correcto en algunas de las seis queries ancla no garantiza que las demás también lo sean, y aquí lo comprobaste con tu propia ejecución.
Ejercicio 3: Diseña un quinto campo, y decide si vale la pena (Difícil)
Alguien en tu equipo propone agregar un quinto campo a to_result: section, el título de la sección de donde salió el chunk (por ejemplo, "Cancellation window"). Argumenta, en dos o tres frases, un caso a favor y uno en contra de incluirlo, y da tu recomendación final.
Ver solución
A favor: section le daría al modelo una pista adicional de contexto sin tener que leer el text completo —"Cancellation window" ya sugiere de qué trata el fragmento antes de procesar el contenido— y podría ayudar a que una respuesta cite no solo el documento sino la sección exacta, mejorando la trazabilidad del Módulo 7.
En contra: agrega otro campo de texto a cada resultado, en una tool que ya tiene que acotar cuánto texto devuelve (Lección 06) — cada campo adicional compite por el mismo presupuesto de contexto. Además, doc_id y las primeras palabras de text casi siempre ya comunican de qué trata el fragmento, así que el valor incremental de section es marginal frente a su costo.
Recomendación: queda fuera para esta guía —los cuatro campos actuales (chunk_id, doc_id, text, score) son suficientes para responder con trazabilidad y honestidad sobre la relevancia—, pero es una decisión de diseño razonable, no una regla absoluta: un sistema real con secciones muy largas podría justificar incluirlo si el costo de contexto lo permite.
Resumen y siguiente paso
- Un
Chunkcrudo (o una tupla con unChunkadentro) no es serializable conjson.dumps— lo confirmamos ejecutando y viendo elTypeErrorexacto. to_result(chunk, score)arma la forma final:chunk_id,doc_id,text,score— cuatro campos, ni los seis completos deasdict(chunk)ni menos de esos cuatro.- El
scoreno es decorativo: es la única señal de confianza que tiene el modelo sobre cada resultado, y se vuelve central en el Módulo 6 (evaluación) y el Módulo 7 (filtrado por umbral). El Ejercicio 2 lo mostró de la forma más honesta posible: un top-1 que no era el esperado, a menos de 0.2 puntos del segundo lugar. title,sectionypositionse dejan fuera a propósito: son metadatos de ingestión, útiles para tu pipeline, no información que el modelo necesite para responder.
Siguiente lección: 05 — Escribir la description para que el modelo sepa cuándo. Ya declaraste el contrato completo y sabes qué forma tiene la salida; ahora afinamos el único campo en prosa —description— y vemos, con un ejemplo de concepto, cómo distingue a search_docs de get_quote/book_room para el modelo.
Recursos adicionales
agent-fundamentals-and-tool-calling-guide, Módulo 2, Lección 05 (Qué devuelve una tool al modelo) — la regla de fondo (serializable o texto plano) que esta lección aplica a una lista de resultados de búsqueda.- Python —
dataclasses.asdict— la función que convierte unChunken undictplano, punto de partida deto_result. - Python —
json— la librería que confirma, ejecutando, qué es y qué no es serializable. agent-fundamentals-and-tool-calling-guide, Módulo 3 (El protocolo de tool calling) — dónde este JSON termina viajando de vuelta, dentro de un bloquetool_result(no re-enseñado aquí).