Intermediate7 min read33 of 40

Hypothetical Questions - Index by Questions, Not Answers

At index time, generate hypothetical questions each chunk could answer. At query time, match the user query to these questions for dramatically better semantic alignment.

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

python
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

python
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

python
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 client

Complete Notebook

Run the full implementation:

HyDE AWS Notebook

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

MetricStandard Chunk EmbeddingHypothetical Questions
Recall@571%86%
Precision@567%81%
MRR0.610.79
Index build timefastslow (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

Next Steps

  1. Try it: Run the HyDE notebook to understand Q-level embedding alignment
  2. Build: Implement index-time question generation on a sample of your documents
  3. Tune: Experiment with n=3 vs n=5 questions per chunk — more questions improve coverage at higher indexing cost
  4. Combine: Add HyDE (#5) at query time for dual-direction alignment

Part of: 37 RAG Patterns Collection

Go Deeper With Live Instruction

Covered in depth in our llm engineering program.