Skip to content
intermediate Phase 8 · Chunking and Retrieval Strategies

Query Rewriting and Expansion

Transform user questions into retrieval-friendly queries with synonyms and sub-queries.

50m
0 problems
Topic Progress 0%

What the Index Never Saw

The second turn of a conversation was "and what about the second one?" Retrieval embedded that string and returned passages about second — ordinals, rankings, second-quarter revenue. The referent was three turns back in the chat, and the chat is not in the index.

Two different defects produce that miss, and one stage fixes both:

  • Reference without resolution. "the second one", "that plan", "the same error" carry their meaning in history the index has never seen.
  • Under-specification. "the login thing again" — the user knows which system they mean; the query text does not say it.

The rewrite stage sits between the conversation and the retriever:

[history turns] + [latest turn]
        |
        v
   [ rewrite ] --- timeout / bad JSON / empty ---> latest turn used raw
        |                                              |
        v                                              v
 {query, facets[]} ---> [ retrieve per facet ] --> [ fuse ] --> [ rerank ]

Because the failure path rejoins the normal path, a rewrite outage degrades search to its current behavior instead of blocking it — a design constraint, not an optimization.

A rewrite is a retrieval-shaped transformation: it keeps the intent, resolves the reference, and drops conversational padding such as "can you tell me". It does not answer or summarize. Anything the model introduces that history does not support — a product name, a policy number, a date — is a new entity in your query, and it will match documents the user never meant (System Prompt Design covers the same discipline on the answer side).

The Rewrite Call and Its Fallback

The rewrite is one model call whose failure modes are all detectable before retrieval runs, which is what makes a fallback possible:

import json
from openai import OpenAI

client = OpenAI()

REWRITE = """Rewrite the user's latest message as a standalone search query.
Resolve pronouns and omitted nouns from the conversation. Add no facts.
Return JSON: {"query": str, "facets": [str, ...]} with 1-3 facets."""

def rewrite(history: list[dict], latest: str) -> dict:
    turns = "\n".join(f"{m['role']}: {m['content']}" for m in history)
    try:
        reply = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "system", "content": REWRITE},
                      {"role": "user", "content": f"{turns}\nlive: {latest}"}],
            response_format={"type": "json_object"},
            temperature=0,
        ).choices[0].message.content
        plan = json.loads(reply)
        if not plan.get("query", "").strip():
            raise ValueError("empty query")        # parses cleanly, still unusable
    except Exception:                              # timeout, 429, non-JSON, empty query
        return {"query": latest, "facets": [], "rewritten": False}
    return {"query": plan["query"],
            "facets": plan.get("facets", [])[:3],
            "rewritten": True}

Input:

history = [
    {"role": "user", "content": "Compare Bronze and Restore Plus for physical therapy."},
    {"role": "assistant", "content": "Bronze caps PT at 12 visits a year; Restore Plus has no cap but needs a referral."},
]
rewrite(history, "and what about the second one?")

Output:

{"query": "Restore Plus physical therapy coverage: visit cap and referral rules",
 "facets": ["Restore Plus physical therapy visits", "Restore Plus referral requirement"],
 "rewritten": true}

The pronoun resolved to Restore Plus because history named it; the facets split the two subjects the user cares about. The except branch is the whole reliability story: a timeout, a 429, malformed JSON, or an empty query all return the raw turn with rewritten: false, so search proceeds and the flag records how often the enhancement was skipped.

Facets, Variant Scoring, and the No-Rewrite Cases

Multi-part questions need more than one retrieval: "what is the return window and who pays shipping?" names two subjects, and one query embedding averages them into a compromise that answers neither. Splitting into one facet per subject gives each its own search — but the result sets now come from separate queries, so their scores sit on different scales and cannot be compared directly. Merging them needs rank-based combination, which is exactly what Reciprocal Rank Fusion does.

Rewrites are chosen, not assumed. Score each configuration over the same frozen set of labeled queries before shipping one:

Configuration recall@10 What the row shows
raw latest turn 0.55 follow-ups retrieve the wrong neighborhood
LLM rewrite only 0.78 pronouns resolved, single intent
rewrite + up to 2 facets 0.84 multi-part questions covered
rewrite + 5 facets with synonyms 0.69 noisy variants dilute the candidate pool

(40 labeled queries; illustrative.) The winner is the best row on your own set, and it stays the winner only until the corpus changes — re-run when documents are re-indexed (Evaluating Embedding Quality covers building the set).

When not to rewrite:

  • Exact identifiers — E_CONN_TIMEOUT, order 88213, SKU NW-2210. Paraphrasing the one string that must match literally guarantees a miss.
  • Operator syntax — level:ERROR svc:billing is already a query language; a model asked to "clean it up" will remove the operators.
  • Hard latency budgets — the rewrite adds a full model round trip before retrieval begins, so a 300 ms end-to-end budget may not contain it.
  • Saturated recall — if the raw query already lands the right passage in the top ten nearly always, the pass buys nothing and adds a failure surface.

Failure modes to log for:

  • Intent drift — the rewrite answers a neighboring question; the eval set catches it, eyeballing does not.
  • Hallucinated entities — a product name or policy number that appears nowhere in history.
  • Uncapped facets — the model returns seven; the code slices to three and logs the truncation.
  • Silent fallback — rewritten false on every request means the stage is dead and nobody noticed.

Quiz

1. Without a rewrite stage, what does retrieval actually search for on the turn "and what about the second one?"

Question 1 options

2. Why does the rewrite function return the raw turn on failure instead of raising?

Question 2 options

3. Which input should bypass the rewrite entirely?

Question 3 options

4. recall@10 on 40 labeled queries: raw 0.55, rewrite 0.78, rewrite + 2 facets 0.84, rewrite + 5 facets with synonyms 0.69. What follows?

Question 4 options

Flashcards

Question

What problem does query rewriting solve?

Answer

Users write conversational, under-specified text. Pronouns refer to history the index never saw, and vague phrasing retrieves a broad neighborhood. A rewrite produces a standalone, retrieval-shaped query before embedding.

Question

What does the fallback path return when the rewrite fails?

Answer

The raw user turn with rewritten=false. Search proceeds exactly as it does today, so a timeout, rate limit, or malformed JSON degrades quality instead of blocking the request.

Question

Why cap facets at two or three?

Answer

Each facet is a separate retrieval plus a merge, so cost and latency scale linearly with the list. Measured recall rises from raw to rewrite to rewrite-plus-facets, then falls once expansion gets noisy.

Question

Which queries should never be rewritten?

Answer

Exact identifiers (error codes, order ids, SKUs), operator syntax (level:ERROR svc:billing), and traffic under a latency budget that cannot absorb another model round trip.

Question

How is a rewrite configuration chosen?

Answer

Score each variant — raw, rewrite, rewrite plus facets — on a frozen labeled query set using recall@k, keep the best row, and re-score after the corpus is re-indexed.

Revision Notes

Key Takeaways

  • 1. The index holds documents, not conversation — reference resolution has to happen in a rewrite stage before embedding
  • 2. Every facet is a retrieval: cap the list at the boundary or cost and latency scale with the model's enthusiasm
  • 3. Return the raw query on any failure and log the flag, so a rewrite outage degrades search instead of stopping it
  • 4. Pick the configuration by recall on a frozen labeled set; expansion past two or three facets measurably hurts

Interview Tips

  • • Name the stage precisely: rewriting fixes recall and reference; ranking fixes ordering — interviewers probe whether you know which layer owns which failure
  • • Describe the fallback first when asked about reliability: what the system returns when the model is down, and what gets logged
  • • Give a concrete no-rewrite case with an example string such as E_CONN_TIMEOUT and explain why paraphrasing it guarantees a miss
  • • Walk the eval row by row — raw, rewrite, facets, over-expansion — and say what each movement attributes to

Cheat Sheet

Query Rewriting — Cheat Sheet

Stage position: history + latest turn → rewrite → retrieve per facet → fuse → rerank → generate. Failure rejoins the raw path, so search never depends on the model being up.

Rewrite contract:

input : chat history + latest user turn
output: {"query": str, "facets": [str, ...]}   1-3 facets, temperature 0, JSON mode
on fail: {"query": <raw turn>, "facets": [], "rewritten": false}

Resolve: pronouns and omitted nouns from history. Never add: names, dates, or policies that history does not contain.

Variant scoring (40 labeled queries, illustrative):

Configuration recall@10
raw latest turn 0.55
LLM rewrite only 0.78
rewrite + 2 facets 0.84
rewrite + 5 facets + synonyms 0.69

Skip the rewrite for: exact identifiers · operator syntax · tight latency budgets · queries that already saturate recall.

Failure modes: intent drift · hallucinated entities · uncapped facets · silent fallback (rewritten false everywhere) · fixing precision problems that belong to reranking.

Merge multi-facet results by rank, not by score: Reciprocal Rank Fusion.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading