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

De índice a herramienta

Descripción

Antes de envolver nada, retomemos exactamente dónde te dejó el Módulo 2: un Chunk con sus metadatos, un índice BM25 construido sobre los 13 documentos de Reservo, y una función search(query, k) que, dada una pregunta en lenguaje natural, devuelve una lista de chunks con su score. Esa función corre perfecto desde una consola de Python. El problema que resuelve esta lección es otro: un modelo de lenguaje no tiene una consola de Python. No puede escribir INDEX.search("¿hay wifi en el Lounge?", k=3) y presionar enter. Solo puede pedir cosas que estén declaradas en un formato que entiende — el mismo contrato name/description/input_schema que ya conoces de agent-fundamentals-and-tool-calling.

Esta lección hace dos cosas: primero, retoma el índice del Módulo 2 completo y lo deja guardado como un módulo reusable (rag_index) que el resto de este módulo va a importar, tal como agent-fundamentals-and-tool-calling reusa reservo_tools. Segundo, ejecuta search() directamente —sin ningún contrato todavía— para que veas, con claridad, exactamente qué información le falta a esa llamada para que un modelo pudiera haberla generado él mismo.

Conexión con el módulo

Esta es la lección "puente" del módulo: conecta la ingeniería que ya construiste (Módulos 1 y 2) con el trabajo de contrato que viene en las lecciones 03-06. Todo lo que declares desde la lección 03 en adelante se apoya en el rag_index que dejas armado aquí.


Analogía: el cuarto de estanterías, antes del mostrador

Retomando la analogía del mostrador de atención de la introducción: esta lección es el momento antes de que exista el mostrador. El cuarto de estanterías ya está armado —los documentos están limpios, chunkeados, indexados, buscables por cualquiera que tenga la llave—, pero todavía no hay nadie en la entrada para recibir preguntas de gente que no tiene esa llave. Tú, ahora mismo, eres la única persona con la llave: puedes entrar y buscar lo que quieras escribiendo search(query, k) directamente. El modelo no. Esta lección es el recorrido por el cuarto ya armado, justo antes de construir el mostrador.


Retomando el índice del Módulo 2

Guarda este bloque completo como rag_index.py en tu directorio de trabajo — es exactamente el índice del Módulo 2 (mismo BM25Index, mismo k1=1.5/b=0.75, mismo tokenize), reconstruido sobre el corpus canónico de 57 chunks que dejó el Módulo 1 —no una versión reducida—: los 13 documentos de Reservo, chunkeados por estructura (chunk_id con relleno de ceros, "cancellation-policy-000"), exactamente como los dejó ingest_document en el Módulo 1, Lección 07. Las lecciones siguientes hacen from rag_index import ... en vez de repetir estas líneas cada vez.

# rag_index.py
import math
import re
from collections import Counter
from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class Chunk:
    chunk_id: str
    doc_id: str
    title: str
    section: str
    position: int
    text: str


def tokenize(text):
    return re.findall(r"[a-z0-9]+", text.lower())


class BM25Index:
    """Indice BM25 hand-rolled: Counter/math para term frequency e IDF,
    numpy solo para ordenar el vector de scores (argsort)."""

    def __init__(self, chunks, k1=1.5, b=0.75):
        self.chunks = chunks
        self.k1 = k1
        self.b = b
        self.doc_tokens = [tokenize(c.text) for c in chunks]
        self.doc_len = [len(toks) for toks in self.doc_tokens]
        self.avg_doc_len = sum(self.doc_len) / len(self.doc_len)
        self.doc_freqs = [Counter(toks) for toks in self.doc_tokens]
        self.n_docs = len(chunks)
        self.idf = self._compute_idf()

    def _compute_idf(self):
        df = Counter()
        for toks in self.doc_tokens:
            for term in set(toks):
                df[term] += 1
        return {
            term: math.log((self.n_docs - freq + 0.5) / (freq + 0.5) + 1)
            for term, freq in df.items()
        }

    def _score_one(self, query_tokens, i):
        score = 0.0
        freqs = self.doc_freqs[i]
        dl = self.doc_len[i]
        for term in query_tokens:
            f = freqs.get(term, 0)
            if f == 0:
                continue
            idf = self.idf.get(term, 0.0)
            numerator = f * (self.k1 + 1)
            denominator = f + self.k1 * (1 - self.b + self.b * dl / self.avg_doc_len)
            score += idf * numerator / denominator
        return score

    def search(self, query, k=5):
        query_tokens = tokenize(query)
        scores = np.array([self._score_one(query_tokens, i) for i in range(self.n_docs)])
        # kind="stable" para que el orden sea reproducible ante empates,
        # como exige la regla dura de esta guia.
        order = np.argsort(-scores, kind="stable")
        results = []
        for i in order[:k]:
            if scores[i] <= 0:
                continue
            results.append((self.chunks[i], round(float(scores[i]), 3)))
        return results


# Corpus canonico de Reservo: 13 documentos, chunkeados por estructura en
# el Modulo 1 (Leccion 07/08) -- 57 chunks en total (operations-manual-raw
# ya viene en su version LIMPIA, plegada al indice desde el Modulo 2 en
# adelante). Misma forma exacta que el Modulo 1/2: doc_id -> (titulo,
# formato, [(seccion, texto), ...]).
_DOCS = {
    "cancellation-policy": ("Cancellation Policy", "md", [
        ("Basic Tier Cancellation Window",
         "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."
        ),
        ("Pro Tier Cancellation Window",
         "Pro members get a shorter, friendlier window: cancellations up to 4 hours "
         "before the reserved start time are free of charge. This is one of the perks "
         "of the pro tier, alongside the 20% discount on hourly rates."
        ),
        ("How to Cancel",
         "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."
        ),
        ("Related Policies",
         "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."
        ),
    ]),
    "no-show-policy": ("No-Show Policy", "md", [
        ("What Counts as a No-Show",
         "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`."
        ),
        ("What Happens on a No-Show",
         "No-shows are charged the full amount of the booking. Unlike a late "
         "cancellation, there is no partial leniency: the no-show fee equals the "
         "entire reserved price, and it is never refunded under `refund-policy`."
        ),
        ("Repeated No-Shows",
         "Members with three or more no-shows in a rolling 30-day window lose the "
         "ability to book same-day reservations; all future bookings must be made at "
         "least 24 hours in advance until the pattern clears."
        ),
        ("Why This Policy Exists",
         "Rooms held for a no-show cannot be re-offered to another member during that "
         "window, so the fee reflects real lost capacity, not a punitive charge."
        ),
    ]),
    "refund-policy": ("Refund Policy", "md", [
        ("What Qualifies for a Refund",
         "A refund applies when a booking is cancelled within the free window "
         "described in `cancellation-policy`, or when Reservo cancels a confirmed "
         "booking because of a facility issue, such as a maintenance problem or a "
         "power outage."
        ),
        ("Refund Amount and Timing",
         "Eligible refunds return the full amount charged for the booking to the "
         "original payment method. Processing takes up to 5 business days once the "
         "cancellation is confirmed in the booking system."
        ),
        ("What Does Not Qualify for a Refund",
         "Cancellations made outside the free window are not eligible; the booking "
         "amount is forfeited under `cancellation-policy`. No-shows are never "
         "refunded, regardless of membership tier; see `no-show-policy` for that "
         "separate case."
        ),
        ("How to Request a Refund",
         "Eligible refunds are issued automatically once a qualifying cancellation is "
         "processed; members do not need to submit a separate request. Questions "
         "about a specific refund go to member support through the booking system."
        ),
    ]),
    "booking-faq": ("Booking FAQ", "md", [
        ("How Do I Book a Room?",
         "Bookings are made through the Reservo booking system by choosing a room, a "
         "date, and a start and end time. A confirmation appears immediately, and the "
         "room is held exclusively for that window."
        ),
        ("Is a Deposit Required to Book?",
         "Yes. A deposit equal to the full session amount is charged at the time of "
         "booking, through the payment method on file; see `payment-methods-faq` for "
         "what is accepted."
        ),
        ("Can I Book More Than One Room at a Time?",
         "Yes, a member can hold bookings in multiple rooms at once, as long as the "
         "times do not overlap for the same member. Each room booking is billed and "
         "cancelled separately."
        ),
        ("How Far in Advance Can I Book a Room?",
         "Rooms can be booked up to 60 days in advance. Same-day booking is allowed, "
         "subject to the same-day restriction described in `no-show-policy` for "
         "members with repeated no-shows."
        ),
    ]),
    "membership-tiers-faq": ("Membership Tiers FAQ", "md", [
        ("What Is the Difference Between Basic and Pro?",
         "Basic is the default tier for every new member, with no monthly fee. Pro is "
         "a paid upgrade that adds a shorter cancellation window, see "
         "`cancellation-policy`, and a discount on every booking."
        ),
        ("How Much Discount Does the Pro Tier Get?",
         "Pro members receive a 20% discount on the hourly rate of every room, "
         "applied automatically at checkout. No code or coupon is needed; the "
         "discount is tied to the membership tier on the account."
        ),
        ("How Do I Upgrade to Pro?",
         "Upgrading to Pro takes effect immediately from the account settings page. "
         "The new cancellation window and the 20% discount apply starting with the "
         "very next booking made after the upgrade."
        ),
        ("Can I Downgrade Back to Basic?",
         "Yes, at any time from the account settings page. Downgrading takes effect "
         "on the next booking; any booking already confirmed under Pro keeps its "
         "Pro-tier terms."
        ),
    ]),
    "payment-methods-faq": ("Payment Methods FAQ", "md", [
        ("What Payment Methods Does Reservo Accept?",
         "Reservo accepts major credit and debit cards on file with the account. The "
         "same card charged for a booking deposit is used automatically for any "
         "no-show or late-cancellation charge."
        ),
        ("Does Reservo Accept Cash?",
         "No. All bookings, deposits, and no-show charges are processed "
         "electronically through the payment method on file."
        ),
        ("What Happens if a Card Is Declined?",
         "A declined card cancels the booking hold immediately; the room is released "
         "back to the schedule. The member is notified and can retry with the same "
         "card or add a different one."
        ),
        ("Can I Split a Payment Between Two Cards?",
         "No, a single booking can only be charged to one card on file at a time. "
         "Members who want to change which card is used should update the default "
         "payment method before booking."
        ),
    ]),
    "wifi-and-equipment-faq": ("Wifi and Equipment FAQ", "md", [
        ("Is Wifi Included in Every Room?",
         "Yes, building-wide wifi reaches every room, from Phonebooth to Boardroom, "
         "at no extra cost. Coverage is the same in every room regardless of size or "
         "hourly rate."
        ),
        ("What Is the Wifi Network Name and Password?",
         "The network name and password are posted on a card inside each room and "
         "also shown on the booking confirmation screen. The password rotates monthly "
         "for security."
        ),
        ("What Common Equipment Is Available Outside the Rooms?",
         "The building shares a printer and a water station on the ground floor, "
         "available to any member with an active booking. Room-specific equipment is "
         "listed in each room manual."
        ),
        ("Who Do I Contact if the Wifi Is Down?",
         "Report a wifi outage through the booking system's support option; staff "
         "follow the internal reset procedure and typically restore the connection "
         "within a few minutes."
        ),
    ]),
    "focus-room-manual": ("Focus Room Manual", "html", [
        ("Overview",
         "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."
        ),
        ("Capacity & Layout",
         "Capacity: 1 person. The room has one desk, one chair, and a soundproofed "
         "door. There is no window, by design, to minimize visual distraction."
        ),
        ("Equipment",
         "- 27-inch monitor with HDMI input; - Adjustable desk lamp; - Wall outlet "
         "with two USB-C ports; - Building-wide wifi (see wifi-and-equipment-faq)"
        ),
        ("Booking & Rate",
         "Base rate: $25.00 per hour (2500 cents), the lowest rate in the building. "
         "Pro members receive the standard 20% discount on every booking."
        ),
        ("House Rules",
         "Focus is not soundproof against phone ringtones; members are asked to keep "
         "devices on silent. Food is allowed but no hot meals, due to the room's "
         "small size and lack of ventilation."
        ),
    ]),
    "studio-room-manual": ("Studio Room Manual", "html", [
        ("Overview",
         "Studio is Reservo's small-team room, built for a working session that needs "
         "a table and a whiteboard rather than a single desk. It sits between Focus "
         "and Boardroom in both size and price."
        ),
        ("Capacity & Layout",
         "Capacity: 4 people. The room has a round table, four chairs, and a "
         "wall-mounted whiteboard. A large window faces the courtyard."
        ),
        ("Equipment",
         "- 43-inch monitor with HDMI and USB-C input; - Wall-mounted whiteboard with "
         "markers; - Conference speakerphone; - Building-wide wifi (see "
         "wifi-and-equipment-faq)"
        ),
        ("Booking & Rate",
         "Base rate: $40.00 per hour (4000 cents). Pro members receive the standard "
         "20% discount on every booking."
        ),
        ("House Rules",
         "Studio can be booked for up to 4 consecutive hours per reservation. The "
         "whiteboard must be wiped clean before the next booking begins."
        ),
    ]),
    "boardroom-room-manual": ("Boardroom Room Manual", "html", [
        ("Overview",
         "Boardroom is Reservo's largest room, reserved for formal meetings, client "
         "presentations, and full-team gatherings. It is the only room with a "
         "dedicated presentation screen."
        ),
        ("Capacity & Layout",
         "Capacity: 10 people. The room has a long table, ten chairs, and a "
         "wall-mounted presentation screen at the head of the table."
        ),
        ("Equipment",
         "- 75-inch presentation screen with HDMI and wireless casting; - Conference "
         "speakerphone with ceiling microphones; - Wall-mounted whiteboard with "
         "markers; - Building-wide wifi (see wifi-and-equipment-faq)"
        ),
        ("Booking & Rate",
         "Base rate: $80.00 per hour (8000 cents), the highest rate in the building. "
         "Pro members receive the standard 20% discount on every booking."
        ),
        ("House Rules",
         "Boardroom receives a full clean every evening regardless of usage, because "
         "of its size. Food is allowed only during bookings longer than 2 hours, and "
         "must be cleared before the room is released."
        ),
    ]),
    "lounge-room-manual": ("Lounge Room Manual", "html", [
        ("Overview",
         "Lounge is Reservo's informal meeting room, built for a relaxed conversation "
         "rather than a formal presentation. It is open-plan, with no door separating "
         "it from the hallway."
        ),
        ("Capacity & Layout",
         "Capacity: 6 people. The room has two low sofas, a coffee table, and extra "
         "chairs stacked against the wall for larger groups."
        ),
        ("Equipment",
         "- 32-inch monitor with HDMI input; - Bluetooth speaker; - Coffee and tea "
         "station; - Building-wide wifi (see wifi-and-equipment-faq)"
        ),
        ("Booking & Rate",
         "Base rate: $50.00 per hour (5000 cents). Pro members receive the standard "
         "20% discount on every booking."
        ),
        ("House Rules",
         "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."
        ),
    ]),
    "phonebooth-room-manual": ("Phonebooth Room Manual", "html", [
        ("Overview",
         "Phonebooth is Reservo's smallest room, built for a single short call rather "
         "than a working session. It is the only room designed to be used standing "
         "up."
        ),
        ("Capacity & Layout",
         "Capacity: 1 person. The room has a narrow shelf-desk and a single stool, "
         "with just enough space to stand and pace during a call."
        ),
        ("Equipment",
         "- Wall-mounted phone charging dock; - Small desk fan; - Building-wide wifi "
         "(see wifi-and-equipment-faq)"
        ),
        ("Booking & Rate",
         "Base rate: $15.00 per hour (1500 cents), the lowest rate in the building "
         "alongside its small size. Pro members receive the standard 20% discount on "
         "every booking."
        ),
        ("House Rules",
         "Phonebooth bookings are capped at 1 hour per reservation, since the room is "
         "designed for short calls. Rooms with a physical door -- Focus, Phonebooth, "
         "and Boardroom -- use a keypad code that rotates weekly."
        ),
    ]),
    "operations-manual-raw": ("Operations Manual (raw, cleaned)", "txt", [
        ("Opening and Closing Procedures",
         "The building opens at 07:00 and closes at 22:00 on weekdays. On weekends "
         "the building opens at 09:00 and closes at 18:00. Staff must complete a "
         "walkthrough of every room before opening to confirm no equipment was left "
         "running overnight."
        ),
        ("Cleaning Procedures",
         "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."
        ),
        ("Key and Access Handling",
         "Rooms with a physical door - Focus, Phonebooth, and Boardroom - use a "
         "keypad code that rotates weekly. Studio and Lounge are open-plan and do not "
         "require a code. Staff must update the keypad codes every Monday before "
         "07:00 and log the change in the access log."
        ),
        ("Wifi Reset Procedure",
         "If a member reports the wifi is down, staff should first check the router "
         "in the utility closet before escalating. A full reset takes approximately 3 "
         "minutes and drops every room's connection at once, so it should only be "
         "done between bookings, never during an active reservation."
        ),
    ]),
}

DOC_ORDER = [
    "cancellation-policy", "no-show-policy", "refund-policy", "booking-faq",
    "membership-tiers-faq", "payment-methods-faq", "wifi-and-equipment-faq",
    "focus-room-manual", "studio-room-manual", "boardroom-room-manual",
    "lounge-room-manual", "phonebooth-room-manual", "operations-manual-raw",
]


def build_corpus() -> list[Chunk]:
    """Reproduce ingest_document(doc_id, fmt, raw_text, max_size=400) del
    Modulo 1: cada seccion mide menos que max_size=400 (chunk_by_structure
    nunca sub-corta por oracion), asi que cada (seccion, texto) se vuelve
    exactamente un Chunk, en orden, con chunk_id de 3 digitos."""
    chunks = []
    for doc_id in DOC_ORDER:
        title, fmt, sections = _DOCS[doc_id]
        for position, (section, text) in enumerate(sections):
            chunks.append(Chunk(
                chunk_id=f"{doc_id}-{position:03d}",
                doc_id=doc_id, title=title, section=section,
                position=position, text=text,
            ))
    return chunks


CHUNKS = build_corpus()
INDEX = BM25Index(CHUNKS)

build_corpus() reconstruye exactamente los 57 chunks que dejó el mini-proyecto del Módulo 1 (5 secciones por manual HTML × 5 manuales = 25, 4 secciones por documento markdown × 7 = 28, 4 secciones del manual de operaciones ya limpio = 4; 25 + 28 + 4 = 57) — no una versión reducida de un chunk por documento. Cada chunk_id lleva el doc_id como prefijo y la posición con relleno de ceros ("cancellation-policy-000", no "cancellation-policy-0"), tal como lo dejó ingest_document en el Módulo 1, Lección 07. INDEX = BM25Index(CHUNKS) construye el mismo índice que ya conoces del Módulo 2, esta vez sobre el corpus fino de verdad.


Ejemplo trabajado: INDEX.search(), todavía sin contrato

Con rag_index.py guardado, esto es exactamente lo que hacías al final del Módulo 2: importar el índice y llamarlo directamente.

from rag_index import INDEX

hits = INDEX.search("How much discount does the pro tier get?", k=3)
for chunk, score in hits:
    print(f"score={score:<7} {chunk!r}")

Qué esperar:

score=9.435   Chunk(chunk_id='cancellation-policy-001', doc_id='cancellation-policy', title='Cancellation Policy', section='Pro Tier Cancellation Window', position=1, text='Pro members get a shorter, friendlier window: cancellations up to 4 hours before the reserved start time are free of charge. This is one of the perks of the pro tier, alongside the 20% discount on hourly rates.')
score=6.537   Chunk(chunk_id='membership-tiers-faq-001', doc_id='membership-tiers-faq', title='Membership Tiers FAQ', section='How Much Discount Does the Pro Tier Get?', position=1, text='Pro members receive a 20% discount on the hourly rate of every room, applied automatically at checkout. No code or coupon is needed; the discount is tied to the membership tier on the account.')
score=5.733   Chunk(chunk_id='membership-tiers-faq-000', doc_id='membership-tiers-faq', title='Membership Tiers FAQ', section='What Is the Difference Between Basic and Pro?', position=0, text='Basic is the default tier for every new member, with no monthly fee. Pro is a paid upgrade that adds a shorter cancellation window, see `cancellation-policy`, and a discount on every booking.')

Esto funciona, en el sentido de que no truena y produce un ranking — pero fíjate en el resultado con calma, porque no es el que esperarías: el top-1 no es membership-tiers-faq — es cancellation-policy-001 (la sección "Pro Tier Cancellation Window"), y por un margen real (9.435 contra 6.537). Ese chunk es sobre la ventana de cancelación del tier pro, no sobre el descuento, pero su única oración menciona "pro tier" y "20% discount" al pasar — suficiente coincidencia léxica exacta para ganarle al chunk que sí trata el tema. membership-tiers-faq-001, la sección que literalmente se llama "How Much Discount Does the Pro Tier Get?", queda en segundo lugar. Es exactamente el tipo de matiz que vas a leer con más cuidado en la Lección 07: dos documentos pueden compartir vocabulario sin compartir tema, y el score es lo que permite distinguir cuál es cuál — la posición sola no alcanza. El problema de fondo no es que search() esté "mal" — es que nada de esto es algo que un modelo pueda producir por sí mismo. Mira de nuevo la primera línea: INDEX.search("How much discount does the pro tier get?", k=3). Para que el modelo generara esa llamada, tendría que saber que existe un objeto llamado INDEX, que tiene un método search, que ese método acepta query y k en ese orden, y que devuelve tuplas de (Chunk, score). Nada de eso es información que el modelo pueda inferir del texto de la conversación — es la forma interna de tu código Python, y el modelo nunca ve tu código.


Lo que le falta a search() para ser una tool

Compara con lo que ya sabes de get_quote en agent-fundamentals-and-tool-calling: esa función tampoco cambió de comportamiento cuando se convirtió en tool — lo que se agregó fue el contrato alrededor. Para search(), el mismo ejercicio deja tres huecos concretos:

1. name          -> "search_docs" no existe todavia como identificador declarado
2. description   -> nada le dice al modelo QUE hace esta busqueda ni CUANDO usarla
3. input_schema  -> el modelo no sabe que "query" es un string y "k" un entero,
                     ni si k es obligatorio, ni si tiene limite

Y hay un cuarto hueco, más sutil, que no tiene equivalente exacto en get_quote: la forma del resultado. get_quote devuelve {"price_cents": 6000} — un único valor, fácil de serializar tal cual. search() devuelve una lista de tuplas (Chunk, score), y Chunk es un dataclass que no es serializable directamente con json.dumps (lo vas a ver fallar, ejecutado, en la Lección 04). Envolver una función de recuperación trae ese problema extra que envolver un cálculo de precio no tenía.


Errores comunes

  1. Pensar que basta con ponerle un nombre a la función para que sea una tool. search_docs = INDEX.search no declara ningún contrato — sigue siendo una función de Python invisible para el modelo. Una tool existe cuando hay un dict con name/description/input_schema que el modelo puede leer.

  2. Suponer que el modelo puede inferir la firma de search() del nombre solo. El modelo no ve def search(self, query, k=5). Todo lo que sabe sobre los argumentos válidos sale del input_schema — si no lo declaras, no existe para el modelo, sin importar qué tan bien documentado esté tu código internamente.

  3. Olvidar que el resultado también necesita una forma declarada. No basta con que search() funcione y devuelva algo útil para ti en Python — ese "algo útil" tiene que llegar en una forma que el modelo pueda leer como texto. Un Chunk crudo no cumple eso todavía (Lección 04).

  4. Reescribir el índice del Módulo 2 de memoria, con pequeñas diferencias. Usa el bloque de rag_index.py de esta lección tal cual — las lecciones siguientes asumen exactamente este comportamiento (mismos scores, mismo orden) para que sus salidas sean reproducibles.


Ejercicios

Ejercicio 1: Encuentra los tres huecos (Fácil)

Sin ejecutar nada: dada la llamada INDEX.search("Is there wifi in the Lounge?", k=2), enumera qué necesitaría saber un modelo —sin ver tu código— para poder generar el equivalente de esa llamada como una petición de tool.

Ver solución

El modelo necesitaría saber, como mínimo: (1) que existe una tool con un nombre al que puede referirse (algo como "search_docs", no "INDEX.search" — el modelo nunca ve nombres de variables de tu código); (2) que esa tool acepta un argumento de texto libre para la pregunta (query) y, opcionalmente, cuántos resultados quiere (k) — información que hoy solo vive en la firma de la función Python, invisible para el modelo; y (3) cuándo le conviene usar esa tool en vez de otra —información que hoy no existe en ningún lado, porque search() no tiene una description—. Los tres son exactamente name, input_schema y description: el contrato completo que las lecciones 03-05 declaran.

Ejercicio 2: Ejecuta y observa la forma cruda (Medio)

Con rag_index.py guardado, ejecuta INDEX.search("What payment methods does Reservo accept?", k=2) e imprime el resultado. Después, intenta json.dumps(hits) sobre la lista completa de tuplas (Chunk, score) que devuelve. ¿Qué pasa?

Ver solución
import json
from rag_index import INDEX

hits = INDEX.search("What payment methods does Reservo accept?", k=2)
for chunk, score in hits:
    print(score, chunk.doc_id)

try:
    print(json.dumps(hits))
except TypeError as e:
    print(f"TypeError: {e}")

Salida esperada:

10.083 booking-faq
4.001 cancellation-policy
TypeError: Object of type Chunk is not JSON serializable

Explicación: el top-1 no es payment-methods-faq — es booking-faq (10.083), y vale la pena entender por qué antes de seguir. El chunk ganador, booking-faq-001 ("Is a Deposit Required to Book?"), no habla de métodos de pago como tema, pero su texto termina con una referencia cruzada literal: "...through the payment method on file; see payment-methods-faq for what is accepted." Esa única frase repite "payment" dos veces (una como palabra suelta, otra como parte del nombre payment-methods-faq entre comillas invertidas) y agrega "methods" y "what" — suficiente coincidencia de términos exactos para superar al chunk que sí trata el tema. El segundo lugar, cancellation-policy (4.001), es otra vez su sección "Related Policies": comparte con la query un único término común ("what"), pero con un corpus de solo 57 chunks sin remoción de stopwords, hasta eso alcanza para un score positivo. Vas a ver este mismo patrón —un fragmento que solo menciona el tema correcto por su nombre le gana al chunk que de verdad lo trata— varias veces en el módulo, con más detalle en la Lección 07. Pero lo que esta lección quería mostrar es otra cosa: json.dumps(hits) falla, porque hits es una lista de tuplas que contienen objetos Chunk, y un dataclass no tiene, por defecto, una forma de convertirse a JSON. Esta es exactamente la forma cruda que el Módulo 2 te dejó: útil para ti en una consola de Python, inútil todavía para viajar de vuelta a un modelo. La Lección 04 resuelve esto.

Ejercicio 3: Diseña, sin declarar todavía, el contrato que falta (Difícil)

Basándote en lo que ya sabes de agent-fundamentals-and-tool-calling Módulo 2, escribe en prosa —sin código todavía— qué type debería tener cada campo del futuro input_schema de search_docs (query y k), y cuál de los dos debería ser required. Justifica tu respuesta pensando en cómo se usaría la tool en una conversación real.

Ver solución

query debería ser "type": "string" y obligatorio: no hay forma de buscar nada sin una pregunta, así que omitirlo no tiene un valor por defecto razonable. k debería ser "type": "integer" y opcional: en la mayoría de las conversaciones el modelo no necesita decidir explícitamente cuántos resultados quiere — un valor por defecto razonable (por ejemplo, 3) cubre el caso común, y dejarlo opcional evita que el modelo tenga que inventar un número cada vez que llama a la tool. Esta es exactamente la forma que declara la Lección 03: "required": ["query"], con k presente en properties pero ausente de required.


Resumen y siguiente paso

  • Retomamos el índice del Módulo 2 completo (Chunk, tokenize, BM25Index, el corpus canónico de 57 chunks sobre los 13 documentos) como el módulo rag_index, que las lecciones siguientes importan en vez de repetir.
  • Ejecutamos INDEX.search(query, k) directamente y confirmamos que funciona — el problema no es la búsqueda, es que nada de esa llamada es algo que un modelo pueda generar sin ver tu código.
  • Identificamos tres huecos del contrato (name, description, input_schema) y un cuarto propio de envolver una recuperación: la forma del resultado, porque un Chunk crudo no es serializable con json.dumps tal cual.

Siguiente lección: 03 — El contrato de search_docs. Declaramos el input_schema completo —query obligatorio, k opcional— con el mismo patrón de agent-fundamentals-and-tool-calling, y lo validamos con un validador reusado.


Recursos adicionales

  1. Anthropic — Tool use (function calling) overview — La referencia oficial que confirma que el modelo solo lee el contrato declarado, nunca el código detrás.
  2. agent-fundamentals-and-tool-calling-guide, Módulo 2, Lección 02 (Qué es una herramienta) — el mismo salto de "función que funciona" a "función con contrato", ahora aplicado a una recuperación en vez de a un cálculo.
  3. Python — dataclasses — la referencia de @dataclass, usada en Chunk, y por qué no es serializable con json.dumps sin ayuda.
  4. Python — json — la librería con la que confirmamos, ejecutando, que un Chunk crudo falla al serializarse.