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
-
Thinking it's enough to give the function a name for it to be a tool.
search_docs = INDEX.searchdoesn't declare any contract — it's still a Python function invisible to the model. A tool exists when there's adictwithname/description/input_schemathe model can read. -
Assuming the model can infer
search()'s signature from the name alone. The model never seesdef search(self, query, k=5). Everything it knows about valid arguments comes from theinput_schema— if you don't declare it, it doesn't exist for the model, no matter how well-documented your code is internally. -
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 rawChunkdoesn't satisfy that yet (Lesson 04). -
Rewriting Module 2's index from memory, with small differences. Use this lesson's
rag_index.pyblock 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 therag_indexmodule, 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 rawChunkisn't serializable withjson.dumpsas-is.
Next lesson: 03 — The search_docs contract. We declare the complete input_schema — query required, k optional — with the same pattern from agent-fundamentals-and-tool-calling, and validate it with a reused validator.
Additional resources
- Anthropic — Tool use (function calling) overview — The official reference confirming the model only reads the declared contract, never the code behind it.
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.- Python —
dataclasses— the reference for@dataclass, used inChunk, and why it isn't serializable withjson.dumpswithout help. - Python —
json— the library we used, running it, to confirm a rawChunkfails to serialize.