●SONNET5 — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context and promo pricing through Aug 31●MCPTUNNEL — MCP tunnels arrives as a Research Preview, letting you reach MCP servers inside private networks●IDP — Admins can now provision MCP connectors via their IdP (starting with Okta); Asana, Figma, Linear and more support managed auth●SANDBOX — Claude Managed Agents can now run tool execution in your own self-hosted sandbox instead of Anthropic's infrastructure●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8●FIXES — Recent fixes cover public gateway endpoints, a confirm prompt for external git worktrees, and MCP request timeouts●SONNET5 — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context and promo pricing through Aug 31●MCPTUNNEL — MCP tunnels arrives as a Research Preview, letting you reach MCP servers inside private networks●IDP — Admins can now provision MCP connectors via their IdP (starting with Okta); Asana, Figma, Linear and more support managed auth●SANDBOX — Claude Managed Agents can now run tool execution in your own self-hosted sandbox instead of Anthropic's infrastructure●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8●FIXES — Recent fixes cover public gateway endpoints, a confirm prompt for external git worktrees, and MCP request timeouts
Precision Lives in the Second Stage: Reranking a Personal Knowledge Search with Claude
Embedding search alone leaves 'semantically close but not the answer' passages at the top. This is a two-stage design that gathers candidates broadly, then lets Claude reorder them by answerability, with structured scoring, abstention, and a cost estimate.
I once stopped mid-search through my own notes. Over years as an indie developer I had accumulated app FAQs and implementation memos, and when I ran an embedding search asking "what is the refund deadline," the top hit was a paragraph describing the philosophy behind the refund policy. The wording was a close match. But the number of days appeared nowhere in it.
Close, yet not answering. That is almost always the shape of an embedding search's misses. My first suspect was the embedding model's accuracy, but that is not what I fixed. I only added a second stage: take the candidates gathered roughly, and have Claude reorder them once more. This article records the design and implementation of that two-stage search, using a small personal knowledge base as the subject.
"Semantically Close" Is Not "Answers the Question"
Embedding search converts the question and documents into vectors in the same meaning space and returns the nearest ones. What it is good at is topical closeness. Ask about refunds, and it gathers every paragraph written about refunds.
The trouble is that this ranking is ordered by topical closeness, not by how well a passage answers the question. A paragraph on refund philosophy could not be closer to the topic of refunds. But for the question "how many days is the deadline," a plainer, operational sentence containing an actual number is more precise. Take the embedding-only top-3 as-is, and candidates that are close in meaning but not the answer push past the ones that actually contain it.
Swapping in a more expensive embedding model does not fundamentally remove this. You are trying to solve a mismatch of measures with more precision on the wrong measure. What you need is a second stage that re-scores the gathered candidates by answerability.
The Shape of a Two-Stage Search
What you do is simple. Gather broadly and shallowly in the first stage; choose narrowly and deeply in the second.
The first stage (recall) is embedding search, and it thinks only about missing nothing, so it grabs a wide top-20 rather than a top-3. The goal here is to make sure a passage containing the answer is somewhere in the candidate set; the correctness of the order does not matter yet. In the second stage (rerank), Claude scores those 20 by "how precisely does this answer the question" and keeps only the top few.
Stage
Handled by
Goal
Measure
First: recall
Embedding search
Miss nothing (broad top-20)
Topical closeness
Second: rerank
Claude
Narrow to a precise few (top-3)
Answerability
The first stage is fast and cheap; the second is smart. Splitting the roles lets Claude cover the embedding's weakness, while the embedding spares Claude from reading every document.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A complete two-stage retrieval implementation: embeddings gather a broad top-N, then Claude reorders them with a structured relevance score (all candidates batched into one request for a consistent rubric and bounded cost)
✦An abstain threshold so the search declines rather than answering when it is not confident, plus returning which passage was chosen and why
✦A cost estimate derived from candidate count and average tokens, with a rule for when Haiku is enough, when Sonnet is needed, and when reranking is unnecessary at all
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementation, Part 1: Gather Candidates Roughly with Embeddings
The first stage depends on your embedding model and store, but from the second stage's view it is merely "something that returns a list of candidates." So we fix only that interface first. Anthropic does not ship an embedding model, so this part is left to your own choice. For the concrete way to store vectors and search neighbors with pgvector, I will defer to "Running persistent memory in production with Claude and pgvector" and focus here on what happens after the candidates arrive.
from dataclasses import dataclass@dataclassclass Candidate: id: str text: str score: float # first-stage similarity (higher = closer)def recall_candidates(query: str, top_n: int = 20) -> list[Candidate]: """Gather a rough top_n via embedding search. Interface is agnostic to the embedding model or store (pgvector, etc.).""" vec = embed(query) # your embedding function rows = vector_store.search(vec, k=top_n) # cosine top_n return [Candidate(id=r.id, text=r.text, score=r.similarity) for r in rows]
top_n is 20 to leave room for the answer-bearing passage to always be "inside the candidate set." Narrow this to 3 and no matter how smart the second stage is, it cannot pick what was never a candidate. Misses can only be prevented in the first stage, so recall stays wide.
Implementation, Part 2: Reorder Candidates with Claude
The second stage has two crux points. One is to receive the scoring in a reliably structured form. The other is to pass all candidates in a single request. Score them one at a time and the rubric drifts between calls, the order becomes unstable, and cost climbs with every round trip; at 20 candidates that is 20x the round trips, which shows up directly in production latency and spend. Show all candidates at once and Claude can score them on a consistent scale while comparing them against each other.
import osfrom anthropic import Anthropicclient = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])RERANK_TOOL = { "name": "rank_candidates", "description": "Score how precisely each candidate answers the question, 0.0 to 1.0", "input_schema": { "type": "object", "properties": { "rankings": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "string"}, "relevance": {"type": "number"}, # 0.0-1.0 "reason": {"type": "string"}, # why this score }, "required": ["id", "relevance", "reason"], }, }, }, "required": ["rankings"], },}def rerank(query: str, cands: list[Candidate], model: str) -> list[dict]: # Batch candidates into one request; per-item calls drift the rubric and add round trips. passages = "\n\n".join(f"[{c.id}]\n{c.text}" for c in cands) msg = client.messages.create( model=model, max_tokens=1024, tools=[RERANK_TOOL], tool_choice={"type": "tool", "name": "rank_candidates"}, messages=[{ "role": "user", "content": ( f"Question: {query}\n\n" "Score the following candidates by how precisely they answer the question. " "Judge whether it directly answers the question, not whether the paraphrase is similar.\n\n" f"{passages}" ), }], ) block = next(b for b in msg.content if b.type == "tool_use") ranked = block.input["rankings"] ranked.sort(key=lambda r: r["relevance"], reverse=True) return ranked
The key is spelling out in the prompt: judge whether it directly answers, not whether the paraphrase is similar. Without this, Claude too is pulled toward the same topical closeness as the embedding, and adding a second stage loses its point. Fixing the scoring measure in words is what gives the second stage its value.
Placed beside a naive first-stage-only implementation, the difference collapses into a few lines.
# Before: return the embedding top-3 as-is (topically close, but sometimes not the answer)def search_naive(query: str) -> list[Candidate]: return recall_candidates(query, top_n=3)# After: gather broadly, then let Claude reorder by answerabilitydef search_two_stage(query: str, model: str) -> list[dict]: cands = recall_candidates(query, top_n=20) return rerank(query, cands, model=model)
When You Are Not Confident, Do Not Answer
The nice part of adding a second stage is not only better ordering. Because the score comes back as a number, you can notice when "there simply is no good candidate." If the top relevance does not reach a threshold, return "no answer" instead of forcing the top of the list. This abstention prevents confidently wrong answers on searches you are not sure about.
ABSTAIN = 0.55 # if the top score is below this, return "no answer"def search(query: str, top_k: int = 3, model: str = "claude-haiku-4-5-20251001") -> dict: cands = recall_candidates(query, top_n=20) if not cands: return {"sources": [], "reason": "no candidates found"} ranked = rerank(query, cands, model=model) if ranked[0]["relevance"] < ABSTAIN: # Close in meaning but not the answer. Do not force the top of the list. return {"sources": [], "reason": "no candidate met the confidence bar"} by_id = {c.id: c for c in cands} picked = [r for r in ranked[:top_k] if r["relevance"] >= ABSTAIN] return { "sources": [ {"id": r["id"], "text": by_id[r["id"]].text, "relevance": r["relevance"], "reason": r["reason"]} for r in picked ], }
What it returns is not the answer itself but the supporting passages and "why each was chosen." Even if you generate an answer downstream, choosing precise evidence first reduces hallucination later. For how retrieval misses quietly erode downstream quality, "Measuring silent recall decay in RAG" has a closer record.
The threshold 0.55 is a starting point; prepare a few dozen questions with known answers and nudge it toward a value that abstains neither too often nor too rarely.
Cost and Precision Trade-off
The second stage adds one Claude call per query. Since candidates are batched, the number of calls does not grow, but input tokens scale with the total size of the candidates. Holding a rough estimate makes the adoption decision concrete.
If 20 candidates average 300 tokens, that is about 6,000 tokens of body alone. Add the question and tool scaffolding and a query lands around 6,000 to 7,000 input tokens, with scoring output around 800 tokens for 20 items at ~40 tokens each. These are design estimates, not measured figures, but they are enough to grasp the order of magnitude. Prices change, so check Anthropic's current rates on the pricing page.
Situation
Model for scoring
Rationale
Mostly FAQs and short passages
Haiku 4.5
Judging answerability is light. Run it fast and cheap
Long, technical, confusable candidates
Sonnet 5
Raise the model only where telling subtle differences apart needs it
Search returns only a few candidates
No rerank
Nothing to reorder. The embedding order is enough
The same question recurs
Cache the result
Store the rerank result by question key and skip the call
For myself, I settled on defaulting to Haiku 4.5 and raising only the hard categories where abstention keeps happening to Sonnet 5. The second stage is easier to sustain on a personal budget as "smart only when needed" than "always smart."
Summary
What embedding search returns is topical closeness, and what we want is answerability. The two are close, yet genuinely different. Simply splitting the roles into a first stage that gathers roughly and a second stage that reselects by answerability changes how search feels, plainly.
As a next step, widen your current search's top_n to 20 and slot this rerank function in behind it. With an abstain threshold in place, the count of confidently wrong answers on unsure searches should be the first thing to fall. I am still mid-tuning myself, but the situations where this small move of adding a second stage pays off are more common than I expected. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.