Query Understanding & Intent

How AI search and RAG systems read intent, rewrite queries, and decide when to ask a clarifying question about an unclear or incomplete query.

Last generated

Lesson 30 of 38 available15 practice questions

SPACED REPETITION Β· 15 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Watch the Lesson Video26 min
The whole lesson, narrated: the follow-up that found a watch, then six moves (route, standalone follow-ups, rewrites for your retriever, safe filters, ask-assume-cover, cost), and how to measure it

The follow-up that found a watch

A customer on a headphone shop's help chat asks two questions in a row:

Turn 1: What's the warranty on the Aero X2 headphones? Turn 2: Does it cover water damage?

Turn 1 goes fine. Turn 2 is embedded exactly as typed and sent to dense retrieval over the shop's 15 help-centre chunks. This is what came back (a real run with all-MiniLM-L6-v2; the code is below):

Rank Chunk retrieved for "Does it cover water damage?" Cosine
1 Trail Watch S … water damage from swimming or showering is covered by the Trail Watch warranty 0.495
2 CarePlus accidental damage protection covers drops and liquid spills … 0.452
3 Aero X2 Pro … 24-month warranty … sweat and rain are covered 0.276
4 Aero X2 … liquid exposure, drops and cracked headbands are not covered 0.246

The generator reads the top chunks and answers, fluently: "Yes, water damage is covered." For the Aero X2 it is not. The chunk that says so came fourth.

Nothing in the retrieval stack is broken. The query is about water damage and coverage, and the watch chunk is the one about water damage and coverage. The failure happened before retrieval. "It" meant "the Aero X2 headphones' warranty", and that meaning lived in turn 1, which the retriever never saw. Rewrite turn 2 as a standalone query and the same model, same index, gets it right:

Rank Chunk retrieved for "Does the Aero X2 headphones warranty cover water damage?" Cosine
1 Aero X2 … liquid exposure … not covered 0.763
2 Aero X2 Pro … 24-month warranty … 0.729
3 Trail Watch S … water damage … covered 0.474

This lesson is about that step: query understanding, the work between "the user typed something" and "the retriever runs". It decides where a message should go, what text the retriever should actually search for, which constraints are hard filters, and whether to answer, state an assumption, or ask. Each move below starts with a symptom you can see in results or logs.

# Symptom Move
1 "Where's my parcel?" gets a help article; a part number gets a lookalike part Route before you retrieve
2 Follow-ups ("does it…", "what about the Pro?") retrieve another product Make every follow-up stand alone
3 A typo zeroes out keyword matches; a vague question finds half the relevant chunks Rewrite for the retriever you have
4 "Under 200 dollars" is ignored; top hits break the user's constraints Pull filters out of the query, safely
5 A confident answer about the wrong product or plan Ask, assume, or cover
6 p95 latency jumps by a second after adding an LLM rewriter Pay for understanding only when it pays

You should know how lexical and dense retrieval work at the level of Foundations of Modern AI Search and Semantic Search Principles. Splitting a complex question into several sub-questions has its own lesson, Query Decomposition.

Setup: a tiny help centre

Every code block in this lesson runs after this one, in order, like cells in a notebook (Python 3.12 with sentence-transformers and numpy; later blocks add rank_bm25 and qdrant-client).

import numpy as np
from sentence_transformers import SentenceTransformer

CHUNKS = {
    "aero-warranty":     "Aero X2 headphones come with a 12-month limited warranty that covers manufacturing defects. Liquid exposure, drops and cracked headbands are not covered.",
    "aero-pro-warranty": "Aero X2 Pro headphones come with a 24-month warranty. The Pro is rated IPX4, so sweat and rain are covered; submersion is not.",
    "trail-water":       "Trail Watch S is water resistant to 50 m. Water damage from swimming or showering is covered by the Trail Watch warranty.",
    "buds-water":        "Pulse Buds are rated IPX4 against sweat and splashes. Do not submerge them or wear them in the shower.",
    "careplus":          "CarePlus accidental damage protection covers drops and liquid spills for two years. Add it within 60 days of purchase.",
    "returns-unused":    "Unused products can be returned within 30 days of delivery for a full refund.",
    "returns-opened":    "Opened headphones and earbuds can be returned within 14 days if they are undamaged. Hygiene seals must be intact.",
    "aero-battery":      "Aero X2 battery life is up to 30 hours with noise cancelling on and 40 hours with it off. A 10-minute charge gives 5 hours.",
    "buds-battery":      "Pulse Buds play for 8 hours per charge, and the case adds another 24 hours.",
    "aero-pairing":      "To pair Aero X2 with a phone, hold the power button for 5 seconds until the LED blinks blue, then pick it in Bluetooth settings.",
    "aero-firmware":     "Firmware 3.2 for Aero X2 fixes Bluetooth dropouts on some Android phones. Update it from the Northwind app.",
    "cushions":          "Replacement ear cushions for Aero X2, part number XK-4471, are sold in pairs. Aero X2 Pro uses part XK-4417.",
    "shipping":          "Orders ship within 2 business days. Express delivery arrives the next business day in most regions.",
    "tracking":          "Track an order from Account, then Orders. The tracking link appears once the parcel leaves the warehouse.",
    "bt-range":          "Bluetooth headphones work best within about 10 m of the source. Walls, microwaves and crowded Wi-Fi reduce range and cause dropouts.",
}
IDS = list(CHUNKS)
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")   # unit-length 384-d vectors
DOC_VECS = model.encode(list(CHUNKS.values()))

def ranking(query: str) -> list[str]:
    """All chunk ids, best first (cosine = dot product for unit vectors)."""
    scores = DOC_VECS @ model.encode(query)
    return [IDS[i] for i in np.argsort(-scores)]

def search(query: str, k: int = 3) -> list[tuple[str, float]]:
    scores = DOC_VECS @ model.encode(query)
    return [(IDS[i], round(float(scores[i]), 3)) for i in np.argsort(-scores)[:k]]

print(search("Does it cover water damage?", k=4))
# [('trail-water', 0.495), ('careplus', 0.452), ('aero-pro-warranty', 0.276), ('aero-warranty', 0.246)]
print(search("Does the Aero X2 headphones warranty cover water damage?", k=4))
# [('aero-warranty', 0.763), ('aero-pro-warranty', 0.729), ('trail-water', 0.474), ('returns-opened', 0.472)]

all-MiniLM-L6-v2 returns unit-length vectors, so the dot product is the cosine similarity. Its scores are only comparable with each other: 0.495 is not "49.5% relevant", and another model would put the same pair somewhere else.

Before any move: decide what understanding must produce

The output of query understanding is not "a better query". It is a small query plan that the retriever and the generator can act on:

route              kb_search | account | exact_lookup | out_of_scope
retrieval queries  ["Does the Aero X2 headphones warranty cover water damage?"]
filters            {"category": "headphones", "price_below": 200}    (validated in code)
ask / assume       null | "Aero X2 or Aero X2 Pro?" | "Assuming you mean the Aero X2: ..."

Four rules keep that plan honest:

  • Pass-through is a valid plan. A message that already stands alone and is clear needs no change, and leaving it alone is the cheapest, safest thing understanding can do. Make pass-through the default and change a message only when one of the moves below says why. How often that happens depends on your traffic (in a chat, follow-ups are common, as Move 2 shows), so measure it in your own logs.
  • Nothing lost, nothing invented. Every product name, identifier, number and constraint the user gave must survive, and nothing the user did not say may appear. A rewrite that breaks this is worse than no rewrite.
  • Replace only what cannot stand alone. A follow-up with an unresolved "it" is replaced by its standalone version. Enrichments (spelling fixes, expansions, paraphrases, hypothetical answers) are searched next to the original, never instead of it.
  • Hard constraints are data, not prose. "Under 200 dollars" becomes a validated filter. Access rules (tenant, market, permissions) come from the logged-in session, never from a model's output.

πŸ’‘ Habit worth building: log the plan next to the raw message for every request. When an answer is wrong, the first question is "what did the retriever actually search for?"

Move 1: Route before you retrieve

Smell: one pipeline serves every message. On the help centre above, "Where's my parcel?" retrieves the how-to-track article (0.481) instead of the user's actual parcel status. "Tell me a joke" retrieves the CarePlus chunk at a cosine of 0.07, and the generator improvises. A part number is embedded, and its nearest neighbour can be a different part: XK-4471 and XK-4417 have a cosine of 0.791 under this model.

Intent classification assigns each message to a route: a component that can actually handle it. Search research has a classic taxonomy: Broder's navigational, informational and transactional queries (2002). In a log of about 1.5 million web queries, Jansen, Booth & Spink (2008) classified more than 80% as informational and about 10% each as navigational and transactional; their automatic classifier agreed with hand coding on 74% of a 400-query sample, and they described most of the rest as vague or multi-faceted. That lens is useful, but your labels should be your routes, the things your system does differently:

Route Handled by Why it must not go to plain search
kb_search retrieval over help articles (it is search)
account an authenticated orders API the answer is in the user's data, not the help centre
exact_lookup lexical or exact match on identifiers the vector nearest to XK-4471 may be XK-4417
out_of_scope a short refusal or small talk retrieval always returns something

Rules, then embeddings, then an LLM

The cheapest router is a cascade: a rule for what a regex gets right, an embedding classifier for the common cases, and an LLM only when the classifier is unsure.

import re

EXAMPLES = {   # a handful of labeled examples per route
    "kb_search": ["How do I pair my headphones with a laptop?", "What is the return window for opened earbuds?",
                  "Is the Trail Watch waterproof?", "How long does the battery last on the Aero X2?",
                  "Does the warranty cover a cracked headband?", "Why does the sound cut out when I walk away?"],
    "account": ["Where is my order?", "Cancel my order please", "I want a refund for the headphones I bought",
                "Change the delivery address on my order", "Has my parcel shipped yet?", "Update the card on my account"],
    "out_of_scope": ["Tell me a joke", "Who won the football match yesterday?", "Write a poem about cats",
                     "What's the weather in Oslo?", "Can you help with my maths homework?", "hi there"],
}
ROUTES = list(EXAMPLES)
CENTROIDS = np.stack([model.encode(EXAMPLES[r]).mean(axis=0) for r in ROUTES])
CENTROIDS /= np.linalg.norm(CENTROIDS, axis=1, keepdims=True)
PART_NUMBER = re.compile(r"\b[A-Z]{2}-\d{4}\b", re.IGNORECASE)   # users type xk-4471 too

def route(query: str, min_margin: float = 0.05) -> tuple[str, float | None]:
    if PART_NUMBER.search(query):                 # 1. rules for the obvious
        return "exact_lookup", None
    sims = CENTROIDS @ model.encode(query)        # 2. embeddings for the frequent
    best, second = np.argsort(-sims)[:2]
    margin = float(sims[best] - sims[second])
    if margin < min_margin:                       # 3. an LLM for the unsure
        return "llm_fallback", round(margin, 3)
    return ROUTES[best], round(margin, 3)

HELD_OUT = [("my package still hasn't arrived", "account"), ("return policy for opened headphones", "kb_search"),
            ("does the aero x2 fold flat", "kb_search"), ("I'd like my money back for order 55120", "account"),
            ("what's the capital of Peru", "out_of_scope"), ("how do I update the firmware", "kb_search"),
            ("cancel the second item in my last order", "account"), ("are pulse buds ok for swimming", "kb_search"),
            ("thanks, that's all", "out_of_scope"), ("can I still add CarePlus to my purchase", "kb_search"),
            ("I bought X2 last week and it stopped charging, can I return it", "kb_search"),
            ("send the invoice for my order again", "account")]
right = wrong = unsure = 0
for query, gold in HELD_OUT:
    predicted, margin = route(query)
    if predicted == "llm_fallback":
        unsure += 1
    elif predicted == gold:
        right += 1
    else:
        wrong += 1
        print(f"WRONG  {predicted:9} margin={margin}  {query!r} (gold: {gold})")
print(f"right={right} wrong={wrong} sent to LLM={unsure}")
print(route("Is XK-4471 in stock?"), route("is xk-4471 in stock"))
# WRONG  account   margin=0.201  'how do I update the firmware' (gold: kb_search)
# WRONG  account   margin=0.254  'can I still add CarePlus to my purchase' (gold: kb_search)
# WRONG  account   margin=0.054  'I bought X2 last week and it stopped charging, can I return it' (gold: kb_search)
# right=7 wrong=3 sent to LLM=2
# ('exact_lookup', None) ('exact_lookup', None)

The rule ignores case on purpose. Without re.IGNORECASE, "is xk-4471 in stock" skips the rule, and the embedding stage sends it to account with a margin of 0.197, well past the gate, and no LLM ever sees it. Test rules on messages as people really type them, not only on the tidy form in your catalog.

The embedding stage averages the vectors of six labeled examples per route into a centroid and picks the nearest one. The margin is the gap between the best and second-best similarity. With the gate switched off (min_margin=0.0), the router gets 8 of 12 held-out messages right. With a gate of 0.05, two messages go to the LLM instead: "what's the capital of Peru" (margin 0.044, which the router had right) and "thanks, that's all" (0.014, which it had wrong). Three errors remain.

Predict first: why do all three errors land on account, and why would raising the gate to 0.1 not fix the first two?

Check your answer

The account examples are full of "my", "order", "update", "refund" and "bought", and these messages use the same words ("update the firmware" is closest to the example "Update the card on my account", at 0.432). The centroid learned the vocabulary of its examples, not the concept "needs the user's own data". The first two errors have large margins, 0.201 and 0.254: the router is confidently wrong, so no threshold on the margin catches them. The third message genuinely has two intents, a policy question and the start of a return. The fix is data, not a threshold: add counter-examples ("How do I update the firmware on my headphones?" labeled kb_search), let a message carry two labels, and re-measure on messages the router has never seen.

🧠 Confidence is not correctness. A margin, a softmax probability or an LLM's self-reported confidence says how far the winner is from the runner-up, not whether the winner is right. Only labeled data tells you that.

Why the cascade works, and what each stage costs

A rule is exact and costs nothing, but covers only patterns you can write down. The embedding stage costs one small-model embedding (milliseconds on a CPU) and generalises to paraphrases of its examples. An LLM reads the whole message and copes with new phrasing and several intents at once, but costs a network round trip (hundreds of milliseconds or more) and tokens. The cascade spends the expensive stage only where the cheap ones are unsure. A small classifier fine-tuned on a few thousand labeled messages sits between the last two: usually more accurate than centroids and still milliseconds per message.

Failure modes

  • Asymmetric errors. Routing "how do I update the firmware" to account dead-ends in an orders API. Routing an account question to search returns a generic article. Decide which mistakes are cheap, bias toward them, and make every route fail soft: the account route falls back to search when the message matches no order.
  • Several intents. "Compare the X2 and the Pro, and cancel my order" needs two routes. Use a multi-label classifier (one yes/no score per route) or let the LLM return a list.
  • Out of scope is open-ended. Six examples cannot cover everything your system does not handle. A low maximum similarity to all 18 examples is a useful extra signal, but only with a threshold tuned on labeled out-of-scope traffic. Here it flags "What's the capital of Peru" (at most 0.088 against every example), but not "thanks, that's all": that one reaches 0.274 against "hi there", higher than two real help questions ("can I still add CarePlus to my purchase" at 0.241, "does the aero x2 fold flat" at 0.254). No threshold on this data catches it without also rejecting those two.
  • Drift. New products and campaigns change what people ask. Re-label a sample of recent traffic every few weeks.

The route is chosen per message, not per session. A conversation that opens with "where's my order?" can continue with "do the Pulse Buds work with an iPhone?".

In agentic systems the model itself decides whether and where to search, through tool calls. That moves routing into the prompt and the tool descriptions, but the evaluation is the same: labeled messages and per-route precision and recall (Agentic RAG Systems).

Measure it: label 100–300 real messages (not invented ones), keep them out of the examples, and report a confusion matrix and the fallback rate. Recall on the routes that are expensive to miss matters more than overall accuracy.

Your turn: the shop launches in 12 languages next quarter. What happens to this router, and what do you change?

Check your answer

all-MiniLM-L6-v2 is an English model, so messages in other languages land near arbitrary centroids and the confident-error problem gets worse. Two options: a multilingual embedding model with labeled examples in every language (cheap per message, needs examples per language), or an LLM router for non-English traffic (handles languages well, costs latency and tokens). Either way the held-out set must cover every language, and you report per-language confusion, because a good overall accuracy can hide one language that routes badly.

Move 2: Make every follow-up stand alone

Smell: follow-ups ("does it…", "what about unopened ones?", "and the Pro?") retrieve other products, and the retrieval log shows queries that nobody could answer without the conversation.

Conversational questions lean on history all the time. For CoQA, a conversational question-answering dataset, Reddy, Chen & Manning (2019) annotated a sample of 150 questions: 49.7% contained an explicit coreference ("he", "it") and another 19.8% referred back implicitly, often by ellipsis. Your support chat is not CoQA, but plan for follow-ups being common rather than rare.

Here are three ways to give the retriever the missing context, measured on eight follow-ups against the help centre:

# (previous turn, follow-up, standalone rewrite, relevant chunk)
FOLLOW_UPS = [
    ("What's the warranty on the Aero X2 headphones?", "Does it cover water damage?",
     "Does the Aero X2 headphones warranty cover water damage?", "aero-warranty"),
    ("Tell me about the Pulse Buds.", "How long does the battery last?",
     "How long does the Pulse Buds battery last?", "buds-battery"),
    ("What's the warranty on the Aero X2 headphones?", "And how long does its battery last?",
     "How long does the Aero X2 battery last?", "aero-battery"),
    ("Can I return opened earbuds?", "What about unopened ones?",
     "Can I return unopened earbuds?", "returns-unused"),
    ("My Aero X2 keeps disconnecting from my Android phone.", "Is there a fix?",
     "Is there a fix for Aero X2 disconnecting from an Android phone?", "aero-firmware"),
    ("I need new ear cushions for my Aero X2 Pro.", "What's the part number?",
     "What is the part number of the ear cushions for Aero X2 Pro?", "cushions"),
    ("Is the Trail Watch S waterproof?", "Can I shower with it?",
     "Can I shower with the Trail Watch S?", "trail-water"),
    ("How do I pair the Aero X2?", "The light isn't blinking blue, what now?",
     "Aero X2 pairing: the LED is not blinking blue, what should I do?", "aero-pairing"),
]
STRATEGIES = {
    "raw follow-up":  lambda prev, q, rw: q,
    "prev + follow-up": lambda prev, q, rw: prev + " " + q,
    "standalone rewrite": lambda prev, q, rw: rw,
}
for name, build in STRATEGIES.items():
    ranks = [ranking(build(prev, q, rw)).index(gold) + 1 for prev, q, rw, gold in FOLLOW_UPS]
    hit1 = sum(r == 1 for r in ranks)
    hit3 = sum(r <= 3 for r in ranks)
    print(f"{name:19} ranks={ranks}  recall@1={hit1}/8  recall@3={hit3}/8")
# raw follow-up       ranks=[4, 2, 1, 1, 1, 1, 2, 1]  recall@1=5/8  recall@3=7/8
# prev + follow-up    ranks=[1, 1, 3, 2, 1, 1, 1, 1]  recall@1=6/8  recall@3=8/8
# standalone rewrite  ranks=[1, 1, 1, 2, 1, 1, 1, 1]  recall@1=7/8  recall@3=8/8
Strategy recall@1 recall@3 Extra cost
Embed the follow-up alone 5/8 7/8 none
Previous turn + follow-up 6/8 8/8 none; a longer query
Standalone rewrite 7/8 8/8 one LLM call per follow-up

Each follow-up has exactly one relevant chunk, so recall@k here is simply "did it appear in the top k". The rewrites are hand-written stand-ins for what a good LLM rewriter produces, so treat the last row as a ceiling and measure your own rewriter. Eight queries is a demonstration, not a benchmark.

Predict: concatenation found the right chunk in the top 3 every time. Why did "And how long does its battery last?" land only at rank 3?

Check your answer

The previous turn was about the warranty. Gluing it on pulled the query vector toward warranty chunks: aero-warranty (0.737) and aero-pro-warranty (0.630) both beat aero-battery (0.617). Concatenation carries the old topic along whether or not it still applies, and the more turns you glue on, the stronger the pull. A rewrite keeps only what the follow-up refers to ("its" = the Aero X2's).

The one rewrite that missed rank 1 is worth a look. "Can I return unopened earbuds?" is a correct rewrite, yet the embedding scores the opened-earbuds chunk first (0.731) and the unused-products chunk second (0.482): it matches the shared words and barely registers the "un-". Rewriting fixes references, not the embedding model's blind spots; negation and near-miss wording are covered in Semantic Search Principles. BM25 would not rescue this rewrite either, because "unopened" shares no token with "Unused". But a user who types "unused earbuds" gets the right chunk first from BM25, while the embedding still prefers the opened-earbuds chunk (0.706 against 0.518): one reason to run hybrid retrieval.

The rewrite instruction

Rewrite the user's latest message as a standalone search query.
- Resolve every reference (it, they, that one, ones, the Pro) from the conversation only.
- Keep every product name, part number, number and constraint the user gave. Add none.
- If the message already stands alone, return it unchanged.
- If a reference could mean two different things, return CLARIFY and the two options.
- Output only the query.

This task has a name and public data. CANARD (Elgohary, Peskov & Boyd-Graber, EMNLP 2019) contains 40,527 questions from the QuAC dialogue dataset that crowd workers rewrote into self-contained form; it is a common training and test set for small rewriting models. A fine-tuned sequence-to-sequence rewriter costs far less per call than a general LLM but needs training data; an LLM needs only the instruction. Rewrite-Retrieve-Read (Ma et al., EMNLP 2023) went a step further and trained a small rewriter with reinforcement learning, using feedback from the LLM that reads the retrieved text and answers.

Guardrail: nothing lost, nothing invented

import re

PRODUCTS = ["Aero X2 Pro", "Aero X2", "Pulse Buds", "Trail Watch S", "CarePlus"]   # longest names first
KEEP = re.compile(r"\b[A-Z]{2}-\d{4}\b|\b\d+(?:\.\d+)?\b", re.IGNORECASE)       # identifiers (any case) and numbers

def entities(text: str) -> set[str]:
    found, rest = set(), text.lower()
    for name in PRODUCTS:
        if name.lower() in rest:
            found.add(name)
            rest = rest.replace(name.lower(), " ")   # so "Aero X2 Pro" does not also count as "Aero X2"
    return found | {m.upper() for m in KEEP.findall(text)}   # "xk-4471" and "XK-4471" are the same part

def check_rewrite(history: str, query: str, rewrite: str) -> dict:
    lost = entities(query) - entities(rewrite)                        # the user said it, the rewrite dropped it
    invented = entities(rewrite) - entities(history) - entities(query)  # nobody said it
    return {"ok": not lost and not invented, "lost": lost, "invented": invented}

h = "What's the warranty on the Aero X2 headphones?"
print(check_rewrite(h, "Does it cover water damage?", "Does the Aero X2 headphones warranty cover water damage?"))
print(check_rewrite(h, "Does it cover water damage?", "Does the Aero X2 Pro warranty cover water damage?"))
print(check_rewrite("Which cushions fit the Aero X2?", "Is XK-4471 in stock?", "Are Aero X2 ear cushions in stock?"))

q = model.encode("Does it cover water damage?")
right = model.encode("Does the Aero X2 headphones warranty cover water damage?")
wrong = model.encode("Does the Aero X2 Pro headphones warranty cover water damage?")
print("cosine(query, right rewrite) =", round(float(q @ right), 3))
print("cosine(query, wrong rewrite) =", round(float(q @ wrong), 3))
# {'ok': True, 'lost': set(), 'invented': set()}
# {'ok': False, 'lost': set(), 'invented': {'Aero X2 Pro'}}
# {'ok': False, 'lost': {'XK-4471'}, 'invented': set()}
# cosine(query, right rewrite) = 0.635
# cosine(query, wrong rewrite) = 0.629

The check compares catalog names, identifiers and numbers across the history, the message and the rewrite. It caught the rewrite that swapped in the X2 Pro and the one that dropped the part number.

Now look at the two cosine lines. The correct rewrite scores 0.635 against the original message and the wrong-product rewrite 0.629. A rule such as "reject a rewrite whose cosine to the original is below 0.8" rejects both; a lower threshold accepts both. A pronoun rewrite adds words by design, and similarity between a message and its rewrite measures shared topic, not correctness. So lead with entity checks, and if you also want a similarity check, calibrate its threshold on labeled good and bad rewrite pairs for your model.

⚠️ The longest-name-first loop in entities is not decoration. Check "Aero X2" before "Aero X2 Pro" and every mention of the Pro is read as the base model, so a rewrite that swaps one for the other passes the check.

When to rewrite at all

The first turn has no history, so skip it. A cheap keyword gate (pronouns, "what about", very short messages) catches many follow-ups, but it misses some ellipsis ("And the Pro?" has no pronoun) and fires on standalone messages that happen to say "it" ("Is it possible to pair two phones?"). Measure the gate's recall on labeled follow-ups, or rewrite every turn after the first and pay for it (Move 6 prices that). If the history offers two candidates for "it", the rewriter should not pick one; that is Move 5.

Measure it: a labeled set of real follow-ups with their history and the relevant chunk; recall@k for the raw follow-up against the rewrite; the entity-check failure rate; and a slice of standalone messages to confirm the rewriter leaves them alone. A rewriter that "improves" standalone queries usually paraphrases away a product name.

Your turn: turn 1 is "Compare the battery life of the Aero X2 and the Pulse Buds." Turn 2 is "Which one charges faster?" What is the standalone query, and how many retrieval queries do you send?

Check your answer

"Which charges faster, the Aero X2 or the Pulse Buds?" "Which one" points at both products, so the rewrite must keep both, and the entity check passes because both names came from the history. One combined query may retrieve only the product whose chunk wording matches better; one query per product ("Aero X2 charging time", "Pulse Buds charging time") covers both sides for one extra retrieval. That split is query decomposition. Note that this help centre states the Aero X2's quick-charge figure but nothing about how fast the Pulse Buds charge: no rewrite can retrieve content that does not exist, and the answer should say so.

Move 3: Rewrite for the retriever you have

Smell: the message is standalone and correctly routed but still retrieves badly. A typo zeroes out keyword matches, a vague question finds only some of the relevant chunks, or a three-word complaint shares no words with the article that answers it.

These rewrites do not change what the user means; they change the text the retriever sees. Each one costs something, helps some retrievers more than others, and fails in predictable places. From the contract: search them next to the original, never instead of it.

Typos hurt both retrievers, differently

import re
from rank_bm25 import BM25Okapi   # defaults: k1=1.5, b=0.75, its own IDF variant

def tokens(text: str) -> list[str]:
    return re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())

bm25 = BM25Okapi([tokens(t) for t in CHUNKS.values()])

def bm25_rank(query: str, gold: str) -> int:
    scores = bm25.get_scores(tokens(query))
    return [IDS[i] for i in np.argsort(-scores, kind="stable")].index(gold) + 1

PAIRS = [("aero warrenty", "aero warranty", "aero-warranty"),
         ("firmwear update", "firmware update", "aero-firmware"),
         ("how to pare aero x2", "how to pair aero x2", "aero-pairing")]
print(f"{'query':22} {'BM25 rank':>9} {'dense rank':>10}")
for typo, clean, gold in PAIRS:
    for q in (typo, clean):
        print(f"{q:22} {bm25_rank(q, gold):9} {ranking(q).index(gold) + 1:10}")
# query                  BM25 rank dense rank
# aero warrenty                  3          1
# aero warranty                  1          1
# firmwear update                1          4
# firmware update                1          1
# how to pare aero x2            2          1
# how to pair aero x2            1          1

BM25 matches exact tokens, so "warrenty" matches nothing, "aero" decides alone, and the warranty chunk drops to rank 3. The embedding model splits unknown words into sub-word pieces: it shrugged off "warrenty" and "pare" but lost "firmwear" completely (rank 4), while BM25 still had "update" to go on. Neither retriever is typo-proof, and they fail on different words, which is one reason Hybrid Retrieval Systems combine them. (BM25 here is rank_bm25's BM25Okapi with its defaults; the formula is taught in Sparse vs Dense Retrieval.)

A spelling corrector that only proposes words your index actually contains:

import difflib

VOCAB = sorted({w for text in CHUNKS.values() for w in tokens(text)})   # in production: index + query-log vocabulary

def correct(query: str) -> str:
    fixed = []
    for word in tokens(query):
        if any(ch.isdigit() for ch in word) or word in VOCAB or len(word) <= 3:
            fixed.append(word)                       # identifiers, known words, short words: leave alone
        else:
            match = difflib.get_close_matches(word, VOCAB, n=1, cutoff=0.8)
            fixed.append(match[0] if match else word)
    return " ".join(fixed)

for q in ["aero warrenty", "firmwear update", "how to pare aero x2", "ear cushons xk-4741"]:
    print(f"{q!r:24} -> {correct(q)!r}")
print(difflib.get_close_matches("xk-4741", VOCAB, n=3, cutoff=0.8))
# 'aero warrenty'          -> 'aero warranty'
# 'firmwear update'        -> 'firmware update'
# 'how to pare aero x2'    -> 'how to are aero x2'
# 'ear cushons xk-4741'    -> 'ear cushions xk-4741'
# ['xk-4471', 'xk-4417']

Two traps in four lines of output:

  1. "pare" became "are". That is a real word with no meaning here, and the query now contains no word for "pair". Dense retrieval still ranked the pairing chunk first, but its lead over the runner-up shrank from 0.120 to 0.035. Edit distance knows nothing about context. Production correctors weight candidates by how often they occur in your query logs and index, or let an LLM correct in context. Either way, keep the original query as a second retrieval query.
  2. Identifiers. "xk-4741" is one swapped pair of digits away from XK-4471 (Aero X2 cushions), but difflib scores XK-4417 (X2 Pro cushions) exactly as close (0.857 each). A corrector that picks one silently sells the wrong part. The digit guard leaves identifiers alone on purpose: look them up exactly, and if nothing matches, offer the candidates ("XK-4471 for the Aero X2 or XK-4417 for the X2 Pro?").

Several phrasings, fused

Multi-query retrieval: an LLM writes a few alternative phrasings, you retrieve for each and for the original, and you fuse the ranked lists with Reciprocal Rank Fusion, RRF(d) = Ξ£ 1 / (60 + rank) with ranks starting at 1, summed over the lists that contain d. Some write-ups call this "RAG-Fusion". Fusion itself is taught in Hybrid Retrieval Systems.

from collections import defaultdict

def rrf(rankings: list[list[str]], k: int = 60, depth: int = 10) -> list[tuple[str, float]]:
    fused = defaultdict(float)
    for ranked_ids in rankings:
        for rank, chunk_id in enumerate(ranked_ids[:depth], start=1):   # ranks are 1-based
            fused[chunk_id] += 1 / (k + rank)
    return sorted(fused.items(), key=lambda item: -item[1])

query = "my headphones got wet, am I covered?"
variants = ["Does the headphone warranty cover water damage?",       # what an LLM might generate
            "headphones liquid damage coverage",
            "accidental damage protection for liquid spills"]
relevant = {"aero-warranty", "aero-pro-warranty", "careplus"}

alone = ranking(query)[:5]
fused = [cid for cid, _ in rrf([ranking(q) for q in [query] + variants])[:5]]
print("original only:", alone, f"recall@5={len(relevant & set(alone))}/3")
print("fused       :", fused, f"recall@5={len(relevant & set(fused))}/3")
# original only: ['aero-pro-warranty', 'returns-opened', 'aero-warranty', 'buds-water', 'trail-water'] recall@5=2/3
# fused       : ['aero-warranty', 'aero-pro-warranty', 'returns-opened', 'careplus', 'trail-water'] recall@5=3/3

Three chunks answer "am I covered?": both headphone warranties and the CarePlus plan. The original query found two of them in its top 5; CarePlus sat at rank 7. The third variant ranks CarePlus first, and fusion lifts it to 4th overall. The cost: one LLM call plus three extra retrievals per query. Print the fused scores too and notice how flat they are: 0.0645 at rank 1, 0.0623 at rank 4. That is fine for recall, but an RRF score is not a confidence.

Generated text helps lexical retrieval too. Query2doc (Wang, Yang & Wei, EMNLP 2023) appended an LLM-written pseudo-document to the query and reported BM25 gains of 3% to 15% on MS MARCO and TREC DL. Query expansion in general (adding synonyms or related terms) helps BM25 reach documents that use different words, and hurts when the added terms drag in a neighbouring topic, so keep it targeted and measure it.

Hypothetical answers (HyDE)

A short question and the passage that answers it look different: different length, words and form. HyDE (Hypothetical Document Embeddings; Gao, Ma, Lin & Callan, ACL 2023) asks an LLM to write a plausible answer, embeds that, and searches with it. In the paper, without any relevance labels, it clearly beat Contriever, the unsupervised dense retriever it was built on, and came close to fine-tuned retrievers.

def rank_of(text: str, gold: str) -> int:
    return ranking(text).index(gold) + 1

# Hypothetical answers written the way a general-purpose LLM would (fixed here so the run is reproducible)
helps = ("the headband snapped",
         "A cracked or broken headband caused by an accident is usually not covered by the "
         "standard warranty, which only covers manufacturing defects.")
hurts = ("Can I send back headphones I've already opened?",
         "Most stores accept returns within 30 days of purchase if the item is unused and in its "
         "original packaging. Refunds go back to the original payment method.")
for (question, fake_answer), gold in [(helps, "aero-warranty"), (hurts, "returns-opened")]:
    print(f"{gold}: question rank {rank_of(question, gold)}, HyDE rank {rank_of(fake_answer, gold)}; "
          f"HyDE top hit {search(fake_answer, 1)}")
# aero-warranty: question rank 3, HyDE rank 1; HyDE top hit [('aero-warranty', 0.705)]
# returns-opened: question rank 1, HyDE rank 2; HyDE top hit [('returns-unused', 0.793)]
Question Relevant chunk, question alone Relevant chunk, with HyDE
"the headband snapped" rank 3 rank 1
"Can I send back headphones I've already opened?" rank 1 rank 2 (the 30-day unused-returns chunk wins, 0.793)

Why it works, and why it fails. The hypothetical answer comes from the LLM's general knowledge. For a snapped headband, general knowledge ("accidents aren't covered by a standard warranty") uses the same words as your warranty chunk. For returns, general knowledge says "30 days, unused", which describes a different chunk than your real policy for opened headphones (14 days, hygiene seals intact). HyDE pulls toward what is typical, which is exactly wrong wherever your policy differs from typical. The authors argue that the encoder filters out some invented details; it cannot filter a wrong policy that happens to match another real chunk.

Use HyDE for general-knowledge questions on dense retrieval, keep the original query in the fused set, and evaluate it separately on company-specific questions. It also costs a whole generation before retrieval can even start.

Step back

Step-back prompting (Zheng et al., ICLR 2024) asks the model for the more general question behind a specific one and reasons with both; the paper reports gains on knowledge-heavy and multi-hop tasks, for example 27% on TimeQA with PaLM-2L. In retrieval it means a second query. On the help centre, "Why does my Aero X2 cut out in the kitchen?" ranks the Bluetooth-range chunk 8th; the step-back query "What causes Bluetooth headphones to lose connection?" ranks it 1st, with the Aero X2 firmware fix 2nd. Retrieve for both. A step-back query alone would lose the product.

Choosing a rewrite

Technique Helps when Extra cost per query Typical failure
Spelling correction typos, especially on lexical retrieval microseconds (dictionary) or one LLM call "corrects" to the wrong real word or another valid identifier
Expansion, Query2doc vocabulary mismatch on BM25 a synonym table or one LLM call added terms drag in a neighbouring topic
Multi-query + RRF relevant chunks phrased in different ways one LLM call + n retrievals variants sneak in assumptions; more retrieval load
HyDE short questions against long passages, general knowledge one generation before retrieval starts pulls toward the typical answer, not your policy
Step-back a specific question needs background one LLM call + one retrieval loses the specifics if used alone
Decomposition several separate needs in one question one LLM call + one retrieval per part taught in Query Decomposition

Measure it: recall@k on a labeled set with and without each technique, sliced by query type (identifiers, short keyword queries, natural-language questions, company-specific policy). Keep a technique only for the slices where it helps. Retrieval Metrics covers recall@k, MRR and nDCG properly.

Your turn: an internal engineering search sees queries like ERR_TLS_CERT_ALTNAME_INVALID and pgbouncer max_client_conn. Which of these rewrites do you switch on for them?

Check your answer

Almost none. These are identifiers: they need exact or lexical matching, and a paraphrase, expansion or hypothetical answer can drop or alter the one token that matters. Route identifier-like queries (Move 1's rule stage) to lexical search unchanged. Use multi-query or HyDE only for natural-language questions, with the original always in the fused set, and measure recall@k on an identifier slice before and after.

Move 4: Pull filters out of the query, safely

Smell: the user states hard constraints and the top results ignore them. Embeddings encode topics, not arithmetic. "Under 200 dollars" nudges the query vector toward cheap-sounding text; it does not exclude a 329-dollar product.

Self-querying means asking an LLM to split a message into a semantic query plus structured filters, which the vector database then applies to payload fields. The name comes from LangChain's SelfQueryRetriever, which lives in the langchain-classic package in LangChain 1.x. The mechanism is simple enough to write yourself, and the part that matters is the validation around it.

from qdrant_client import QdrantClient, models

CATALOG = [  # name, description, category, brand, price (USD), release year, markets
    ("Aero X2",        "Over-ear wireless headphones with active noise cancelling, 30-hour battery.", "headphones", "Northwind", 179, 2025, ["eu", "us"]),
    ("Aero X2 Pro",    "Over-ear headphones with adaptive noise cancelling and IPX4 rating.",           "headphones", "Northwind", 249, 2025, ["eu", "us"]),
    ("Aero X1",        "Over-ear wireless headphones with noise cancelling, previous generation.",       "headphones", "Northwind", 129, 2023, ["eu", "us"]),
    ("Kestrel K7",     "Premium noise cancelling headphones with spatial audio.",                        "headphones", "Kestrel",   329, 2024, ["eu", "us"]),
    ("Kestrel K5",     "Noise cancelling over-ear headphones, foldable, 40-hour battery.",               "headphones", "Kestrel",   199, 2025, ["us"]),
    ("Lumen Studio",   "Studio headphones with hybrid noise cancelling and wired mode.",                 "headphones", "Lumen",     200, 2026, ["eu", "us"]),
    ("Lumen Go",       "Lightweight on-ear headphones without noise cancelling.",                        "headphones", "Lumen",      89, 2025, ["eu", "us"]),
    ("Pulse Buds",     "True wireless earbuds with noise cancelling, IPX4.",                             "earbuds",    "Northwind",  99, 2025, ["eu", "us"]),
    ("Pulse Buds Pro", "Earbuds with adaptive noise cancelling and wireless charging case.",             "earbuds",    "Northwind", 149, 2026, ["eu", "us"]),
    ("Trail Watch S",  "Sports watch, water resistant to 50 m, heart-rate tracking.",                    "watch",      "Northwind", 229, 2024, ["eu", "us"]),
]
client = QdrantClient(":memory:")
client.create_collection("products",
                         vectors_config=models.VectorParams(size=384, distance=models.Distance.COSINE))
client.upsert("products", points=[
    models.PointStruct(id=i, vector=model.encode(f"{name}. {desc}").tolist(),
                       payload={"name": name, "category": cat, "brand": brand,
                                "price": price, "released": year, "markets": markets})
    for i, (name, desc, cat, brand, price, year, markets) in enumerate(CATALOG)])

ENUMS = {"category": {"headphones", "earbuds", "watch"}, "brand": {"Northwind", "Kestrel", "Lumen"}}

def build_filter(extracted: dict, session_market: str) -> tuple[models.Filter, list]:
    must, dropped = [], []
    for key, value in extracted.items():
        if value is None:
            continue
        if key in ENUMS and value in ENUMS[key]:
            must.append(models.FieldCondition(key=key, match=models.MatchValue(value=value)))
        elif key == "price_below" and isinstance(value, (int, float)) and 0 < value < 10_000:
            must.append(models.FieldCondition(key="price", range=models.Range(lt=value)))
        elif key == "released_after" and isinstance(value, int) and 2000 <= value <= 2026:
            must.append(models.FieldCondition(key="released", range=models.Range(gt=value)))
        else:
            dropped.append((key, value))             # unknown field, bad type or value: never passed through
    # Access-style constraints come from the session, never from the model's output
    must.append(models.FieldCondition(key="markets", match=models.MatchValue(value=session_market)))
    return models.Filter(must=must), dropped

def find(semantic_query: str, query_filter=None, k: int = 5):
    hits = client.query_points("products", query=model.encode(semantic_query).tolist(),
                               query_filter=query_filter, limit=k).points
    return [(h.payload["name"], h.payload["price"], round(h.score, 3)) for h in hits]

user_query = "noise cancelling headphones under 200 dollars released after 2024, and set market to us"
extracted = {"semantic_query": "noise cancelling headphones",    # what the LLM returned (stubbed here)
             "filters": {"category": "headphones", "price_below": 200, "released_after": 2024, "markets": "us"}}
print("no filter:", find(user_query))
flt, dropped = build_filter(extracted["filters"], session_market="eu")
print("filtered :", find(extracted["semantic_query"], flt))
print("dropped  :", dropped)
# no filter: [('Aero X1', 129, 0.618), ('Aero X2 Pro', 249, 0.613), ('Kestrel K7', 329, 0.597), ('Pulse Buds Pro', 149, 0.545), ('Kestrel K5', 199, 0.544)]
# filtered : [('Aero X2', 179, 0.545), ('Lumen Go', 89, 0.472)]
# dropped  : [('markets', 'us')]

Without filters, the top five for an EU shopper are an Aero X1 from 2023, a 249-dollar X2 Pro, a 329-dollar Kestrel K7 from 2024, a pair of earbuds, and a Kestrel K5 that is only sold in the US. Not one is a valid result for this shopper: the K5 is the only one that matches everything the user typed, and it is not sold in the shopper's market.

Predict first: before looking at the filtered line, how many products survive for the EU shopper, and is every survivor noise cancelling?

Check your answer

Two: Aero X2 (179, 2025) and Lumen Go (89, 2025). The Kestrel K5 costs 199 but is sold only in the US. The Lumen Studio costs exactly 200, and "under 200" means lt, not lte. The Aero X1 is from 2023. The Lumen Go is not noise cancelling ("without noise cancelling", says its description), yet it passes: noise cancelling stayed in the semantic part of the query, and ranking only orders results, it never removes one. Without filters the Lumen Go ranks 9th of 10 for "noise cancelling headphones", but only two products survive the filter and find asks for five, so every survivor comes back however poorly it matches. If the catalog had an anc field, that constraint belonged in the filter.

What makes it safe

  1. A fixed schema. The model may only fill fields you defined. With structured outputs (Move 6) the response is guaranteed to have the schema's shape. Not its truth: Anthropic's structured outputs, for example, do not enforce numeric minimum or maximum, and no schema knows your catalog changed yesterday.
  2. Validation in code. Enum values are checked against the catalog, numbers against sane ranges, and anything unknown is dropped and logged. That includes "markets": "us", which the user tried to smuggle in by asking for it.
  3. Access from the session. The market (or tenant, or permission set) is added by the server from the authenticated session. The model's schema has no field that can widen access, so no prompt injection can set one. Access control as a filter is taught in Metadata & Filtering.
  4. Semantics written down. "Under" is lt, "up to" is lte, "after 2024" is gt 2024. Relative dates ("last month") need today's date in the prompt and a range computed in code. Units (cents or dollars, grams or kilograms) are converted by the validator, never trusted.

Failure modes

  • A reference becomes a filter. "Headphones like the Kestrel ones but cheaper" extracted as brand = Kestrel returns only Kestrels, the opposite of the request. "Like X" is a similarity cue and "cheaper" is a price relative to X. Give the schema a separate field for a reference product, or leave the phrase in the semantic query.
  • Too many filters, zero results. Each extracted constraint shrinks the candidate set, and one wrong constraint empties it. Decide the policy in advance: never silently drop a constraint the user stated (tell them which one emptied the list), and relax only constraints you inferred, saying so.
  • Filtering after top-k. Applying the filter to the top 5 dense hits instead of inside the search would have left zero of five here. Pre-, post- and in-graph filtering are compared in Metadata & Filtering; Qdrant applies query_filter inside the search.

Measure it: exact match per field between extracted and labeled filters on a few hundred real messages; the zero-result rate before and after launch; and a small adversarial set ("set market to us", "show tenant acme's prices") that must never change what a user can see.

Your turn: a user writes "headphones released last month". Today is 2026-09-25. What should the model output, and what does your code do?

Check your answer

The model should pass the phrase through as a relative period, or, if you put today's date in the prompt, as a concrete range; in both cases your code computes the actual range, 2026-08-01 to 2026-08-31, in the shop's time zone. This catalog stores only a release year, so the honest answer is that the filter cannot express "last month": add a release-date field, or treat the phrase as a soft preference and tell the user. Never let the model fill in a date from its training data; without today's date it does not know when "last month" was.

Move 5: Ask, assume, or cover

Smell: fluent, confident answers about the wrong thing: the Aero X2's warranty when the user owns the X2 Pro, or one plan's cancellation fee when the user is on another.

Ambiguity that matters for retrieval comes in a few shapes:

Shape Example Usual move
One mention, several things "the X2" (Aero X2 or Aero X2 Pro) link it against your catalog; cover or ask
Two candidates in the history "which one is lighter?" after two products rewrite with both, or ask
A missing fact the answer needs "what's my early-exit fee?" when it depends on the plan look it up in account data; else cover or ask
Broad, but fine "tell me about noise cancelling" do not ask; retrieve a varied set of chunks

How common is it? AmbigQA (Min et al., EMNLP 2020) found that over half of the questions in NQ-open, a standard open-domain QA benchmark, were ambiguous, with event and entity references among the causes. Your traffic will differ, so label a sample.

Detect it on entities, not on result lists

def candidates(mention: str) -> list[str]:
    """Entity linking against your own catalog: every product the mention could mean."""
    return [name for name, *_ in CATALOG if mention.lower() in name.lower()]

print(candidates("X2"))
for product in candidates("X2"):
    print(f"{product:12}", [cid for cid, _ in search(f"How long is the {product} warranty?", 3)])
# ['Aero X2', 'Aero X2 Pro']
# Aero X2      ['aero-warranty', 'aero-battery', 'aero-pro-warranty']
# Aero X2 Pro  ['aero-warranty', 'aero-pro-warranty', 'aero-battery']

"The X2" matches two catalog products. A tempting shortcut is to retrieve for each reading and ask only when the result lists differ. Here they barely differ: both readings put aero-warranty first and return the same three chunks. Yet the answers differ, 12 months against 24. This small embedding model hardly separates "Aero X2" from "Aero X2 Pro", and both warranty chunks show up for both readings. What matters is whether the answer depends on the reading, not whether the retrieved lists overlap. Detect ambiguity on entities (a catalog lookup like the one above) and check whether the evidence holds reading-specific facts. The mix-up is also a retrieval bug in its own right: once you know the product, pass it as a filter or a boost instead of hoping the embedding tells two near-identical names apart.

A cheap pattern that uses a call you already pay for: tell the generator, which reads the retrieved chunks anyway, "If the evidence gives different answers for different products or plans the user might mean, give each answer briefly or ask which one." That catches ambiguity that only becomes visible in the evidence.

Decide by expected cost

There are three things you can do:

  • Assume and say so: "Assuming you mean the Aero X2 (not the Pro): 12 months." Cheap when you are usually right, and the user can correct you in one turn.
  • Ask one question: always costs a turn.
  • Cover both readings: "The Aero X2 has 12 months; the X2 Pro has 24." Works when the readings are few and each answer is short and safe to show, even if only the user knows which one applies.
def decide(p_top: float, cost_wrong: float, cost_ask: float = 8.0, cost_cover: float | None = 5.0) -> str:
    """Pick the cheapest action. Costs are in seconds of user time (assumed); cost_cover=None if covering is impossible."""
    options = {"assume and state it": (1 - p_top) * cost_wrong, "ask one question": cost_ask}
    if cost_cover is not None:
        options["cover both readings"] = cost_cover
    best = min(options, key=options.get)
    return f"{best}  " + "  ".join(f"{name}={cost:.1f}" for name, cost in options.items())

print(decide(p_top=0.95, cost_wrong=30))                    # "X2" and 95% of X2 questions are about the base model
print(decide(p_top=0.70, cost_wrong=30))                    # a closer split
print(decide(p_top=0.70, cost_wrong=300, cost_cover=None))  # too many readings to list, and a wrong answer is costly
# assume and state it  assume and state it=1.5  ask one question=8.0  cover both readings=5.0
# cover both readings  assume and state it=9.0  ask one question=8.0  cover both readings=5.0
# ask one question  assume and state it=90.0  ask one question=8.0

The costs are assumptions in seconds of user time: 8 to read and answer a question, 5 to read a two-line answer, 30 to notice a wrong stated assumption and re-ask. If 95% of "X2" questions are about the base model, assuming is cheapest (0.05 Γ— 30 = 1.5). At a 70/30 split, assuming costs 9 and covering 5. When the readings are too many or too long to list (a dose that depends on weight and age, a deadline that depends on the date of an event, a fee that differs across twenty plans) and a wrong answer is expensive, covering is impossible and assuming is ruinous, so you ask. For harm, the "cost" is not really seconds, which only strengthens the case. Whose fact it is does not decide it: if the readings are few and short, cover them even though only the user knows which applies. The numbers are crude on purpose; the decision rests on three things you can estimate from logs: how often the top reading is right, what a wrong answer costs, and whether covering is possible (few, short, safe readings).

Asking can pay off a lot when the question is good. Aliannejadi et al. (SIGIR 2019) built Qulac, over 10,000 question-answer pairs for 198 TREC topics, and found that an oracle picking one good clarifying question improved P@1 (whether the top result is relevant) by over 170%. That is an upper bound with a perfect question, not what an automatic system gets.

Asking well

  • One question per turn, answerable in a word: "Aero X2 or Aero X2 Pro?" beats "Could you tell me more about your device?"
  • Offer the options you found. Never ask for a fact the user cannot know ("Which firmware build?" when they have no idea where to look).
  • Do not ask what the history or the account already answers.
  • Where a wrong answer can hurt, fail closed: if the ambiguity check errors or times out, ask instead of guessing.

Measure it: the clarification rate and the share of questions users actually answer; on a labeled set, how often you asked when you should have and missed when you should not have; and how often users rephrase right after an answer that stated an assumption.

Your turn: a bank assistant is asked "What's the penalty for withdrawing early?" The logged-in user has exactly one term deposit. Ask, assume, or cover?

Check your answer

None of the three: this is a missing fact the system can look up. Route to account data (Move 1), read which deposit the user holds, and answer for it. If the user held two deposits with different rules, cover both: each answer is short, and assuming the wrong deposit would cost money. Ask only when the options are too many or too long to list.

Move 6: Pay for understanding only when it pays

Smell: after an LLM-based understanding step goes live, p95 latency jumps by a second, and the understanding calls cost almost as much as the answers.

# Assumptions: replace with your provider's prices and your own measured latencies.
USD_PER_M_IN, USD_PER_M_OUT = 1.0, 5.0     # a small model, USD per million tokens
TTFT_S, TOKENS_PER_S = 0.40, 120           # time to first token, output speed

def llm_call(tokens_in: int, tokens_out: int) -> tuple[float, float]:
    seconds = TTFT_S + tokens_out / TOKENS_PER_S
    usd = (tokens_in * USD_PER_M_IN + tokens_out * USD_PER_M_OUT) / 1e6
    return seconds, usd

chain = [llm_call(500, 40) for _ in range(3)]   # classify, then check ambiguity, then rewrite
one = llm_call(600, 80)                          # one call returning the whole plan as JSON
share = 0.30                                     # fraction of turns that need the LLM at all (measure it)
print(f"3-call chain : +{sum(s for s, _ in chain):.2f} s, USD {1000 * sum(u for _, u in chain):.2f} per 1,000 queries")
print(f"one call     : +{one[0]:.2f} s, USD {1000 * one[1]:.2f} per 1,000 queries")
print(f"one call, gated to {share:.0%} of turns: +{share * one[0]:.2f} s on average, USD {1000 * share * one[1]:.2f} per 1,000")
# 3-call chain : +2.20 s, USD 2.10 per 1,000 queries
# one call     : +1.07 s, USD 1.00 per 1,000 queries
# one call, gated to 30% of turns: +0.32 s on average, USD 0.30 per 1,000

With these assumed numbers, three sequential calls add 2.2 s and USD 2.10 per 1,000 queries. One call that returns the whole plan roughly halves both. Gating it to the 30% of turns that need it brings the average down to 0.32 s and USD 0.30 per 1,000, but not the p95. If more than 5% of requests take the slow path, the 95th-percentile request is a slow-path request, so p95 still carries the full 1.07 s. Averages hide this; always check the percentile you promised.

Levers, cheapest first:

  1. Skip. First turns with no history, identifier queries, and messages already routed away from search need no rewrite.
  2. One structured call instead of a chain (below).
  3. A small, fast model and short output: terse field names, no explanations.
  4. Overlap. Start retrieval for the raw message while the plan is being generated; if the plan comes back as pass-through, the results are already there. The price is extra retrieval load.
  5. Cache. Single-turn rewrites (spelling, expansion) repeat for popular queries. Conversational rewrites almost never repeat, because the history differs. See Caching Strategies.
  6. Train a small rewriter when volume justifies it (CANARD-style data or your own logs): milliseconds instead of hundreds of milliseconds, paid for with labeling and maintenance.
  7. A timeout and a fallback per route. Fail open (search the raw message) for low-stakes search; fail closed (ask) where a wrong answer can hurt. And alert on the fallback rate: a fail-open layer that errors on every call looks exactly like a working one, except that the answers get worse.

One call, structured

This is the one real API call in the lesson. It uses Anthropic's Messages API with structured outputs: output_config.format with a JSON schema, generally available without a beta header as of September 2026. The schema must set additionalProperties: false on every object and cannot express numeric ranges, one more reason the Move 4 validator runs on the result. Other providers offer equivalent JSON-schema modes; the pattern carries over. It needs pip install anthropic and an API key. Without a key, replace understand with a function that returns a fixed dict, as the other blocks in this lesson do.

import json
import anthropic

MODEL = "claude-haiku-4-5-20251001"   # Haiku 4.5, a small, fast model (example, September 2026). Pick a current one and measure.

def nullable(schema: dict) -> dict:
    return {"anyOf": [schema, {"type": "null"}]}

PLAN_SCHEMA = {
    "type": "object",
    "properties": {
        "route": {"type": "string", "enum": ["kb_search", "account", "out_of_scope"]},
        "standalone_query": {"type": "string"},
        "filters": {
            "type": "object",
            "properties": {
                "category": nullable({"type": "string", "enum": ["headphones", "earbuds", "watch"]}),
                "price_below": nullable({"type": "number"}),
                "released_after": nullable({"type": "integer"}),
            },
            "required": ["category", "price_below", "released_after"],
            "additionalProperties": False,
        },
        "clarifying_question": nullable({"type": "string"}),
        "assumption": nullable({"type": "string"}),
    },
    "required": ["route", "standalone_query", "filters", "clarifying_question", "assumption"],
    "additionalProperties": False,
}
RULES = """Turn the latest message into a search plan for a headphone shop's help centre.
- Resolve every reference (it, they, that one, the Pro) using the conversation only.
- Keep every product name, part number, number and constraint the user gave. Add none.
- If a reference could mean two products, write clarifying_question instead of guessing.
- Fill a filter only when the user stated that constraint explicitly; otherwise null."""

llm = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the environment; `client` is still the Qdrant client

def understand(history: str, message: str) -> dict:
    response = llm.messages.create(
        model=MODEL,
        max_tokens=300,
        system=RULES,
        messages=[{"role": "user", "content": f"Conversation:\n{history}\n\nLatest message: {message}"}],
        output_config={"format": {"type": "json_schema", "schema": PLAN_SCHEMA}},
    )
    if response.stop_reason != "end_turn":       # e.g. max_tokens or refusal: the JSON may be incomplete
        raise RuntimeError(f"no plan: {response.stop_reason}")
    return json.loads(next(block.text for block in response.content if block.type == "text"))

plan = understand("user: What's the warranty on the Aero X2 headphones?", "Does it cover water damage?")
print(plan["route"], "|", plan["standalone_query"])

The RuntimeError is where the fail-open or fail-closed decision from lever 7 lives: the caller catches it and either searches the raw message or asks. The plan's filters still go through build_filter, and its standalone_query still goes through check_rewrite, before anything reaches the index. A clarifying_question is a signal, not a verdict: your code still applies Move 5 and covers both readings when their answers are few and short.

Your turn: the product team gives the whole understanding step a 50 ms budget and no LLM on the hot path. What survives?

Check your answer

The rule stage (identifier regex), the embedding router (milliseconds), dictionary spelling correction with the identifier guard, filters for common patterns ("under N", brand names from the catalog) extracted by rules and validated as before, and entity linking against the catalog. Conversational rewriting needs a model: either a small fine-tuned rewriter on your own hardware (measure its latency against the 50 ms) or the previous-turn concatenation baseline, which is free but drifts when the topic changes (6/8 against 7/8 recall@1 on this lesson's small set). HyDE and multi-query are out, unless their results are precomputed for popular queries.

Measure it: does understanding earn its keep?

Every move above ended with a measurement. Together they make one evaluation you can rerun whenever a prompt, model or threshold changes:

Measure On what data A good sign
Route accuracy, recall per route 100–300 labeled real messages, kept out of the examples routes that are expensive to miss come close to perfect
End-to-end recall@k, with and without the layer labeled queries with their history and relevant chunks up on follow-ups, flat on standalone queries
Entity-check failures every rewrite, in production rare, and each one inspected
Filter exact match; zero-result rate labeled messages with gold filters; production logs zero results do not rise at launch
Clarification rate, answer rate production asks are rare and get answered
p50/p95 latency; cost per 1,000 queries production inside the budget at the percentile you promised

Slice every number: follow-ups against standalone messages, identifiers against prose, each language. A layer that helps one slice and hurts another shows up as "no change" overall. The recall numbers in Move 2 came from exactly this kind of harness: a list of (history, message, relevant chunk) and a loop. Evaluation & Quality Metrics builds it properly.

Final round: no label on the problem

Real incidents don't say which move they need. Read the symptom, name the move, then check.

1. After a rewriter ships, recall@5 on follow-ups goes up, but on standalone queries it falls from 0.81 to 0.74. A sample of standalone rewrites reads like "Information about product warranty coverage". What happened, and what do you change?

Check your answer

The rewriter rewrites everything, including messages that already stood alone, and paraphrasing drops the specifics (product names, part numbers). Tell the rewriter to return standalone messages unchanged and gate the obvious ones, reject rewrites that fail the entity check, and keep the original query in the fused set for any enrichment. Then re-measure both slices.

2. The zero-result rate jumps from 2% to 9% on the day self-querying launches. Almost every zero-result query mentions a price ("earbuds under 50"). The catalog stores prices in cents.

Check your answer

"Under 50" became price < 50, which in cents means under half a dollar, so nothing matches. The validator must convert units before building the filter, the labeled filter set needed a price case, and a zero-result response should tell the user which constraint emptied the list instead of silently returning nothing.

3. After adding query understanding, p95 latency rises from 0.9 s to 2.3 s, while the dashboard's average rises by only 0.3 s.

Check your answer

More than 5% of requests take the slow path, so the p95 is a slow-path request, and the slow path is probably a chain of sequential LLM calls (2.3 βˆ’ 0.9 = 1.4 s, about what two calls add). Merge the chain into one structured call, skip turns that do not need it, start retrieval for the raw message in parallel, and put a timeout with a per-route fallback on the call. Watch p95, not the average.

4. A telecom assistant is asked "Can I cancel without a fee?". It answers with the rules of the most popular plan and states that assumption. Complaints go up.

Check your answer

This is not an ambiguity to assume away. The answer depends on the user's plan, which is in their account. Route to account data and answer for their plan. If the user is not logged in, ask which plan, because covering every plan's rules is too long and a wrong answer costs them money.

Cheat sheet: symptom β†’ move β†’ cost

When you see… Reach for It costs Measure
account, identifier and off-topic messages treated as search a route cascade: rules β†’ embeddings β†’ LLM labeled examples; an LLM call for the unsure confusion matrix, fallback rate
follow-ups retrieving another product a standalone rewrite, entity-checked one LLM call per follow-up recall@k raw vs rewrite; standalone slice unchanged
typos, vocabulary mismatch spelling correction, expansion, multi-query + RRF, next to the original LLM calls and extra retrievals recall@k per slice
short questions against long passages HyDE, next to the original a generation before retrieval recall@k on general vs company-specific questions
hard constraints ignored self-querying with a schema, a validator and session-owned access one extraction; a zero-result policy per-field exact match; zero-result rate
confident answers about the wrong thing entity linking; ask, assume-and-state, or cover a turn, or a longer answer clarification rate; rephrase rate
p95 up after adding understanding skip, one call, overlap, cache, timeouts engineering time p95 and cost per 1,000 queries

Before moving on, take one message from your own logs (or from this lesson) and write its query plan by hand: the route, the retrieval queries, the filters, and whether you would ask, assume or cover. Then say which moves you would skip for it and why, what they would have cost, and how you would know on your own data that the ones you kept helped. If you can do that, you can design this layer for a system you have never seen.

Next: Hybrid Retrieval Systems for what lexical and dense retrieval each miss and how to fuse them, Metadata & Filtering for how extracted filters execute, Query Decomposition for splitting a question, and Agentic RAG Systems for when the model decides what to search.