Módulo 3: `search_docs` como herramienta del agente

Acotar la tool: `k` y truncado

Descripción

search_docs ya tiene contrato (Lección 03), forma de retorno (Lección 04) y una description que dice cuándo usarla (Lección 05). Falta una pieza que no tiene equivalente exacto en get_quote ni en book_room: ¿qué pasa si el modelo pide k=500? ¿Qué pasa si un chunk del corpus mide 5.000 caracteres? Ninguna de las dos preguntas tenía sentido con las tools de Reservo —hours era un entero cualquiera, sí, pero nunca iba a generar una respuesta de miles de palabras—. Con una tool de recuperación, sí importa, porque tanto la cantidad de resultados como el tamaño de cada uno determinan directamente cuánto texto termina compitiendo por espacio en la conversación del agente.

Esta lección construye la protección que el Ejercicio 3 de la Lección 03 ya anticipó: el input_schema no puede (ni debería) expresar "k como máximo 5" de forma declarativa con las herramientas básicas de esta guía —igual que MANAGE_BOOKING_SCHEMA en agent-fundamentals-and-tool-calling no podía expresar reglas condicionales—. La protección real vive en el código de search_docs: un tope duro sobre k y un truncado del texto de cada chunk. Vas a ejecutar ambas cosas y ver, con números reales, cuánto cambia el tamaño de la respuesta.

Conexión con el módulo

Esta lección cierra el trabajo de "construir la tool" antes de la síntesis: la Lección 07 ejecuta la versión completa —contrato + retorno + description + acotada— contra las seis queries fijas del corpus, y la Lección 08 la integra en un registro junto a las tools de Reservo.


Analogía: el mostrador tiene un límite de atención por turno

El mostrador de atención del archivo interno, aunque tenga toda la buena voluntad, no puede entregarle a un visitante 500 documentos completos de una sola vez, aunque los 500 fueran técnicamente "relevantes" en algún grado. Un mostrador bien operado tiene una política clara: "entregamos como máximo 5 documentos por consulta, y cada uno viene resumido a un fragmento razonable — si necesitas más detalle de uno en particular, pídelo aparte". Esa política no la decide el formulario de pedido (el input_schema) — el formulario solo dice "aquí escribes cuántos quieres" —; la decide la persona detrás del mostrador, aplicando un límite operativo antes de entregar cualquier cosa. search_docs necesita exactamente esa misma política.


Por qué el riesgo es real, no hipotético

Dos preguntas distintas, dos riesgos distintos:

k sin límite. Si el modelo pide k=500 sobre un corpus de 57 chunks, no hay 500 resultados relevantes que devolver — pero sin un tope, la función devolvería tantos como el índice tenga scores positivos, incluidos chunks con relevancia casi nula solo porque comparten una palabra común con la query. Más resultados no es más precisión: es más ruido que el modelo tiene que procesar para encontrar lo que realmente importa.

Texto sin truncar. Cada chunk de este corpus mide entre 103 y 290 caracteres — modesto para esta guía, porque el chunking consciente de estructura del Módulo 1 corta por sección, no por documento entero. Pero un documento real de producción puede tener chunks de varios miles de caracteres, o secciones que no se prestan a un chunking tan fino. Sin un límite, search_docs podría devolver, por ejemplo, tres chunks de 2.000 caracteres cada uno para una sola llamada — 6.000 caracteres que compiten con el resto de la conversación por el mismo presupuesto de contexto, un tema que retoma a fondo context-engineering-guide (frontera ya señalada en la introducción de esta guía: aquí se recupera, allá se cura cómo entra al prompt).


Ejemplo trabajado: MAX_K y truncado de texto

MAX_K = 5
MAX_CHARS = 280


def truncate(text, max_chars=MAX_CHARS):
    if len(text) <= max_chars:
        return text
    return text[:max_chars].rstrip() + "..."


def search_docs(query, k=3):
    """La tool completa: acota k, ejecuta la busqueda, arma el resultado
    con el texto truncado."""
    bounded_k = max(1, min(k, MAX_K))
    hits = INDEX.search(query, k=bounded_k)
    return [
        {
            "chunk_id": chunk.chunk_id,
            "doc_id": chunk.doc_id,
            "text": truncate(chunk.text),
            "score": score,
        }
        for chunk, score in hits
    ]

Dos decisiones concretas en esas cuatro líneas de bounded_k: min(k, MAX_K) recorta cualquier pedido por encima del tope; max(1, ...) evita que un k=0 (o negativo, si alguien lo pasara a mano sin pasar por el validador) deje la búsqueda sin ejecutar nada útil. Ninguna de las dos reglas vive en el input_schema — viven en el cuerpo de la función, exactamente donde tienen que vivir según lo que ya aprendiste en agent-fundamentals-and-tool-calling Módulo 2, Lección 06.

Probemos con un pedido de k=8, muy por encima del tope:

demo = search_docs("What is the cancellation policy for Boardroom bookings?", k=8)
print("k pedido: 8, MAX_K:", MAX_K)
print("resultados devueltos:", len(demo))
for r in demo:
    print(f"  score={r['score']:<7} doc_id={r['doc_id']:<24} len(text)={len(r['text'])}")

Qué esperar:

k pedido: 8, MAX_K: 5
resultados devueltos: 5
  score=10.658  doc_id=cancellation-policy      len(text)=170
  score=6.298   doc_id=cancellation-policy      len(text)=199
  score=6.062   doc_id=membership-tiers-faq     len(text)=191
  score=5.982   doc_id=refund-policy            len(text)=226
  score=5.315   doc_id=no-show-policy           len(text)=200

Aunque se pidieron 8, se devolvieron exactamente 5 — el tope de MAX_K se aplicó sin que el input_schema tuviera que rechazar el input como inválido (k=8 sigue siendo un integer perfectamente válido según el schema; lo que no es válido es que la función lo respete sin límite). Ninguno de los cinco mide más de MAX_CHARS=280, así que truncate los deja pasar sin cambios — el chunk más largo de este top-5, refund-policy con 226 caracteres, sigue lejos del tope. Fíjate en algo más sutil: el segundo y el tercer lugar (cancellation-policy otra vez, y membership-tiers-faq) no tratan directamente sobre la política de cancelación de Boardroom — comparten con la query palabras como "cancellation", "booking" y "policy" sin ser, cada uno, la respuesta completa. Esto no lo arregla el truncado ni el tope de k: es exactamente el mismo patrón léxico que vas a ver, con más detalle, en la Lección 07.


Ejemplo trabajado: el truncado, antes y después

from rag_index import CHUNKS

longest = next(c for c in CHUNKS if c.chunk_id == "operations-manual-raw-001")
print("original, len:", len(longest.text))
print(longest.text)
print()
print("truncado, len:", len(truncate(longest.text)))
print(truncate(longest.text))

Qué esperar:

original, len: 290
Rooms are cleaned between every booking when the gap is 30 minutes or longer. For back-to-back bookings under 30 minutes, cleaning is limited to wiping the table and checking for left-behind belongings. Boardroom receives a full clean every evening regardless of usage, because of its size.

truncado, len: 283
Rooms are cleaned between every booking when the gap is 30 minutes or longer. For back-to-back bookings under 30 minutes, cleaning is limited to wiping the table and checking for left-behind belongings. Boardroom receives a full clean every evening regardless of usage, because of...

operations-manual-raw-001 es, con 290 caracteres, el chunk más largo de todo el corpus canónico —el chunking consciente de estructura del Módulo 1 corta por sección, así que ningún chunk se acerca ni de lejos a los cientos de caracteres de un documento entero—. Aun así, con MAX_CHARS=280, truncate lo recorta a 283 (280 caracteres de contenido más los tres puntos de "..."). Mira dónde cae el corte: justo antes de terminar la frase "...because of its size.", cortando la explicación de por qué Boardroom recibe una limpieza completa cada noche, y dejando solo el hecho ("Boardroom receives a full clean every evening"). Esto no es un error del truncado: es su costo real, honesto. Incluso con chunks tan chicos como los de este corpus, MAX_CHARS=280 puede seguir cortando antes de que termine una idea — un trade-off que cualquier límite de tamaño fijo tiene, y que vale la pena decir con todas sus letras, no esconder.


El trade-off de k, en ambas direcciones

k demasiado chico  -> el chunk correcto puede quedar fuera del top-k, aunque el
                       indice lo hubiera encontrado con un k mayor
k demasiado grande  -> resultados de relevancia marginal se cuelan, y el modelo
                       tiene que procesar mas texto para encontrar lo que sirve

No hay un valor de k "correcto" en abstracto — depende de qué tan grande es el corpus y qué tan ambigua suele ser una query típica. MAX_K=5 es una decisión razonable para un corpus de 57 chunks sobre 13 documentos: suficiente margen para que una query con más de un chunk relevante (como viste en varias de las salidas anteriores, donde el segundo o tercer resultado también tenía sentido) no se quede corta, sin abrir la puerta a devolver el corpus casi entero. El Módulo 6 (evaluación) te da la herramienta real para decidir esto con datos —recall@k medido, no una intuición— en vez de solo con criterio.


Errores comunes

  1. Intentar expresar MAX_K dentro del input_schema. JSON Schema real sí tiene "maximum" como palabra clave, pero el validador mínimo de esta guía (reusado de agent-fundamentals-and-tool-calling) no la implementa, y agregarla sin que el validador la revise sería una promesa vacía en el schema. La protección real, con las herramientas de esta guía, vive en el código — el mismo principio que ya viste con las reglas condicionales de manage_booking.

  2. Truncar el texto antes de calcular el score. El truncado tiene que aplicarse después de que INDEX.search ya calculó los scores sobre el texto completo. Si truncaras el texto primero y luego indexaras o buscaras sobre la versión recortada, estarías empobreciendo la señal de búsqueda para ahorrar texto en un paso que no lo necesita.

  3. Usar min(k, MAX_K) sin el max(1, ...). Sin el segundo límite, un k=0 (que el input_schema no prohíbe explícitamente, porque no tiene "minimum": 1) haría que INDEX.search devuelva una lista vacía siempre, incluso cuando hay resultados relevantes disponibles — un fallo silencioso, sin ningún mensaje de error, que sería difícil de diagnosticar.

  4. Trucar el "..." como si fuera parte del contenido real. El sufijo de truncado es una señal para quien lea el resultado (humano o modelo) de que el texto fue recortado, no una continuación real del documento. Un chunk mostrado sin ese marcador, cortado a mitad de oración, podría leerse como si el documento terminara ahí de verdad.


Ejercicios

Ejercicio 1: Calcula el resultado sin ejecutar (Fácil)

Con MAX_K=5 y MAX_CHARS=280, ¿qué devuelve search_docs("Is there wifi in the Lounge?", k=2) en términos de cantidad de resultados? ¿Y search_docs("Is there wifi in the Lounge?", k=20)?

Ver solución

Con k=2, como 2 < MAX_K (5), bounded_k = max(1, min(2, 5)) = 2 — la función pide como máximo 2 resultados al índice, y devuelve hasta 2 (menos si el índice no tiene tantos con score positivo). Con k=20, bounded_k = max(1, min(20, 5)) = 5 — el tope de MAX_K recorta el pedido a 5, sin importar que se hayan pedido 20. En ningún caso el input_schema rechaza el input: k=2 y k=20 son ambos enteros válidos según el schema; la diferencia la aplica el cuerpo de search_docs, no la validación de forma.

Ejercicio 2: Mide el truncado en un chunk corto (Medio)

boardroom-room-manual-004 (la sección "House Rules" del manual de Boardroom) mide 194 caracteres — menos que MAX_CHARS=280. Ejecuta truncate sobre su texto y confirma qué pasa cuando el texto original ya es más corto que el límite.

Ver solución
from rag_index import CHUNKS

boardroom_text = next(c for c in CHUNKS if c.chunk_id == "boardroom-room-manual-004").text
print("len original:", len(boardroom_text))
print("len truncado:", len(truncate(boardroom_text)))
print(truncate(boardroom_text) == boardroom_text)

Salida esperada:

len original: 194
len truncado: 194
True

Explicación: truncate solo actúa cuando len(text) > max_chars. Con 194 caracteres, menos que el límite de 280, el chunk pasa sin cambios — el texto devuelto es idéntico al original, sin sufijo "...". El truncado no recorta nunca de más; solo interviene cuando realmente hace falta.

Ejercicio 3: Diseña un MAX_CHARS distinto y justifica el trade-off (Difícil)

Supón que cambias MAX_CHARS de 280 a 120. Sin ejecutar nada todavía, predice qué le pasaría a la mayoría de los chunks de este corpus (revisa sus longitudes: van de 103 a 290 caracteres). Después, ejecuta truncate con max_chars=120 sobre membership-tiers-faq-000 (191 caracteres) y confirma tu predicción. ¿Qué información se pierde con ese límite más agresivo?

Ver solución

Predicción: con MAX_CHARS=120, la gran mayoría de los chunks del corpus (53 de los 57 miden más de 120 caracteres) quedarían truncados, y de forma mucho más agresiva que con 280 — se perdería buena parte del contenido de cada fragmento. Solo los cuatro chunks más cortos del corpus (103-112 caracteres) escaparían sin cambios.

mt = next(c for c in CHUNKS if c.chunk_id == "membership-tiers-faq-000")
print(truncate(mt.text, max_chars=120))

Salida esperada:

Basic is the default tier for every new member, with no monthly fee. Pro is a paid upgrade that adds a shorter cancellat...

Qué se pierde: con 120 caracteres, el fragmento corta a mitad de la palabra "cancellation" ("cancellat..." — se cortó "cancellation window, see cancellation-policy, and a discount on every booking"), dejando solo la introducción genérica de la comparación basic/pro y perdiendo justo la mención del descuento, que es la parte más relevante para una pregunta sobre el tier pro. Un MAX_CHARS demasiado agresivo puede truncar exactamente la parte del chunk que responde la pregunta, dejando el resultado técnicamente presente pero prácticamente inútil — el mismo tipo de trade-off que viste con MAX_CHARS=280 sobre operations-manual-raw-001, pero mucho más severo. Elegir el límite correcto es un balance entre presupuesto de contexto y utilidad real del resultado, no un número arbitrario.


Resumen y siguiente paso

  • k y el texto de cada chunk necesitan un límite que el input_schema no puede expresar con las herramientas de esta guía — igual que manage_booking no podía expresar reglas condicionales en agent-fundamentals-and-tool-calling. La protección vive en el código de search_docs, no en el schema.
  • MAX_K=5 recorta cualquier pedido por encima del tope, ejecutado: un pedido de k=8 devuelve 5 resultados, sin que el input_schema rechace el input como inválido.
  • MAX_CHARS=280 trunca el texto de cada chunk con un sufijo "...", ejecutado sobre el chunk más largo del corpus (operations-manual-raw-001, 290 caracteres, recortado a 283) — y vimos, con honestidad, que el corte puede caer antes de que termine una idea relevante, incluso con chunks tan cortos como los de este corpus.
  • Ninguno de los dos límites es "el número correcto" en abstracto: son decisiones de diseño con trade-offs medibles, que el Módulo 6 (evaluación) te da las herramientas para ajustar con datos.

Siguiente lección: 07 — search_docs ejecutada de punta a punta. Con el contrato, el retorno, la description y los límites ya completos, corremos la tool final contra las seis queries fijas del corpus de Reservo, incluida la query-trampa.


Recursos adicionales

  1. agent-fundamentals-and-tool-calling-guide, Módulo 2, Lección 06 (Diseñar tools acotadas) — el mismo principio de que ciertas reglas viven en el código, no en el input_schema, aplicado aquí a límites de tamaño en vez de reglas condicionales.
  2. context-engineering-guide — dónde se decide qué chunks recuperados caben en la ventana de contexto y en qué orden; esta lección solo decide qué pasa el filtro de la tool misma.
  3. JSON Schema — minimum/maximum — cómo JSON Schema real expresaría un límite numérico declarativo, y por qué el validador mínimo de esta guía no lo implementa.
  4. Python — slicing de strings — la operación text[:max_chars] con la que truncate recorta el texto.