Build a Search Tool
Why a Search Tool
A large part of what assistants are asked about lives in your own content: help-center articles, course descriptions, policies, documentation. Claude does not know this content, and it changes over time. Instead of putting everything into the system prompt, which is expensive and limited (Lesson 33), give Claude a search tool: it searches when it needs information, and reads only the relevant parts.
This lesson builds a two-tool search system over a knowledge base:
| Tool | Purpose |
|---|---|
search_help | Finds relevant articles for a query, and returns short previews |
read_article | Returns the full text of one article, by ID |
User question ──► search_help("refund") ──► [kb-refunds: preview] ──► read_article("kb-refunds") ──► answer
Why two tools instead of one? Search results are previews, so they are small. Claude reads full articles only when a preview looks relevant. That keeps each tool result small and puts Claude in control of what it reads, the same "search, then fetch" pattern used by web search tools.
How Simple Search Works
Production systems often use vector search with embeddings, which matches by meaning. To understand the mechanics, this lesson implements a simple keyword search:
- Tokenize: split text into lowercase words.
- Remove stopwords: drop common words like "the" and "how" that carry little meaning.
- Normalize: reduce simple plurals ("refunds" → "refund"), so query and article words match.
- Score: count matching words, with title matches weighted higher than body matches, because a word in the title signals what the article is about.
- Rank: return the best matches first, with stable tie-breaking.
The tools' interface stays the same if you later replace the scoring with vector search, so the rest of the application does not change.
Designing the Search Results
Search results should help Claude decide what to read next:
| Field | Purpose |
|---|---|
id | A stable identifier to pass to read_article |
title | What the article is about |
score | Relative relevance |
snippet | The start of the article, to judge relevance |
Limit the number of results (here, at most 5), and the length of snippets. Return a clear message when nothing matches, so Claude can rephrase the query or tell the user.
Practical Example: A Help-Center Search Tool
"""search_tool.py: keyword search over a knowledge base, as two tools."""
import json
import re
from anthropic import Anthropic, beta_tool
ARTICLES = [
{"id": "kb-refunds", "title": "Refund policy",
"body": "Refunds are available within 14 days of purchase if less than 30% of the course is "
"completed. To request a refund, email support with your order number."},
{"id": "kb-certificates", "title": "Certificates",
"body": "Certificates are issued within 24 hours after you pass the final quiz. "
"Download them from your profile page."},
{"id": "kb-live-sessions", "title": "Live sessions",
"body": "Live sessions run every Monday and Thursday at 7 PM IST. Recordings are posted within 2 hours."},
{"id": "kb-password", "title": "Reset your password",
"body": "Use 'Forgot password' on the login page. The reset link expires after 30 minutes."},
{"id": "kb-internship", "title": "Free internship program",
"body": "The internship lasts 8 weeks. Interns submit a project every 2 weeks and receive a "
"certificate on completion."},
]
ARTICLES_BY_ID = {article["id"]: article for article in ARTICLES}
STOPWORDS = {"a", "an", "the", "is", "are", "i", "me", "my", "do", "how", "what", "when", "can",
"to", "of", "in", "for", "and", "or", "after", "with", "on", "your", "you", "it"}
TITLE_WEIGHT = 3
MAX_RESULTS = 5
SNIPPET_CHARS = 120
def normalize(word: str) -> str:
return word[:-1] if len(word) > 3 and word.endswith("s") else word
def tokenize(text: str) -> list[str]:
return [normalize(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOPWORDS]
def score(query_terms: set[str], article: dict) -> int:
title_terms = tokenize(article["title"])
body_terms = tokenize(article["body"])
return sum(TITLE_WEIGHT * title_terms.count(t) + body_terms.count(t) for t in query_terms)
def search(query: str) -> list[dict]:
terms = set(tokenize(query))
scored = [(score(terms, a), a) for a in ARTICLES]
ranked = sorted((pair for pair in scored if pair[0] > 0), key=lambda pair: (-pair[0], pair[1]["id"]))
results = []
for points, article in ranked[:MAX_RESULTS]:
body = article["body"]
snippet = body if len(body) <= SNIPPET_CHARS else body[:SNIPPET_CHARS].rstrip() + "..."
results.append({"id": article["id"], "title": article["title"], "score": points, "snippet": snippet})
return results
@beta_tool
def search_help(query: str) -> str:
"""Search the GOCOURSE help center for articles about policies, certificates, sessions, and accounts.
Use it for any question about how GOCOURSE works, before answering from general knowledge.
Returns up to 5 matching articles as JSON with id, title, score, and a short snippet.
Use read_article with an id to read the full text before relying on details.
Args:
query: A few keywords describing the topic, e.g. 'refund' or 'certificate internship'
"""
results = search(query)
if not results:
return f"No help articles match {query!r}. Try different or fewer keywords."
return json.dumps(results, ensure_ascii=False)
@beta_tool
def read_article(article_id: str) -> str:
"""Read the full text of one help-center article.
Use it after search_help, with an id from the search results.
Returns the article's title and full text as JSON.
Args:
article_id: Article id from search_help, e.g. 'kb-refunds'
"""
article = ARTICLES_BY_ID.get(article_id.strip())
if article is None:
raise ValueError(f"No article with id {article_id!r}. Use search_help to find valid ids.")
return json.dumps({"title": article["title"], "text": article["body"]}, ensure_ascii=False)
if __name__ == "__main__":
for query in ["How do I get a refund?", "Do I get a certificate after the internship?"]:
hits = [(r["id"], r["title"], r["score"]) for r in search(query)]
print(f"{query!r} -> {hits}")
runner = Anthropic().beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=4096,
max_iterations=6,
system=("You are the GOCOURSE support assistant. Answer only from help-center articles found with "
"the tools. If no article covers the question, say so and suggest emailing support."),
tools=[search_help, read_article],
messages=[{"role": "user", "content": "I finished 40% of my course 10 days after buying it. Can I get a refund?"}],
)
final = runner.until_done()
print("\n" + "".join(b.text for b in final.content if b.type == "text"))
Output (the final answer's wording varies):
'How do I get a refund?' -> [('kb-refunds', 'Refund policy', 5)]
'Do I get a certificate after the internship?' -> [('kb-internship', 'Free internship program', 5), ('kb-certificates', 'Certificates', 4)]
Unfortunately, you don't qualify for a refund. Refunds are available within 14 days of purchase only if less than 30% of the course is completed, and you've completed 40%. If you think there are special circumstances, you can email support with your order number.
How It Works
tokenize()lowercases text, extracts words and numbers with a regular expression, drops stopwords, and normalizes simple plurals withnormalize(). The same steps apply to queries and articles, so "refunds" in an article matches "refund" in a query.score()counts each query term's occurrences, weighting title matches three times. For "How do I get a refund?", the remaining terms aregetandrefund. The refund article scores 3 for "refund" in its title, plus 2 for the two occurrences in its body ("Refunds" and "refund"), for a total of 5.search()keeps only articles with a positive score, sorts them by score (highest first) and then by ID (for stable ordering of ties), and returns at most five previews, with snippets shortened to 120 characters.- The second query shows ranking across several articles: the internship article scores 5 (title "internship" 3, plus "internship" and "certificate" in its body), and the certificates article scores 4 (title 3, plus "Certificates" in its body).
search_helptells Claude what the search covers, how to write a query (a few keywords), and to read full articles before relying on details. Empty results produce a helpful message instead of[].read_articlereturns one article by ID, and raises a helpful error for unknown IDs, pointing back tosearch_help.- The system prompt grounds the assistant: answer only from the articles, and say when no article covers the question (Lessons 33 and 40). In the run, Claude searched, read the refund article, and applied the policy to the student's situation.
Going Further
| Improvement | How |
|---|---|
| Search by meaning, not exact words | Embeddings and a vector index |
| Larger knowledge bases | A search engine or database full-text search |
| Citations in answers | The API supports returning search results as search_result content blocks in tool results, which enable citations. See the Search results documentation, and Lesson 157. |
| Searching the internet | The web search server tool (Lesson 155) |
Common Mistakes
Returning Full Documents from Search
What happens: every search fills the context with long texts, most of them irrelevant.
Fix: return previews from search, and full text only on request.
No Stable Identifiers
What happens: Claude cannot reliably ask for "that article".
Fix: include an id in every result, and accept it in read_article.
Answering from Snippets Alone
What happens: details that are cut off in the snippet are guessed.
Fix: tell Claude to read the full article before relying on details.
Empty Results Without Guidance
What happens: Claude gives up, or invents an answer.
Fix: return a message that suggests different keywords, and instruct Claude to admit when nothing covers the question.
Unstable Ranking
What happens: the same query returns results in different orders, which makes behavior hard to test.
Fix: break ties deterministically, for example by ID.
Best Practices
- Split search and reading into two tools: previews first, full text on demand.
- Return stable IDs, titles, and short snippets.
- Weight titles higher than body text in keyword search.
- Normalize words the same way for queries and documents.
- Limit results and snippet length.
- Ground answers in retrieved articles, and say when nothing matches.
- Keep the tool interface stable, so the search engine underneath can be upgraded.
Exercise
- Search for "live session recordings". Which article ranks first, and what is its score?
- Add an article about course discounts, and check that "student discount" finds it.
- Change
search()so that snippets start near the first matching word instead of at the beginning of the article. - Explain why "pass" becomes "pas" after normalization, and why that does not break the search.
Step 1: the terms are live, session, and recording (after normalization). kb-live-sessions scores 3 + 3 for "live" and "sessions" in its title, plus 1 for "Live", 1 for "sessions", and 1 for "Recordings" in its body: a total of 9. No other article contains these words, so it is the only result.
Step 4: the simple rule removes a final "s" from words longer than three characters, so "pass" becomes "pas". This is not a real word, but the same rule is applied to queries and articles alike, so a query containing "pass" still matches an article containing "pass": both become "pas". Simple normalization only needs to be consistent, not linguistically correct. For better matching of word forms, use a proper stemming library or embeddings.