Skip to content

Query Rewriting

Users write queries the way they think, not the way your documents are written. "How do I log out?" becomes "token invalidation session termination JWT refresh token revocation" in your docs. "What's the price?" means nothing without context. "Tell me about security" is too broad to retrieve anything useful.

Query rewriting uses an LLM to transform the user's raw query into something your retrieval pipeline can work with. This lesson covers three techniques: single-query expansion, multi-query decomposition, and HyDE (hypothetical document embeddings). Each one addresses a different failure mode in retrieval.

Gnome mascot

What you'll learn

  • Query rewriting bridges the vocabulary gap between how users ask questions and how your documents are written; it adds the domain-specific terms that embeddings need to find the right content
  • Multi-query retrieval generates several reformulations of the same question, retrieves for each, and fuses the results: it covers more ground than a single query can
  • HyDE generates a hypothetical document that would answer the question, then searches for real documents similar to it; it works because an answer-shaped text embeds closer to the real answer than a question-shaped text does

The problem: your documents and your users speak different languages

A user types "I got logged out, how do I get back in?" Your documentation says "Session expiry and re-authentication flow." The vector embedding of the user's query is close to documents about "logging out intentionally," not documents about session expiry. The user's language is colloquial; the doc's language is technical.

Query rewriting solves this by running the query through an LLM before retrieval. The LLM expands "I got logged out" into something closer to the document vocabulary: "session expiration re-authentication token refresh." Now the embedding matches the right documents.

The same technique applies to:

  • Ambiguous queries: "JWT size" could mean token payload size or cryptographic key size. Rewriting adds specificity.
  • Multi-part questions: "How does JWT work and when should I use it?" becomes two retrievals: one for the mechanism, one for the use case.
  • Context-dependent queries: "How do I revoke it?" needs the LLM to infer "it" refers to a JWT from conversation history.

Options & when to use each

TechniqueWhat it doesBest forCosts you
Single-query rewritingLLM expands the query with domain terms and fixes ambiguityVocabulary gap between user language and doc languageOne extra LLM call per query (~100ms, ~$0.001)
Multi-query retrievalLLM generates 3-5 reformulations, retrieves for each, fuses with RRFBroad or ambiguous queries where a single retrieval might miss3-5x retrieval cost; total latency is max of parallel retrievals
HyDELLM generates a fake answer document, embeds it, searches for similar real docsQueries where the answer has a very different shape from the question (e.g., "What's the policy on..." vs the policy text itself)One extra LLM call + one extra embedding
Query decompositionLLM breaks a complex query into sub-questions, retrieves and answers each separatelyMulti-hop questions that require information from different documentsMultiple LLM calls for both rewriting and answering

Build it: three query rewriting patterns

Pattern 1: single-query expansion

Here is a concrete example demonstrating the implementation details.

python
def rewrite_query(raw_query: str, conversation_history: list[str] | None = None) -> str:
    """
    Expand a user query with domain terminology and resolve pronouns.
    Optionally uses conversation history for context.
    """
    system = """You are a query rewriter for a RAG system about JWT authentication.
    Rewrite the user's query to be specific, self-contained, and use
    technical terminology that matches documentation language.
    Do not answer the query. Only rewrite it. Output the rewritten query only."""

    history_block = ""
    if conversation_history:
        history_block = "Conversation history:\n" + "\n".join(
            f"- {msg}" for msg in conversation_history[-3:]
        ) + "\n"

    user = f"{history_block}User query: {raw_query}\nRewritten query:"

    response = call_llm(system=system, message=user)
    return response.strip()

Here is a concrete example demonstrating the implementation details.

python
# Before rewriting
raw = "I keep getting kicked out, it's annoying"
print("Raw query:", raw)
# -> "I keep getting kicked out, it's annoying"

rewritten = rewrite_query(raw)
print("Rewritten:", rewritten)
# -> "JWT session expiration handling and preventing unexpected token expiry"

Pattern 2: multi-query retrieval with fusion

Here is a concrete example demonstrating the implementation details.

python
from concurrent.futures import ThreadPoolExecutor, as_completed

def rewrite_to_multiple(raw_query: str, n_variants: int = 3) -> list[str]:
    """Generate multiple query reformulations."""
    system = """You are a query rewriter. Generate alternative phrasings of the user's
    question that might match different documents. Include at least one formulation
    that uses highly specific technical terms and one that is more general."""

    user = f"Generate {n_variants} different search queries for: {raw_query}\nQueries:"

    response = call_llm(system=system, message=user)
    # Parse each line as a separate query
    queries = [line.strip("- 0123456789. ") for line in response.split("\n") if line.strip()]
    return [raw_query] + queries[:n_variants]  # include original


def multi_query_retrieval(
    raw_query: str,
    top_k: int = 10,
    n_variants: int = 3,
) -> list[dict]:
    """
    Generate multiple query variants, retrieve for each in parallel,
    and fuse results with reciprocal rank fusion.
    """
    queries = rewrite_to_multiple(raw_query, n_variants)

    # Parallel retrieval
    all_results = []
    with ThreadPoolExecutor(max_workers=len(queries)) as executor:
        futures = {
            executor.submit(hybrid_search, q, top_k=top_k * 2): q
            for q in queries
        }
        for future in as_completed(futures):
            all_results.extend(future.result())

    # Reciprocal rank fusion across all result sets
    doc_scores: dict[int, float] = {}
    doc_contents: dict[int, str] = {}
    k = 60

    for result_set in [all_results[i::len(queries)] for i in range(len(queries))]:
        for rank, doc in enumerate(result_set, start=1):
            doc_id = doc["id"]
            doc_scores[doc_id] = doc_scores.get(doc_id, 0) + 1.0 / (k + rank)
            doc_contents[doc_id] = doc["content"]

    # Sort by fused score and return top k
    ranked = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
    return [
        {"id": doc_id, "content": doc_contents[doc_id], "rrf_score": score}
        for doc_id, score in ranked[:top_k]
    ]

Pattern 3: HyDE (Hypothetical Document Embeddings)

Here is a concrete example demonstrating the implementation details.

python
def hyde_retrieval(raw_query: str, top_k: int = 10) -> list[dict]:
    """
    Generate a hypothetical document that would answer the query,
    embed it, and retrieve real documents similar to that embedding.
    """
    system = """You are a technical writer. Write a short paragraph (3-5 sentences)
    that would appear in documentation answering the user's question.
    Write in the style of API documentation: factual, specific, uses
    correct technical terminology. Do not start with 'Here is...' or
    'The answer is...'. Write the paragraph directly."""

    hypothetical_doc = call_llm(system=system, message=raw_query)

    # Embed the hypothetical document instead of the query
    hypo_embedding = get_embedding(hypothetical_doc)

    # Search for real documents close to the hypothetical one
    results = conn.execute("""
        SELECT id, content, embedding <=> %s::vector AS distance
        FROM documents
        ORDER BY embedding <=> %s::vector
        LIMIT %s
    """, (hypo_embedding, hypo_embedding, top_k)).fetchall()

    return [
        {"id": row[0], "content": row[1], "distance": row[2]}
        for row in results
    ]

HyDE works because an answer-shaped text ("JWT tokens include an exp claim set to a Unix timestamp. The server rejects tokens whose exp is in the past. To set expiration, include exp in the payload when signing.") embeds much closer to a real documentation paragraph than a question-shaped text ("How do I set JWT expiration?") does. The question omits the words that appear in the answer -- exp, claim, timestamp, payload -- which HyDE generates.

When to use which

Here is a concrete example demonstrating the implementation details.

python
def smart_retrieve(raw_query: str, top_k: int = 10) -> list[dict]:
    """Choose the right rewriting strategy based on query characteristics."""
    words = raw_query.split()

    if len(words) <= 3:
        # Very short query: expand it
        expanded = rewrite_query(raw_query)
        return hybrid_search(expanded, top_k=top_k)

    if "?" not in raw_query and len(words) <= 5:
        # Keyword-like query, probably missing context
        expanded = rewrite_query(raw_query)
        return hybrid_search(expanded, top_k=top_k)

    if any(kw in raw_query.lower() for kw in [" and ", " or ", "compare", "versus", "vs"]):
        # Multi-part query: decompose and retrieve
        return multi_query_retrieval(raw_query, top_k=top_k)

    # Default: try HyDE for complex questions
    return hyde_retrieval(raw_query, top_k=top_k)

What goes wrong

MistakeHow you notice itThe fix
Rewritten query is too specificRetrieval returns zero or very few results (over-constrained)Add to the rewrite prompt: "If the query is already specific enough, return it unchanged." Fall back to original query if rewritten returns nothing
Rewritten query hallucinates termsRetrieved documents are about a different topic entirely; the LLM added a term that doesn't exist in your corpusInclude corpus vocabulary hints in the rewrite prompt. Keep a running list of key terms from your documents and pass them as context
HyDE generates a wrong answerRetrieved documents match the hypothetical wrong answer instead of the correct oneHyDE doesn't need to be factually correct; it needs to be structurally similar to a real answer. If the LLM hallucinates content, the embedding still lands in the right neighborhood. But if it invents an entirely different topic, retrieval fails. Validate HyDE output against known topics
Multi-query overhead is too highLatency spikes to 2-3 seconds for a single user queryRun retrievals in parallel (ThreadPoolExecutor above). Cache rewritten queries. Skip multi-query for factual lookups
LLM cost dominates retrieval costYour API bill is 10x higher after adding rewriting, with minimal metric improvementMeasure recall before and after. If rewriting only improves recall@5 by 0.02, it's not worth the cost. Use the evaluation pipeline from the previous lesson to make this decision with data

Confirm it worked

Here is a concrete example demonstrating the implementation details.

python
# Test: a query in casual language that should match technical docs
raw_query = "why does it keep logging me out every few minutes"

# Without rewriting
raw_results = hybrid_search(raw_query, top_k=5)
raw_top = raw_results[0]["content"][:200] if raw_results else "(no results)"

# With rewriting
rewritten = rewrite_query(raw_query)
rewritten_results = hybrid_search(rewritten, top_k=5)
rewritten_top = rewritten_results[0]["content"][:200] if rewritten_results else "(no results)"

# With HyDE
hyde_results = hyde_retrieval(raw_query, top_k=5)
hyde_top = hyde_results[0]["content"][:200] if hyde_results else "(no results)"

print(f"Raw query top result:       {raw_top}")
print(f"Rewritten query top result: {rewritten_top}")
print(f"HyDE top result:            {hyde_top}")
# Expected: rewritten and HyDE results should contain technical terms
# like "exp", "expiration", "token lifetime" that the raw query missed

Next: Handling Failures