Query Rewriting

aka Multi-Query Retrieval, Query Expansion, Query Reformulation, RAG-Fusion (query side)

category: retrieval · status: mature

Use an LLM to generate several alternative formulations of the user's query, retrieve documents for each, and rank-fuse the results so recall does not depend on one phrasing. A documentation assistant gets the query 'why is my deploy slow?' A single embedding lookup retrieves a handful of chunks about deploy speed in general. The rewriter produces variants — 'deployment latency causes', 'slow CI pipeline diagnosis', 'docker image build time bottlenecks', 'kubernetes rollout delay reasons' — and runs retrieval for each in parallel. The fused result set now contains chunks about image-build caching and rollout strategy that the original query embedding sat too far from. Reciprocal Rank Fusion across the five rankings surfaces the chunks that appear in multiple lists.

2. Problem

A team runs retrieval over a corpus where the user's natural phrasing is only one of many ways to express the same information need. The corpus chunks may use different vocabulary, abbreviations, or framing for the same concept, and an embedding-based lookup against a single query vector lands in only one neighbourhood of the embedding space. Users themselves under-specify, ask compound questions, or use idioms the corpus does not echo.

A single query embedding samples only one point in the semantic space and retrieves only the chunks closest to that point. Relevant chunks expressed in different vocabulary, at a different specificity level, or framed as a different sub-question are missed entirely, and no downstream reranker can rescue a chunk that was never retrieved. The user's first phrasing is a noisy estimator of intent, and recall is bottlenecked by how well that one phrasing aligns with how the answer chunks were written.

Competing forces:

3. When to Use

4. When Not to Use

5. Architecture Diagram

flowchart TD
  Q[Original query] --> R[LLM rewriter]
  R --> V1[Variant 1]
  R --> V2[Variant 2]
  R --> V3[Variant N]
  Q --> V0[Original kept as variant 0]
  V0 --> Ret[Retriever]
  V1 --> Ret
  V2 --> Ret
  V3 --> Ret
  Ret --> F[Rank fusion / RRF]
  F --> Out[Top-N to generator or reranker]

Query Rewriting expands the user query into N variants, retrieves for each in parallel, and rank-fuses the results.

6. Components

7. Tools

8. Guardrails

The retriever cannot be driven by the user's original query alone; the result set is the rank-fusion across all generated variants plus the original.

Therefore: have the LLM rewrite the query into several variants, retrieve for each in parallel, and fuse the rankings so the answer set reflects the intent rather than the surface phrasing.

9. Failure Modes

10. Evaluation Metrics

11. Code Examples

LLM emits N variants of the query, the original is kept as variant 0, every variant retrieves in parallel, and RRF fuses the rankings.unverified

function multi_query_retrieve(query, n=4, k=10):
    variants = llm.generate_variants(query, n=n)         # paraphrase / decompose / step-back
    variants = [query] + variants                        # keep the original

    rankings = []
    for v in variants:
        rankings.append(retriever.search(v, k=k))        # parallel

    # Reciprocal Rank Fusion across variant rankings
    C = 60
    scores = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking):
            scores[doc.id] = scores.get(doc.id, 0) + 1 / (C + rank)

    fused = sort_desc(scores.items(), by=value)
    return [docs[doc_id] for doc_id, _ in fused[:k]]

MultiQueryRetriever wraps a base retriever; the LLM expands the user query into variants and the retriever runs once per variant, unioning the results. · source: https://python.langchain.com/docs/how_to/MultiQueryRetriever/ · sdk: langchain>=1.0, langchain-classicunverified

import logging
from langchain.chat_models import init_chat_model
from langchain_classic.retrievers.multi_query import MultiQueryRetriever

llm = init_chat_model("gpt-4o-mini")
base_retriever = vector_store.as_retriever(search_kwargs={"k": 5})

retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=llm,
)

# Surface the generated variants in logs.
logging.basicConfig()
logging.getLogger("langchain.retrievers.multi_query").setLevel(logging.INFO)

docs = retriever.invoke("What is task decomposition for LLM agents?")
for d in docs:
    print(d.page_content[:200])

HyDEQueryTransform rewrites the query into a hypothetical-answer formulation that drives retrieval; MultiStepQueryEngine decomposes follow-up steps. · source: https://docs.llamaindex.ai/en/stable/examples/query_transformations/HyDEQueryTransformDemo/ · sdk: llama-index>=0.12unverified

from llama_index.core import VectorStoreIndex
from llama_index.core.indices.query.query_transform import HyDEQueryTransform
from llama_index.core.query_engine import TransformQueryEngine
from llama_index.llms.openai import OpenAI

llm = OpenAI(model="gpt-4o-mini")
index = VectorStoreIndex.from_documents(documents)
base_query_engine = index.as_query_engine(llm=llm, similarity_top_k=5)

# HyDE-style query transformation: the LLM rewrites the short query
# into an answer-shaped paragraph that drives the retriever.
hyde = HyDEQueryTransform(llm=llm, include_original=True)
query_engine = TransformQueryEngine(
    query_engine=base_query_engine,
    query_transform=hyde,
)

response = query_engine.query("What is task decomposition?")
print(response)

LLM emits N query variants; each retrieves in parallel; Reciprocal Rank Fusion combines the rankings without mixing raw scores.unverified

function multi_query_retrieve(query, n=4, k=10):
    variants = llm.generate_variants(query, n=n)   # paraphrase / decompose
    variants = [query] + variants                  # keep the original

    rankings = parallel_map(
        lambda v: retriever.search(v, k=k),
        variants,
    )

    # Reciprocal Rank Fusion across variant rankings (rank-based, scale-free).
    C = 60
    scores = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking):
            scores[doc.id] = scores.get(doc.id, 0) + 1 / (C + rank)

    fused    = sort_desc(scores.items(), by=value)
    top      = [docs[doc_id] for doc_id, _ in fused[:k]]
    reranked = reranker.rerank(query, top)
    return reranked

function answer(query):
    docs = multi_query_retrieve(query, n=4, k=10)
    return llm.generate(prompt=query, context=docs[:5])

# Why this works: the user's first phrasing is a noisy estimator of
# intent; retrieving against several formulations samples more of the
# semantic space, and rank-based fusion keeps the BM25/dense/variant
# scales from contaminating one another.
# Related: see `modular-rag`, `hierarchical-retrieval`.

Expected benefits

Variants

Paraphrase variants

The LLM rewrites the query into N surface-level paraphrases that preserve the same information need.

Distinguishing factor: variants are different phrasings of the same question

When to use: Default when vocabulary mismatch between query and corpus is the suspected recall drag.

Sub-question decomposition

The LLM decomposes a compound query into N narrower sub-questions, each retrieved independently.

Distinguishing factor: variants are sub-questions, not paraphrases

When to use: When the query is multi-hop or compound and different chunks answer different parts.

RAG-Fusion

Multi-query retrieval followed by Reciprocal Rank Fusion across the per-variant rankings to surface chunks that recur in several lists.

Distinguishing factor: named composition with RRF as the fusion step

When to use: When variant retrievers produce non-comparable scores and rank-based fusion is preferred.

Step-back / abstraction

The LLM emits one more abstract reformulation that retrieves higher-level context chunks alongside the literal query.

Distinguishing factor: one variant deliberately steps up the abstraction level

When to use: When the answer needs background context the literal query phrasing would miss.

Known uses

Related patterns

References