Module 4: Agentic Retrieval in the Loop
Reformulating and Retrying the Query
Description
Lesson 02 closed Question 2 with an observation left hanging: search_docs("What is Reservo's cancellation policy?", k=3) gets the expected doc_id (cancellation-policy) into first place, but the winning chunk — cancellation-policy-003, the "Related Policies" section — is only a cross-reference sentence ("see refund-policy... see no-show-policy..."), not the real text of the cancellation window. A monolithic pipeline would settle for that result because the doc_id "was right." An agent that actually reads what it retrieved doesn't settle: it recognizes the chunk doesn't answer the question, reformulates the query, and tries again.
This lesson builds that second half of the cycle. You're going to write a checker — real, executed — that detects when a chunk is predominantly a reference to other documents rather than content of its own, and you're going to see, with two real search_docs calls, how a second query with different words finds the chunk the first one missed.
Connection to the module
This lesson builds directly on Lesson 02's Question 2: same case, same first result, but now with the missing piece — what to do when that result isn't enough. It's the foundation for Lesson 04 (multi-hop), which generalizes this pattern to questions that need several independent searches, not just a second attempt at the same question.
Analogy: the folder that only says "see also"
The module introduction's researcher asks the archive for the "cancellation policy" folder. The archivist brings a folder — but opening it, all that's inside is a note: "for refunds, see the refunds folder; for no-shows, see the no-shows folder." The folder technically matched what was asked for, but it doesn't contain the answer — it's a pointer index, not the content. A researcher who settles for that note and reports it as if it were the complete policy is failing, even though they "found something with the right name." A real researcher notices the difference, and goes back to the desk with a more specific question: not "what's the cancellation policy?" but "how many hours in advance can I cancel free of charge in pro mode?" — a question that can no longer be answered with a simple pointer note.
An executable checker: is this chunk just a cross-reference?
Reservo's canonical corpus deliberately has chunks that mention other doc_id values inside backticks — you saw this since Module 2 — to simulate how a real company's documents reference each other. That's genuinely useful for a human reader browsing the whole archive, but it's a warning sign when that specific chunk is the only result the agent is going to read. Let's build a simple detector, based on that same feature of the text:
import re
REF_RE = re.compile(r"`[a-z]+(?:-[a-z]+)+`")
def looks_like_cross_reference(text, min_refs=2):
"""True if the text mentions 2 or more doc_id-style identifiers inside
backticks (`refund-policy`, `no-show-policy`, ...) -- a signal that
the chunk is a pointer note, not real content."""
return len(REF_RE.findall(text)) >= min_refs
REF_RE looks for the exact pattern the corpus uses to write a reference to another document: one or more lowercase words separated by hyphens, inside backticks — the same shape as `refund-policy` or `no-show-policy`. min_refs=2 is a deliberate choice: a chunk that mentions a single doc_id in passing (common, and not necessarily a problem) doesn't get flagged; two or more mentions in the same short chunk is the real signal that the chunk exists to point elsewhere, not to answer on its own.
Worked example: the checker against the real first result
Picking back up exactly Lesson 02's search:
from search_docs_tool import search_docs
results = search_docs("What is Reservo's cancellation policy?", k=3)
for r in results:
flag = looks_like_cross_reference(r["text"])
print(f" {r['chunk_id']:28s} score={r['score']:<7} cross_reference={flag}")
print(f" text: {r['text']!r}")
What to expect:
cancellation-policy-003 score=10.734 cross_reference=True
text: '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.'
refund-policy-000 score=7.217 cross_reference=False
text: "A refund applies when a booking is cancelled within the cancellation window for the member's tier, or when Reservo cancels a booking due to a facility issue on Reservo's side."
no-show-policy-000 score=6.364 cross_reference=False
text: '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`.'
There's the signal, executed: the chunk that won first place (cancellation-policy-003) contains exactly two backtick-wrapped references — `refund-policy` and `no-show-policy` — and looks_like_cross_reference flags it True. The third result (no-show-policy-000) mentions `cancellation-policy` just once — below the threshold, correctly not flagged, because a single passing mention doesn't turn an entire chunk into a simple pointer note.
Concept: the agent decides to reformulate
With the signal executed and in hand, the next step — deciding to reformulate, and with what words — is conceptual: the reformulation itself is a model decision, not something a checker can generate automatically.
[model·concept]
search_docs's first result has the correct doc_id
(cancellation-policy), but looks_like_cross_reference flags it as a
pointer note, not the policy's real content. The original question
("What is Reservo's cancellation policy?") is too general -- it
matches the sentence that only NAMES the related policies. I'll
reformulate, targeting the specific data the original question
needed: how many hours in advance you can cancel free of charge in
pro mode.
decision -> use_tool "search_docs" with {query: "How many hours in
advance is a pro tier cancellation free of charge?", k: 3}
The reformulation doesn't change topic — it's still about the pro cancellation window — but it changes the level of specificity: from a general question a cross-reference can "answer" superficially, to a concrete question only the chunk with the real number can satisfy.
Worked example: the second search, executed
retry_results = search_docs("How many hours in advance is a pro tier cancellation free of charge?", k=3)
for r in retry_results:
flag = looks_like_cross_reference(r["text"])
print(f" {r['chunk_id']:28s} score={r['score']:<7} cross_reference={flag}")
What to expect:
cancellation-policy-001 score=15.138 cross_reference=False
no-show-policy-000 score=8.757 cross_reference=False
cancellation-policy-000 score=7.715 cross_reference=False
The reformulation worked, in both ways that matter. First, the winning chunk changed: cancellation-policy-001 — "Pro Tier Cancellation Window," the real text: "Pro members get a shorter, friendlier window: cancellations up to 4 hours before the reserved start time are free of charge..." — replaced the first attempt's pointer note. Second, looks_like_cross_reference confirms False on all three results: none is a simple cross-reference this time. And the margin is much wider than the first search: 15.138 against 8.757 (almost double), versus 10.734 against 7.217 in the first attempt — an additional signal, though not conclusive on its own, that this second query found something more specific.
The complete pattern, visualized
query 1: "What is Reservo's cancellation policy?"
│
▼
search_docs -> top1: cancellation-policy-003 (only points to other policies)
│
▼
looks_like_cross_reference(top1.text) -> True (REAL signal, executed)
│
▼
[model·concept] "this doesn't answer the question, reformulating"
│
▼
query 2: "How many hours in advance is a pro tier cancellation free of charge?"
│
▼
search_docs -> top1: cancellation-policy-001 (the real text, "4 hours")
│
▼
looks_like_cross_reference(top1.text) -> False (real content, not a note)
Two calls to search_docs, both real and both within the same logical conversation turn — from the point of view of the agent's history (the messages you already know from agent-fundamentals-and-tool-calling Module 4), they're two trips around the loop: one tool decision, one result, a second tool decision with a different query, a second result, and only then the final answer.
Why this isn't "search until it works"
It's worth being precise about what this pattern is and isn't. It isn't a while that retries the same query until the score clears some magic number — that would be brute force, not reformulation. It also isn't retrying with a random query until something hits. It's an informed decision: the agent reads the result's text (not just the score), identifies why it doesn't work — in this case, with a concrete, executable signal: it's a pointer note, not content — and builds a second query that specifically targets the missing data. looks_like_cross_reference doesn't decide the reformulation for the agent; it gives the agent (concept) a concrete, verifiable reason not to settle for the first result.
Common mistakes
-
Retrying without changing the query. Running
search_docstwice with exactly the same question text produces, with a deterministic index like this guide's, exactly the same result — zero gain. The reformulation has to change the query's vocabulary, not just repeat the call. -
Confusing "the doc_id was right" with "the chunk answers the question." As you saw in Module 3, a correct
doc_idin the top-1 doesn't guarantee the specific chunk is useful — it can be, as in this case, a cross-reference section from the same correct document. Checking thedoc_idalone isn't enough; you have to look at thetext. -
Reformulating without limit, with no attempt cap. Nothing in this lesson stops a poorly designed agent from reformulating indefinitely if no query finds anything good. The loop's iteration cap (
max_iterations, already established inagent-fundamentals-and-tool-callingModule 4) still applies here unchanged — reformulating consumes one trip around the loop, like any other tool call. -
Thinking
looks_like_cross_referenceis a general test for "bad chunk." It's a checker specific to a concrete pattern in this corpus (mentions of otherdoc_idvalues inside backticks). A chunk can be useless for a question for many other reasons — wrong topic, too generic, truncated mid-idea — that this specific checker doesn't detect. It's a useful signal, not a complete quality guarantee.
Exercises
Exercise 1: Apply the checker to a new result (Easy)
Without running anything: given the text "No-shows are never refunded, regardless of membership tier. See \no-show-policy` for the full no-show rules and the fee that applies instead."(chunkrefund-policy-002), how many backtick-wrapped references does it contain? Would looks_like_cross_referenceflag itTrueorFalse`? Confirm by running it.
See solution
It contains only one reference: `no-show-policy`. With min_refs=2, looks_like_cross_reference should return False — the threshold requires two or more.
text = "No-shows are never refunded, regardless of membership tier. See `no-show-policy` for the full no-show rules and the fee that applies instead."
print(looks_like_cross_reference(text))
Expected output:
False
Explanation: this chunk does have real content of its own (it states that no-shows are never refunded, a real policy fact) and also mentions another policy in passing — exactly the case the threshold of 2 is designed not to flag as a false positive.
Exercise 2: Reformulate a different query and confirm the improvement (Medium)
Starting from search_docs("What fee applies if a member does not show up for a booking?", k=3), confirm the top-1 is a cross-reference. Reformulate with search_docs("Does a no-show get charged the full reserved price?", k=3) and confirm the second attempt finds no-show-policy-001 without being a cross-reference.
See solution
first = search_docs("What fee applies if a member does not show up for a booking?", k=3)
for r in first:
print(r["chunk_id"], r["score"], looks_like_cross_reference(r["text"]))
print()
retry = search_docs("Does a no-show get charged the full reserved price?", k=3)
for r in retry:
print(r["chunk_id"], r["score"], looks_like_cross_reference(r["text"]))
Expected output:
cancellation-policy-003 17.143 True
no-show-policy-003 11.418 False
refund-policy-002 9.844 False
no-show-policy-001 13.335 False
refund-policy-002 7.809 False
no-show-policy-000 6.636 False
Explanation: the first attempt again puts cancellation-policy-003 in first place, flagged True by the checker — the same usual pointer note, winning because it shares generic words like "if," "member," "booking" with the query. The reformulation, with more specific vocabulary ("charged the entire reserved price"), brings no-show-policy-001 — the chunk with the real fact: "No-shows are charged the full amount of the booking... the no-show fee equals the entire reserved price" — into first place, with no cross-reference in the top-3.
Exercise 3: Design a reformulation cap (Hard)
Write a function search_with_retry(query, retry_query, k=3, max_attempts=2) that calls search_docs with query; if the top-1 passes looks_like_cross_reference, retries once with retry_query; returns the result of whichever attempt worked (or the second one, if neither did). Run it with the worked example's pair of queries and confirm it returns the second search's results.
See solution
def search_with_retry(query, retry_query, k=3, max_attempts=2):
"""Search with `query`; if the top-1 is a cross-reference, retry
ONCE with `retry_query`. max_attempts bounds the number of calls to
search_docs, the same way max_iterations bounds the agent's complete loop."""
attempts = 0
results = search_docs(query, k=k)
attempts += 1
if results and looks_like_cross_reference(results[0]["text"]) and attempts < max_attempts:
results = search_docs(retry_query, k=k)
attempts += 1
return results, attempts
final_results, attempts = search_with_retry(
"What is Reservo's cancellation policy?",
"How many hours in advance is a pro tier cancellation free of charge?",
)
print(f"attempts used: {attempts}")
for r in final_results:
print(f" {r['chunk_id']:28s} score={r['score']}")
Expected output:
attempts used: 2
cancellation-policy-001 score=15.138
no-show-policy-000 score=8.757
cancellation-policy-000 score=7.715
Explanation: search_with_retry runs exactly this lesson's same logic, but wrapped in a reusable function: it tries query first, and only if the top-1 fails the checker and there's still room under max_attempts does it retry with retry_query. attempts=2 confirms both calls got used — the first identified the problem, the second solved it. max_attempts plays the same role here as max_iterations does in the agent's complete loop: an explicit cap that keeps the reformulation from repeating without limit if no query found anything useful.
Summary and next step
- The correct
doc_idin the top-1 doesn't guarantee the chunk answers the question — Modules 2/3 already hinted at this; this lesson made it explicit with a real checker:looks_like_cross_reference, which detects chunks that only name other policies inside backticks. - We ran the complete pattern:
search_docswith the original query returns a cross-reference (True); the model (concept) reformulates, targeting the specific missing data; the secondsearch_docs, executed, brings back the real chunk (False), with a much wider margin than the first attempt. - Reformulating isn't brute force: it's an informed decision, based on reading the result's text, not just its score or
doc_id. - The loop's iteration cap (
agent-fundamentals-and-tool-callingModule 4) still applies: every reformulation consumes one trip around the loop, and the boundedwhile/fordoesn't change shape just because one of the tools is a retrieval one.
Next lesson: 04 — Multi-hop retrieval. What happens when the question doesn't need to reformulate the same search, but instead needs two independent searches on two different topics?
Additional resources
- Anthropic — Tool use (function calling) overview — The request-execute-result cycle this lesson repeats twice within the same logical conversation turn.
agent-fundamentals-and-tool-calling-guide, Module 4, Lesson 04 (The iteration cap) — the mechanism that keeps a reformulation from repeating without limit.- Python — Regular expressions (
re) — the reference forre.compile/findall, used byREF_REto detect the backtick-wrapped identifiers. embeddings-deep-dive-guide— why a real semantic search could, in principle, tell "cancellation window" apart from "see also" without needing an explicit second query; this guide uses lexical BM25, which does need this reformulation pattern.