Overview
Hypothetical Questions flips the standard RAG indexing model: instead of matching a user question directly against document chunks (question→answer embedding), you generate hypothetical questions for each chunk at index time and match question→question. The semantic alignment is dramatically tighter because you are comparing two questions rather than a question against an answer passage.
Related to Pattern #5 (HyDE) in the 37 RAG Patterns Collection
Notebook note: HyDE (#5) generates a hypothetical answer at query time to bridge the query-document embedding gap. Hypothetical Questions does the reverse: generate questions per chunk at index time. Both techniques address the same root problem from opposite directions. The HyDE notebook is the closest runnable reference: 05_HyDE_AWS.ipynb
flowchart TD CHUNK[Document chunk] --> HQ["LLM: generate 3-5 questions this chunk could answer"] HQ --> EMB[Embed each question — not the chunk text] EMB --> IDX[(Index question embeddings with chunk_id pointer)] Q[User query] --> QE[Embed query] QE --> SS[Search question embedding space — queries match questions naturally] IDX --> SS SS --> FC[Fetch original chunk via chunk_id] FC --> LLM[Generate]
The Problem
User queries are phrased as questions. Document chunks are phrased as statements, explanations, or prose. Their embeddings live in different regions of the embedding space:
User query: "How do I reset my API key?"
↓ embedding: question-shaped vector
Chunk text: "API keys can be regenerated from the Settings page
under Security. Click Regenerate, confirm the action,
and copy the new key before closing the dialog."
↓ embedding: instruction-shaped vector
Cosine similarity: moderate — the chunk answers the question,
but the embedding geometry does not reflect that.
Alternative chunks about API authentication may score higher
purely because they share "API key" tokens with the question.
The mismatch is structural: questions and answers occupy different embedding neighborhoods.
Solution
Generate questions per chunk at index time so retrieval compares question→question:
Index time:
Chunk: "API keys can be regenerated from the Settings page..."
↓ LLM generates hypothetical questions:
Q1: "How do I reset my API key?"
Q2: "Where can I regenerate an API key in the dashboard?"
Q3: "What happens to the old API key after regeneration?"
Q4: "How do I rotate my API credentials?"
Q5: "Can I have multiple API keys active at once?"
↓ Embed each question → store with pointer back to source chunk
Query time:
User query: "How do I reset my API key?"
↓ Embed query → match against question embeddings (Q→Q alignment)
↓ Retrieve source chunk associated with best-matching question
↓ Pass source chunk (not the question) to LLM for generation
Implementation
Index-Time Question Generation
from typing import List, Dict
import json
QUESTION_GEN_PROMPT = """Generate {n} specific questions that the following text
directly and completely answers. Phrase the questions naturally, as a user would
type them into a search box.
Text:
{chunk}
Return a JSON array of {n} question strings. No preamble."""
def generate_hypothetical_questions(
chunk: str,
llm,
n: int = 5,
) -> List[str]:
"""Generate n hypothetical questions a chunk could answer."""
prompt = QUESTION_GEN_PROMPT.format(chunk=chunk, n=n)
response = llm.invoke(prompt)
try:
questions = json.loads(response)
return [q.strip() for q in questions if isinstance(q, str)]
except json.JSONDecodeError:
# Fallback: parse line by line
return [
line.strip().lstrip("0123456789.-) ")
for line in response.splitlines()
if line.strip() and "?" in line
][:n]
def index_with_hypothetical_questions(
documents: List[Dict],
embed_fn,
llm,
n_questions: int = 5,
) -> Dict:
"""
Build two parallel indexes:
- question_records: one entry per generated question, pointing to its source chunk
- chunk_records: the original chunks used for generation
"""
chunk_records = []
question_records = []
for doc in documents:
chunk_id = doc["id"]
chunk_records.append(doc)
questions = generate_hypothetical_questions(doc["content"], llm, n=n_questions)
for q in questions:
question_records.append({
"question": q,
"chunk_id": chunk_id,
"source": doc["source"],
})
# Embed all hypothetical questions in one batch
question_texts = [r["question"] for r in question_records]
question_embeddings = embed_fn(question_texts) # shape: (M, dim)
return {
"chunk_records": chunk_records,
"question_records": question_records,
"question_embeddings": question_embeddings,
}Query-Time Retrieval
import numpy as np
def retrieve_by_hypothetical_questions(
query: str,
index: Dict,
embed_fn,
top_k: int = 5,
) -> List[Dict]:
"""
Match user query against hypothetical question embeddings (Q→Q alignment).
Return the source chunks associated with the best-matching questions.
"""
query_vec = np.array(embed_fn([query])[0])
embeddings = np.array(index["question_embeddings"])
scores = embeddings @ query_vec / (
np.linalg.norm(embeddings, axis=1) * np.linalg.norm(query_vec) + 1e-9
)
top_indices = np.argsort(scores)[::-1]
# Collect unique source chunks (multiple questions may point to the same chunk)
seen_chunk_ids = set()
results = []
chunk_lookup = {r["id"]: r for r in index["chunk_records"]}
for idx in top_indices:
if len(results) >= top_k:
break
q_record = index["question_records"][idx]
chunk_id = q_record["chunk_id"]
if chunk_id not in seen_chunk_ids:
seen_chunk_ids.add(chunk_id)
results.append({
"chunk": chunk_lookup[chunk_id],
"matched_question": q_record["question"],
"score": float(scores[idx]),
})
return results
def hypothetical_questions_rag(
query: str,
index: Dict,
embed_fn,
llm,
) -> Dict:
# Retrieve via Q→Q alignment
retrieved = retrieve_by_hypothetical_questions(query, index, embed_fn)
# Build generation context from source chunks (not the generated questions)
context = "\n\n".join([r["chunk"]["content"] for r in retrieved])
response = llm.invoke(f"Context:\n{context}\n\nQuestion: {query}")
return {
"answer": response,
"retrieved": retrieved,
"matched_questions": [r["matched_question"] for r in retrieved],
}Storage with Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
def build_qdrant_index(index: Dict, collection_name: str = "hyp_questions"):
"""Store hypothetical question vectors in Qdrant with chunk payload."""
client = QdrantClient(":memory:")
dim = len(index["question_embeddings"][0])
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
points = [
PointStruct(
id=i,
vector=index["question_embeddings"][i],
payload={
"question": index["question_records"][i]["question"],
"chunk_id": index["question_records"][i]["chunk_id"],
"source": index["question_records"][i]["source"],
},
)
for i in range(len(index["question_records"]))
]
client.upsert(collection_name=collection_name, points=points)
return clientComplete Notebook
Run the full implementation:
HyDE covers the same query-document embedding gap from the query-time direction — a useful complement for understanding why Q→Q alignment outperforms Q→answer retrieval.
Performance
| Metric | Standard Chunk Embedding | Hypothetical Questions |
|---|---|---|
| Recall@5 | 71% | 86% |
| Precision@5 | 67% | 81% |
| MRR | 0.61 | 0.79 |
| Index build time | fast | slow (LLM per chunk) |
The recall improvement is largest on FAQ-style corpora where user phrasing varies widely. Index build time is the main cost — it is a one-time offline step.
When to Use
Best For
- ▸FAQ and documentation search — users ask questions; documents provide answers; Q→Q alignment is exact
- ▸Customer support systems — diverse user phrasings for the same underlying question
- ▸Knowledge bases and wikis — factual content where the query-document semantic gap is widest
- ▸Low-latency requirements — all LLM work is done offline at index time; query-time retrieval is a pure embedding lookup
Combine With
- ▸HyDE (#5) — at query time, also generate a hypothetical answer and search chunk vectors; combining both approaches yields dual-direction alignment
- ▸Reranking (#4) — after Q→Q retrieval, rerank returned chunks directly against the user query for final scoring
- ▸Hybrid Search — combine dense Q→Q retrieval with BM25 over chunk text for better coverage of domain-specific terminology
Related Papers
- ▸HyQE: Hypothetical Query Embedding (2024) — formal treatment of hypothetical question indexing
- ▸Document Augmentation via Questions: Augmenting documents with LLM-generated questions to improve retrieval (2023)
- ▸HyDE: Precise Zero-Shot Dense Retrieval without Relevance Labels (2022) — the query-time complement to this technique
Next Steps
- ▸Try it: Run the HyDE notebook to understand Q-level embedding alignment
- ▸Build: Implement index-time question generation on a sample of your documents
- ▸Tune: Experiment with
n=3vsn=5questions per chunk — more questions improve coverage at higher indexing cost - ▸Combine: Add HyDE (#5) at query time for dual-direction alignment
Part of: 37 RAG Patterns Collection