aka Multi-Query Retrieval, Query Expansion, Query Reformulation, RAG-Fusion (query side)
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.
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:
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.
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.
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]]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])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)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`.The LLM rewrites the query into N surface-level paraphrases that preserve the same information need.
The LLM decomposes a compound query into N narrower sub-questions, each retrieved independently.
Multi-query retrieval followed by Reciprocal Rank Fusion across the per-variant rankings to surface chunks that recur in several lists.
The LLM emits one more abstract reformulation that retrieves higher-level context chunks alongside the literal query.
specialises → naive-ragcomposes-with → hybrid-searchcomposes-with → cross-encoder-rerankingalternative-to → hydecomposes-with → modular-ragcomposes-with → hierarchical-retrieval