Module 3: `search_docs` as an Agent Tool

From Index to Tool

Description

Before wrapping anything, let's pick up exactly where Module 2 left you: a Chunk with its metadata, a BM25 index built over Reservo's 13 documents, and a search(query, k) function that, given a natural-language question, returns a list of chunks with their score. That function runs perfectly from a Python console. The problem this lesson solves is a different one: a language model doesn't have a Python console. It can't type INDEX.search("is there wifi in the Lounge?", k=3) and press enter. It can only ask for things declared in a format it understands — the same name/description/input_schema contract you already know from agent-fundamentals-and-tool-calling.

This lesson does two things: first, it picks the complete Module 2 index back up and saves it as a reusable module (rag_index) that the rest of this module will import, the same way agent-fundamentals-and-tool-calling reuses reservo_tools. Second, it runs search() directly — still with no contract at all — so you can see, clearly, exactly what information that call is missing for a model to have been able to generate it itself.

Connection to the module

This is the module's "bridge" lesson: it connects the engineering you already built (Modules 1 and 2) with the contract work coming in lessons 03-06. Everything you declare from lesson 03 on rests on the rag_index you set up here.


Analogy: the room full of shelves, before the help desk

Picking back up the introduction's help-desk analogy: this lesson is the moment before the desk exists. The room full of shelves is already set up — the documents are clean, chunked, indexed, searchable by anyone with the key — but there's still nobody at the entrance to take questions from people who don't have that key. You, right now, are the only person with the key: you can walk in and search for whatever you want by typing search(query, k) directly. The model can't. This lesson is the walk-through of the already-set-up room, right before building the desk.


Picking Module 2's index back up

Save this whole block as rag_index.py in your working directory — it's exactly Module 2's index (same BM25Index, same k1=1.5/b=0.75, same tokenize), rebuilt over the canonical 57-chunk corpus Module 1 left behind — not a reduced version: Reservo's 13 documents, chunked by structure (chunk_id zero-padded, "cancellation-policy-000"), exactly as ingest_document left them in Module 1, Lesson 07. The lessons that follow do from rag_index import ... instead of repeating these lines every time.

# 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:
    """Hand-rolled BM25 index: Counter/math for term frequency and IDF,
    numpy only to sort the score vector (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" so the order is reproducible on ties,
        # as this guide's hard rule requires.
        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


# Reservo's canonical corpus: 13 documents, chunked by structure in
# Module 1 (Lesson 07/08) -- 57 chunks total (operations-manual-raw
# already comes in its CLEAN version, folded into the index from Module 2
# onward). Same exact shape as Module 1/2: doc_id -> (title,
# format, [(section, text), ...]).
_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]:
    """Reproduces ingest_document(doc_id, fmt, raw_text, max_size=400) from
    Module 1: every section is shorter than max_size=400 (chunk_by_structure
    never sub-cuts by sentence), so every (section, text) becomes
    exactly one Chunk, in order, with a 3-digit chunk_id."""
    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() reconstructs exactly the 57 chunks Module 1's mini-project left behind (5 sections per HTML manual × 5 manuals = 25, 4 sections per markdown document × 7 = 28, 4 sections of the already-clean operations manual = 4; 25 + 28 + 4 = 57) — not a reduced version with one chunk per document. Every chunk_id carries doc_id as a prefix and the position zero-padded ("cancellation-policy-000", not "cancellation-policy-0"), exactly as ingest_document left it in Module 1, Lesson 07. INDEX = BM25Index(CHUNKS) builds the same index you already know from Module 2, this time over the real full corpus.


Worked example: INDEX.search(), still with no contract

With rag_index.py saved, this is exactly what you were doing at the end of Module 2: importing the index and calling it directly.

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}")

What to expect:

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.')

This works, in the sense that it doesn't crash and produces a ranking — but look at the result carefully, because it isn't what you'd expect: the top-1 is not membership-tiers-faq — it's cancellation-policy-001 (the "Pro Tier Cancellation Window" section), and by a real margin (9.435 against 6.537). That chunk is about the pro tier's cancellation window, not about the discount, but its one sentence mentions "pro tier" and "20% discount" in passing — enough exact lexical overlap to beat the chunk that actually covers the topic. membership-tiers-faq-001, the section literally called "How Much Discount Does the Pro Tier Get?", comes in second. This is exactly the kind of nuance you'll read more carefully in Lesson 07: two documents can share vocabulary without sharing a topic, and the score is what lets you tell which is which — position alone isn't enough. The underlying problem isn't that search() is "wrong" — it's that none of this is something a model could produce on its own. Look again at the first line: INDEX.search("How much discount does the pro tier get?", k=3). For the model to generate that call, it would have to know an object called INDEX exists, that it has a search method, that method accepts query and k in that order, and that it returns tuples of (Chunk, score). None of that is information the model can infer from the conversation's text — it's the internal shape of your Python code, and the model never sees your code.


What search() is missing to become a tool

Compare this with what you already know about get_quote in agent-fundamentals-and-tool-calling: that function didn't change behavior when it became a tool either — what got added was the contract around it. For search(), the same exercise leaves three concrete gaps:

1. name          -> "search_docs" doesn't exist yet as a declared identifier
2. description   -> nothing tells the model WHAT this search does or WHEN to use it
3. input_schema  -> the model doesn't know "query" is a string and "k" an integer,
                     whether k is required, or whether it has a limit

And there's a fourth, more subtle gap with no exact equivalent in get_quote: the shape of the result. get_quote returns {"price_cents": 6000} — a single value, easy to serialize as-is. search() returns a list of (Chunk, score) tuples, and Chunk is a dataclass that's not directly serializable with json.dumps (you'll see it fail, executed, in Lesson 04). Wrapping a retrieval function brings that extra problem that wrapping a price calculation didn't have.


Common mistakes

  1. Thinking it's enough to give the function a name for it to be a tool. search_docs = INDEX.search doesn't declare any contract — it's still a Python function invisible to the model. A tool exists when there's a dict with name/description/input_schema the model can read.

  2. Assuming the model can infer search()'s signature from the name alone. The model never sees def search(self, query, k=5). Everything it knows about valid arguments comes from the input_schema — if you don't declare it, it doesn't exist for the model, no matter how well-documented your code is internally.

  3. Forgetting that the result also needs a declared shape. It's not enough for search() to work and return something useful to you in Python — that "something useful" has to arrive in a shape the model can read as text. A raw Chunk doesn't satisfy that yet (Lesson 04).

  4. Rewriting Module 2's index from memory, with small differences. Use this lesson's rag_index.py block as-is — the lessons that follow assume exactly this behavior (same scores, same order) so their outputs are reproducible.


Exercises

Exercise 1: Find the three gaps (Easy)

Without running anything: given the call INDEX.search("Is there wifi in the Lounge?", k=2), list what a model would need to know — without seeing your code — to be able to generate the equivalent of that call as a tool request.

See solution

The model would need to know, at minimum: (1) that a tool exists with a name it can refer to (something like "search_docs", not "INDEX.search" — the model never sees your code's variable names); (2) that this tool accepts a free-text argument for the question (query) and, optionally, how many results it wants (k) — information that today only lives in the Python function's signature, invisible to the model; and (3) when it makes sense to use this tool instead of another — information that doesn't exist anywhere today, because search() has no description. All three are exactly name, input_schema, and description: the complete contract lessons 03-05 declare.

Exercise 2: Run it and observe the raw shape (Medium)

With rag_index.py saved, run INDEX.search("What payment methods does Reservo accept?", k=2) and print the result. Then try json.dumps(hits) on the complete list of (Chunk, score) tuples it returns. What happens?

See solution
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}")

Expected output:

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

Explanation: the top-1 is not payment-methods-faq — it's booking-faq (10.083), and it's worth understanding why before moving on. The winning chunk, booking-faq-001 ("Is a Deposit Required to Book?"), doesn't discuss payment methods as its topic, but its text ends with a literal cross-reference: "...through the payment method on file; see payment-methods-faq for what is accepted." That single sentence repeats "payment" twice (once as a standalone word, once as part of the payment-methods-faq name inside backticks) and adds "methods" and "what" — enough exact term overlap to beat the chunk that actually covers the topic. Second place, cancellation-policy (4.001), is again its "Related Policies" section: it shares only one common term with the query ("what"), but with a corpus of only 57 chunks and no stopword removal, even that is enough for a positive score. You're going to see this same pattern — a fragment that merely mentions the right topic by name beating the chunk that actually covers it — several more times in this module, in more detail in Lesson 07. But what this lesson wanted to show is something else: json.dumps(hits) fails, because hits is a list of tuples containing Chunk objects, and a dataclass doesn't, by default, have a way to convert itself to JSON. This is exactly the raw shape Module 2 left you with: useful to you in a Python console, still useless for traveling back to a model. Lesson 04 solves this.

Exercise 3: Design, without declaring it yet, the missing contract (Hard)

Based on what you already know from agent-fundamentals-and-tool-calling Module 2, write in prose — no code yet — what type each field of search_docs's future input_schema should have (query and k), and which of the two should be required. Justify your answer by thinking about how the tool would be used in a real conversation.

See solution

query should be "type": "string" and required: there's no way to search for anything without a question, so leaving it out has no reasonable default value. k should be "type": "integer" and optional: in most conversations the model doesn't need to explicitly decide how many results it wants — a reasonable default (say, 3) covers the common case, and leaving it optional keeps the model from having to invent a number every time it calls the tool. This is exactly the shape Lesson 03 declares: "required": ["query"], with k present in properties but absent from required.


Summary and next step

  • We picked back up Module 2's complete index (Chunk, tokenize, BM25Index, the canonical corpus of 57 chunks over the 13 documents) as the rag_index module, which the following lessons import instead of repeating.
  • We ran INDEX.search(query, k) directly and confirmed it works — the problem isn't the search, it's that none of that call is something a model could generate without seeing your code.
  • We identified three contract gaps (name, description, input_schema) and a fourth one specific to wrapping a retrieval: the shape of the result, because a raw Chunk isn't serializable with json.dumps as-is.

Next lesson: 03 — The search_docs contract. We declare the complete input_schemaquery required, k optional — with the same pattern from agent-fundamentals-and-tool-calling, and validate it with a reused validator.


Additional resources

  1. Anthropic — Tool use (function calling) overview — The official reference confirming the model only reads the declared contract, never the code behind it.
  2. agent-fundamentals-and-tool-calling-guide, Module 2, Lesson 02 (What a tool is) — the same leap from "a function that works" to "a function with a contract," now applied to a retrieval instead of a calculation.
  3. Python — dataclasses — the reference for @dataclass, used in Chunk, and why it isn't serializable with json.dumps without help.
  4. Python — json — the library we used, running it, to confirm a raw Chunk fails to serialize.