Your RAG system has the right documents but still returns irrelevant chunks — because hyper-specific queries embed into vectors that miss the general-language paragraphs that actually answer them. This lesson shows you how to implement stepback prompting to abstract queries up one level of generality before retrieval, with production-ready code and evaluation strategies.

Picture this: a user asks your RAG system, "What was the dosage of metformin prescribed to patients with HbA1c above 9% in the 2023 ACCORD trial?" Your vector store has excellent coverage of the ACCORD trial, but the embedding for that hyper-specific question lands in a strange part of the semantic space — far from the general paragraphs about metformin dosing protocols that would actually answer it. You get back irrelevant chunks, the LLM hallucinates, and the user loses trust in your system.
This is the specificity trap. RAG systems fail not because they lack the right documents, but because the query is too specific to match the generalized language used in the source material. Stepback prompting is the fix: before you retrieve anything, you instruct an LLM to abstract the original query into a broader, more principled question — the kind of question a textbook or reference document would naturally answer — and then use that abstracted question (alongside the original) to retrieve a richer, more complete set of context chunks.
By the end of this lesson, you'll be able to design and implement a full stepback prompting layer for your RAG pipeline, understand exactly when it helps (and when it doesn't), tune its behavior for domain-specific corpora, and evaluate whether it's actually improving your retrieval outcomes.
What you'll learn:
This lesson assumes you're already comfortable building basic RAG pipelines. If you need to catch up, start with RAG Fundamentals: Build Your First Retrieval-Augmented Generation System and Building a RAG Pipeline with Your Own Documents. You should understand how embedding models work — the Embeddings Explained article covers the intuition well — and have basic familiarity with vector similarity search.
You'll need:
openai (or anthropic) for the LLM callschromadb or any vector store you preferlangchain (optional, we'll show both raw and LangChain versions)Before writing any code, let's make sure we have a clear mental model of the problem.
When you embed a query like "What was the metformin dosage for ACCORD trial patients with HbA1c > 9%?", the resulting vector captures the compound specificity of that question: the exact trial name, the exact biomarker threshold, the exact drug name all simultaneously. The embedding model tries to represent that entire specificity in one dense vector.
Now consider what's actually in your vector store. The relevant document chunks might be phrased as:
None of these chunks use the exact combination of terms in your query. They're written at a more general level because reference documents and clinical guidelines explain principles, not answer hyper-specific exam questions. The semantic distance between your specific query vector and these principle-level chunks can be surprisingly large.
Key insight
The specificity mismatch isn't a bug in your embedding model — it's a fundamental property of how domain knowledge gets documented. Reference material is written to be broadly applicable. Queries are specific to a particular case. Stepback prompting bridges this gap by translating user intent back into the language of reference material.
This phenomenon shows up across domains:
| Specific Query | Why It Fails | Stepback Version |
|---|---|---|
| "What tax rate applies to a California LLC earning $2.3M in 2024?" | Combines jurisdiction, entity type, amount, year | "What are the federal and state tax structures for multi-member LLCs?" |
| "Why did NVIDIA stock drop 6% on February 21, 2024?" | Overly anchored to a specific event | "What factors typically cause significant short-term volatility in semiconductor stocks?" |
| "How do I fix ORA-12154 connecting from Python on Linux?" | Triple compound specificity | "What are common Oracle connection string configuration patterns?" |
The stepback version isn't the only thing you retrieve against — you retrieve against both. But the stepback version casts a wider net that catches the foundational context the original query misses.
The heart of stepback prompting is a well-engineered prompt that reliably produces useful abstractions. "Useful" here means specific enough to stay on topic, but general enough to match how documents are actually written.
Let's build this carefully.
Stepback prompting works best with few-shot examples. A zero-shot instruction like "make this question more general" produces erratic results — sometimes the model makes it too general ("What is medicine?"), sometimes barely changes it. Few-shot examples calibrate the right level of abstraction.
Here's a production-ready prompt template for a medical/clinical corpus:
STEPBACK_SYSTEM_PROMPT = """You are an expert at reformulating specific questions into broader,
more general questions that capture the underlying concept or principle being asked about.
Given a specific query, produce ONE broader "stepback" question that:
1. Captures the general domain concept the specific question is about
2. Would be answered by the kind of reference material, documentation, or textbooks
that contain information relevant to the specific query
3. Removes specific identifiers (names, dates, exact values) while preserving the
conceptual intent
4. Is phrased as a genuine question, not a topic statement
Examples:
Specific: "What was the loading dose of clopidogrel used in the PLATO trial for ACS patients?"
Stepback: "What are the standard dosing protocols and clinical considerations for clopidogrel in acute coronary syndrome treatment?"
Specific: "Did the FDA approve semaglutide for cardiovascular risk reduction in March 2024?"
Stepback: "What is the regulatory approval process for expanding cardiovascular indications of GLP-1 receptor agonists?"
Specific: "What was the p-value for the primary endpoint in the EMPEROR-Reduced trial?"
Stepback: "How are primary endpoints and statistical significance interpreted in heart failure clinical trials?"
Now generate ONE stepback question for the following query. Return ONLY the stepback question,
no explanation or preamble."""
def generate_stepback_query(original_query: str, client, model: str = "gpt-4o-mini") -> str:
"""
Generate a stepback (abstracted) version of the original query.
Args:
original_query: The user's specific question
client: OpenAI client instance
model: Model to use for abstraction
Returns:
The abstracted stepback query as a string
"""
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": STEPBACK_SYSTEM_PROMPT},
{"role": "user", "content": f"Specific: {original_query}\nStepback:"}
],
temperature=0.0, # We want deterministic, consistent abstraction
max_tokens=150
)
stepback_query = response.choices[0].message.content.strip()
# Clean up if the model prefixes "Stepback:" despite instructions
if stepback_query.lower().startswith("stepback:"):
stepback_query = stepback_query[9:].strip()
return stepback_query
Tip
Set temperature=0.0 for your stepback generation. You want reproducible abstractions, not creative variations. Save the temperature budget for your final answer synthesis where nuanced language actually helps.
The right abstraction level varies significantly by domain and corpus. For a corpus of technical API documentation, one level of abstraction is appropriate. For a corpus of legal case law, you need a different calibration.
Here's a reusable class that lets you configure the abstraction style:
from dataclasses import dataclass
from typing import Optional
@dataclass
class StepbackConfig:
"""Configuration for domain-specific stepback behavior."""
domain_description: str
abstraction_level: str # "shallow", "moderate", "deep"
preserve_entities: list[str] # Entity types to keep (e.g., ["drug_class", "condition"])
examples: list[dict] # Few-shot examples: {"specific": str, "stepback": str}
CLINICAL_TRIALS_CONFIG = StepbackConfig(
domain_description="clinical research and medical literature",
abstraction_level="moderate",
preserve_entities=["drug_class", "condition_category", "trial_phase"],
examples=[
{
"specific": "What was the primary endpoint of DAPA-HF?",
"stepback": "How are primary efficacy endpoints defined and measured in SGLT2 inhibitor heart failure trials?"
},
{
"specific": "What was the reduction in HbA1c at 26 weeks in the SUSTAIN-6 trial?",
"stepback": "How do GLP-1 receptor agonists affect glycemic control outcomes in cardiovascular outcome trials?"
}
]
)
LEGAL_CONFIG = StepbackConfig(
domain_description="legal documents, case law, and regulatory filings",
abstraction_level="shallow", # Legal specifics often matter more
preserve_entities=["jurisdiction", "statute_type", "legal_standard"],
examples=[
{
"specific": "Was the search in Riley v. California considered constitutional?",
"stepback": "What Fourth Amendment standards apply to warrantless searches of digital devices?"
},
{
"specific": "What damages were awarded in the 2019 Epic v. Apple antitrust case?",
"stepback": "How are damages calculated in platform monopolization antitrust cases under Sherman Act Section 2?"
}
]
)
def build_domain_stepback_prompt(config: StepbackConfig) -> str:
"""Build a domain-specific stepback prompt from configuration."""
examples_text = "\n\n".join([
f'Specific: "{ex["specific"]}"\nStepback: "{ex["stepback"]}"'
for ex in config.examples
])
return f"""You are an expert at reformulating specific questions about {config.domain_description}
into broader questions that capture the underlying principle or concept.
The abstracted question should:
- Be at a {config.abstraction_level} level of abstraction
- Preserve the conceptual intent while removing hyper-specific details
- Match the language and framing used in {config.domain_description} reference material
- Be answerable from general reference documents, not just specific case studies
Examples:
{examples_text}
Return ONLY the stepback question, no explanation."""
Generating a good stepback query is only half the battle. How you combine the retrieval results from the original and stepback queries significantly affects both relevance and coverage.
You have three main strategies:
Retrieve k results for each query independently, then take the union (deduplicated). This is the simplest approach and works well when your corpus is large and there's meaningful semantic distance between the two queries.
from typing import Any
import chromadb
from chromadb.utils import embedding_functions
class StepbackRetriever:
def __init__(
self,
collection: chromadb.Collection,
openai_client,
stepback_config: Optional[StepbackConfig] = None,
k_original: int = 4,
k_stepback: int = 4
):
self.collection = collection
self.client = openai_client
self.stepback_config = stepback_config
self.k_original = k_original
self.k_stepback = k_stepback
if stepback_config:
self.system_prompt = build_domain_stepback_prompt(stepback_config)
else:
self.system_prompt = STEPBACK_SYSTEM_PROMPT
def retrieve(
self,
original_query: str,
return_stepback_query: bool = False
) -> dict[str, Any]:
"""
Perform stepback-enhanced retrieval.
Returns a dict with 'chunks', 'original_query', and optionally 'stepback_query'.
"""
# Step 1: Generate the stepback query
stepback_query = generate_stepback_query(
original_query,
self.client,
system_prompt_override=self.system_prompt
)
# Step 2: Retrieve for original query
original_results = self.collection.query(
query_texts=[original_query],
n_results=self.k_original,
include=["documents", "metadatas", "distances"]
)
# Step 3: Retrieve for stepback query
stepback_results = self.collection.query(
query_texts=[stepback_query],
n_results=self.k_stepback,
include=["documents", "metadatas", "distances"]
)
# Step 4: Merge and deduplicate
merged_chunks = self._merge_results(
original_results,
stepback_results,
original_query
)
output = {
"chunks": merged_chunks,
"original_query": original_query,
}
if return_stepback_query:
output["stepback_query"] = stepback_query
return output
def _merge_results(
self,
original_results: dict,
stepback_results: dict,
original_query: str
) -> list[dict]:
"""
Merge retrieval results, deduplicating by content hash.
Preserves source metadata so downstream steps know the provenance.
"""
seen_content = set()
merged = []
def process_results(results, source_label):
docs = results["documents"][0]
metas = results["metadatas"][0]
distances = results["distances"][0]
for doc, meta, distance in zip(docs, metas, distances):
# Deduplicate by first 200 chars (handles minor whitespace diffs)
content_key = doc[:200].strip()
if content_key not in seen_content:
seen_content.add(content_key)
merged.append({
"content": doc,
"metadata": meta,
"distance": distance,
"retrieval_source": source_label # Track provenance
})
# Original query results first (higher priority in ordering)
process_results(original_results, "original")
process_results(stepback_results, "stepback")
return merged
Note
Tracking retrieval_source on each chunk pays dividends during debugging. When your RAG answer is wrong, knowing whether the bad context came from the original query or the stepback query tells you exactly where to tune.
For higher-stakes applications, you can retrieve more candidates from both queries and then use a reranker to select the final context. This is particularly powerful because the reranker can score chunks against the original specific query, giving you the best of both worlds: broad retrieval coverage from the stepback, precision scoring from the reranker.
from sentence_transformers import CrossEncoder
class StepbackRetrieverWithReranking:
def __init__(
self,
collection: chromadb.Collection,
openai_client,
reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
k_candidates: int = 12, # Larger initial pool
k_final: int = 5 # Final chunks after reranking
):
self.collection = collection
self.client = openai_client
self.reranker = CrossEncoder(reranker_model)
self.k_candidates = k_candidates
self.k_final = k_final
def retrieve(self, original_query: str) -> list[dict]:
# Generate stepback query
stepback_query = generate_stepback_query(original_query, self.client)
# Retrieve candidates from both queries (larger k)
original_results = self.collection.query(
query_texts=[original_query],
n_results=self.k_candidates // 2,
include=["documents", "metadatas", "distances"]
)
stepback_results = self.collection.query(
query_texts=[stepback_query],
n_results=self.k_candidates // 2,
include=["documents", "metadatas", "distances"]
)
# Merge all candidates
all_candidates = self._deduplicated_merge(original_results, stepback_results)
# Rerank ALL candidates against the ORIGINAL specific query
# This is the key: broad retrieval, precise scoring
query_doc_pairs = [
(original_query, candidate["content"])
for candidate in all_candidates
]
rerank_scores = self.reranker.predict(query_doc_pairs)
# Add scores and sort
for candidate, score in zip(all_candidates, rerank_scores):
candidate["rerank_score"] = float(score)
reranked = sorted(all_candidates, key=lambda x: x["rerank_score"], reverse=True)
return reranked[:self.k_final]
def _deduplicated_merge(self, *result_sets) -> list[dict]:
seen = set()
merged = []
for results in result_sets:
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
key = doc[:200].strip()
if key not in seen:
seen.add(key)
merged.append({"content": doc, "metadata": meta, "distance": dist})
return merged
The reranking approach pairs naturally with what you'd learn in Reranking Retrieved Results: Implementing Cross-Encoders to Improve RAG Accuracy — stepback prompting effectively creates a larger, more diverse candidate pool for the reranker to work with, which often produces better results than either technique alone.
In some corpora — particularly those with strong hierarchical structure like legal codes, technical manuals, or medical guidelines — you can use the stepback query to first identify which section or document is relevant, then use the original query to retrieve specific passages within that section. This is especially powerful when combined with Parent Document Retrieval.
Let's now assemble everything into a production-ready RAG pipeline that incorporates stepback prompting end-to-end.
import openai
import chromadb
from typing import Optional
import json
import logging
logger = logging.getLogger(__name__)
RAG_ANSWER_PROMPT = """You are a precise, knowledgeable assistant. Answer the user's question
using ONLY the context provided below. If the context doesn't contain enough information to
answer confidently, say so explicitly rather than speculating.
Context retrieved for this query:
{context}
---
Original Question: {original_query}
Instructions:
- Base your answer on the provided context
- If context from general background (labeled [BACKGROUND]) and specific context
(labeled [SPECIFIC]) are both present, integrate them coherently
- Cite relevant details from the context to support your answer
- If the context is insufficient, clearly state what information is missing"""
class StepbackRAGPipeline:
"""
A complete RAG pipeline with stepback prompting for improved retrieval coverage.
"""
def __init__(
self,
collection: chromadb.Collection,
openai_api_key: str,
stepback_model: str = "gpt-4o-mini",
answer_model: str = "gpt-4o",
domain_config: Optional[StepbackConfig] = None,
use_reranking: bool = False,
k_original: int = 4,
k_stepback: int = 4,
):
self.collection = collection
self.client = openai.OpenAI(api_key=openai_api_key)
self.stepback_model = stepback_model
self.answer_model = answer_model
self.use_reranking = use_reranking
if use_reranking:
self.retriever = StepbackRetrieverWithReranking(
collection=collection,
openai_client=self.client,
k_candidates=k_original + k_stepback + 4,
k_final=k_original
)
else:
self.retriever = StepbackRetriever(
collection=collection,
openai_client=self.client,
stepback_config=domain_config,
k_original=k_original,
k_stepback=k_stepback
)
def query(
self,
user_query: str,
verbose: bool = False
) -> dict:
"""
Run the full stepback RAG pipeline.
Returns a dict with answer, stepback_query, and retrieved chunks.
"""
logger.info(f"Processing query: {user_query[:100]}...")
# Step 1: Retrieve with stepback
retrieval_output = self.retriever.retrieve(
original_query=user_query,
return_stepback_query=True
)
chunks = retrieval_output["chunks"]
stepback_query = retrieval_output.get("stepback_query", "N/A")
if verbose:
print(f"\n{'='*60}")
print(f"Original query: {user_query}")
print(f"Stepback query: {stepback_query}")
print(f"Chunks retrieved: {len(chunks)} total")
print(f" From original: {sum(1 for c in chunks if c.get('retrieval_source') == 'original')}")
print(f" From stepback: {sum(1 for c in chunks if c.get('retrieval_source') == 'stepback')}")
print(f"{'='*60}\n")
# Step 2: Format context with source labels
context_parts = []
for i, chunk in enumerate(chunks):
source = chunk.get("retrieval_source", "unknown")
label = "[SPECIFIC]" if source == "original" else "[BACKGROUND]"
doc_source = chunk["metadata"].get("source", f"Document {i+1}")
context_parts.append(
f"{label} Source: {doc_source}\n{chunk['content']}"
)
context_text = "\n\n---\n\n".join(context_parts)
# Step 3: Generate answer
answer_prompt = RAG_ANSWER_PROMPT.format(
context=context_text,
original_query=user_query
)
response = self.client.chat.completions.create(
model=self.answer_model,
messages=[
{"role": "user", "content": answer_prompt}
],
temperature=0.1
)
answer = response.choices[0].message.content
return {
"answer": answer,
"original_query": user_query,
"stepback_query": stepback_query,
"chunks_used": chunks,
"context_text": context_text
}
Warning
Don't blindly pass all merged chunks into your context window. With k_original=4 and k_stepback=4, you might have up to 8 chunks. At ~500 tokens per chunk, that's 4,000 tokens just for context — before your system prompt and answer. Always monitor token usage, especially if you're retrieving larger chunks. The Understanding LLM Context Windows article has a good framework for budgeting this.
In production, the stepback generation and both retrieval calls can happen in parallel (stepback generation and original retrieval can run concurrently since they don't depend on each other):
import asyncio
import openai
async def async_stepback_retrieve(
original_query: str,
async_client: openai.AsyncOpenAI,
collection: chromadb.Collection,
k: int = 4
) -> dict:
"""
Parallel implementation: generate stepback and retrieve original simultaneously.
The stepback retrieval runs after stepback generation completes.
"""
async def get_stepback_query():
response = await async_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": STEPBACK_SYSTEM_PROMPT},
{"role": "user", "content": f"Specific: {original_query}\nStepback:"}
],
temperature=0.0,
max_tokens=150
)
return response.choices[0].message.content.strip()
async def get_original_results():
# ChromaDB query is synchronous, run in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: collection.query(
query_texts=[original_query],
n_results=k,
include=["documents", "metadatas", "distances"]
)
)
# Run stepback generation and original retrieval concurrently
stepback_query, original_results = await asyncio.gather(
get_stepback_query(),
get_original_results()
)
# Now retrieve with stepback query (sequential, depends on above)
loop = asyncio.get_event_loop()
stepback_results = await loop.run_in_executor(
None,
lambda: collection.query(
query_texts=[stepback_query],
n_results=k,
include=["documents", "metadatas", "distances"]
)
)
return {
"original_query": original_query,
"stepback_query": stepback_query,
"original_results": original_results,
"stepback_results": stepback_results
}
This parallel approach typically reduces end-to-end latency by 30-40% compared to sequential execution, since the LLM call for stepback generation (usually 300-500ms) overlaps with your first retrieval call.
Let's build something real: a research assistant for scientific papers that uses stepback prompting to handle the kind of hyper-specific questions researchers actually ask.
import chromadb
from chromadb.utils import embedding_functions
import openai
# Initialize
client = openai.OpenAI(api_key="your-key-here")
chroma_client = chromadb.Client()
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key="your-key-here",
model_name="text-embedding-3-small"
)
collection = chroma_client.create_collection(
name="research_papers",
embedding_function=openai_ef
)
# Sample corpus — realistic paper abstracts and methodology sections
# In practice, use your document ingestion pipeline to load actual PDFs
paper_chunks = [
{
"id": "doc_001",
"content": """Transformer architecture relies on self-attention mechanisms that compute
relationships between all token pairs in a sequence. The attention score between tokens
i and j is computed as the dot product of query and key vectors, scaled by the square
root of the key dimension. This scaling prevents vanishing gradients in deep networks
with high-dimensional representations.""",
"metadata": {"source": "Attention Is All You Need", "section": "architecture", "year": 2017}
},
{
"id": "doc_002",
"content": """BERT (Bidirectional Encoder Representations from Transformers) introduced
the masked language modeling pretraining objective, where 15% of input tokens are masked
and the model learns to predict them. This bidirectional pretraining allows BERT to
capture both left and right context simultaneously, unlike autoregressive language models
which only attend to prior tokens.""",
"metadata": {"source": "BERT paper", "section": "pretraining", "year": 2018}
},
{
"id": "doc_003",
"content": """Fine-tuning pretrained language models on downstream tasks typically requires
updating all model parameters, which is computationally expensive for large models.
Parameter-efficient fine-tuning methods like LoRA (Low-Rank Adaptation) address this by
decomposing weight updates into low-rank matrices, reducing trainable parameters by
10,000x while maintaining competitive performance on most NLP benchmarks.""",
"metadata": {"source": "LoRA paper", "section": "methodology", "year": 2021}
},
{
"id": "doc_004",
"content": """Retrieval-augmented generation addresses the knowledge cutoff limitation
of parametric language models by conditioning generation on retrieved documents.
The retrieval component typically uses a bi-encoder architecture for efficiency,
while generation uses a sequence-to-sequence model conditioned on both the query
and retrieved passages.""",
"metadata": {"source": "RAG paper", "section": "overview", "year": 2020}
},
{
"id": "doc_005",
"content": """Chain-of-thought prompting improves the reasoning capabilities of large
language models by including intermediate reasoning steps in the prompt. Studies show
that this technique is most effective for models with over 100 billion parameters,
suggesting that the ability to leverage chain-of-thought emerges as an emergent
capability at sufficient model scale.""",
"metadata": {"source": "Chain-of-Thought paper", "section": "findings", "year": 2022}
},
{
"id": "doc_006",
"content": """The scaling laws for neural language models demonstrate that model
performance follows a power law relationship with compute budget, dataset size, and
parameter count. Optimal allocation of a fixed compute budget should distribute
resources equally between model size and training tokens, a finding later refined
by the Chinchilla scaling laws which suggested prior large models were undertrained.""",
"metadata": {"source": "Scaling Laws paper", "section": "results", "year": 2020}
}
]
# Add to collection
collection.add(
ids=[chunk["id"] for chunk in paper_chunks],
documents=[chunk["content"] for chunk in paper_chunks],
metadatas=[chunk["metadata"] for chunk in paper_chunks]
)
print(f"Indexed {len(paper_chunks)} chunks")
Now let's directly compare standard retrieval vs. stepback-enhanced retrieval:
from research_config import RESEARCH_STEPBACK_CONFIG # Use domain config from earlier
# Initialize pipeline
pipeline = StepbackRAGPipeline(
collection=collection,
openai_api_key="your-key-here",
stepback_model="gpt-4o-mini",
answer_model="gpt-4o",
k_original=3,
k_stepback=3
)
# Test queries: hyper-specific questions that benefit from stepback
test_queries = [
"What percentage of tokens does BERT mask during pretraining and why that specific amount?",
"What was the rank used in LoRA experiments to reduce parameters by 10,000x?",
"Does chain-of-thought prompting work on GPT-2?", # Implied no — requires understanding scale
]
for query in test_queries:
print(f"\n{'='*70}")
result = pipeline.query(query, verbose=True)
print(f"\nANSWER:\n{result['answer']}")
print(f"\nChunks used: {[c['metadata']['source'] for c in result['chunks_used']]}")
Run the above with verbose=True and pay attention to:
Stepback quality: Does the generated stepback question capture the right abstraction level? For "Does chain-of-thought work on GPT-2?", a good stepback might be "What are the model scale requirements for chain-of-thought prompting to be effective?"
Retrieval coverage: Did the stepback query retrieve the scaling laws document (doc_006) that's relevant to understanding why chain-of-thought doesn't work on small models? Standard retrieval might miss this connection.
Answer completeness: The final answer should integrate both the specific context (chain-of-thought findings) and the background context (scaling requirements) to give a complete, principled response.
Stepback prompting doesn't exist in isolation — it's one of several query transformation techniques. Knowing when to use each is important.
| Technique | Mechanism | Best For | Limitation |
|---|---|---|---|
| Stepback Prompting | Abstract query up one level of generality | Queries too specific for document language | Adds LLM latency; may miss very specific facts |
| Query Expansion (HyDE) | Generate a hypothetical document | Factual lookup questions | Can hallucinate misleading expansions |
| Multi-Query Retrieval | Generate N query variations | Ambiguous or multi-faceted questions | High latency; context bloat |
| Query Routing | Route to different data sources | Multi-corpus environments | Requires pre-defined routing logic |
For maximum coverage, you can combine these. Stepback prompting pairs particularly well with Query Expansion techniques like HyDE: use HyDE for the original specific query (to find the exact fact) and stepback for the abstracted query (to retrieve foundational context).
Similarly, if your system needs to decide which retrieval strategy to apply dynamically, that's where Query Routing in RAG becomes relevant — you can route queries that are "too specific" (as detected by a classifier) to the stepback-enhanced path automatically.
Don't assume stepback prompting helps. Measure it. Here's a practical evaluation framework:
from dataclasses import dataclass
import json
@dataclass
class RetrievalEvalSample:
query: str
relevant_doc_ids: list[str] # Ground truth relevant documents
notes: str = ""
def evaluate_stepback_impact(
collection: chromadb.Collection,
openai_client,
eval_samples: list[RetrievalEvalSample],
k: int = 5
) -> dict:
"""
Compare retrieval recall with and without stepback prompting.
"""
retriever_standard = StepbackRetriever(
collection=collection,
openai_client=openai_client,
k_original=k,
k_stepback=0 # Disable stepback for baseline
)
retriever_stepback = StepbackRetriever(
collection=collection,
openai_client=openai_client,
k_original=k // 2,
k_stepback=k // 2
)
results = []
for sample in eval_samples:
# Standard retrieval
standard_results = retriever_standard.retrieve(sample.query)
standard_ids = [
c["metadata"].get("id", "") for c in standard_results["chunks"]
]
standard_recall = len(
set(standard_ids) & set(sample.relevant_doc_ids)
) / len(sample.relevant_doc_ids)
# Stepback retrieval
stepback_results = retriever_stepback.retrieve(
sample.query, return_stepback_query=True
)
stepback_ids = [
c["metadata"].get("id", "") for c in stepback_results["chunks"]
]
stepback_recall = len(
set(stepback_ids) & set(sample.relevant_doc_ids)
) / len(sample.relevant_doc_ids)
results.append({
"query": sample.query,
"stepback_query": stepback_results.get("stepback_query"),
"standard_recall": standard_recall,
"stepback_recall": stepback_recall,
"improvement": stepback_recall - standard_recall,
"notes": sample.notes
})
# Aggregate
avg_standard = sum(r["standard_recall"] for r in results) / len(results)
avg_stepback = sum(r["stepback_recall"] for r in results) / len(results)
helped = sum(1 for r in results if r["improvement"] > 0)
hurt = sum(1 for r in results if r["improvement"] < 0)
neutral = sum(1 for r in results if r["improvement"] == 0)
print(f"\nEvaluation Results (n={len(results)} queries):")
print(f"Average recall — Standard: {avg_standard:.3f}, Stepback: {avg_stepback:.3f}")
print(f"Improvement: +{avg_stepback - avg_standard:.3f}")
print(f"Helped: {helped} | Hurt: {hurt} | Neutral: {neutral}")
# Show cases where stepback hurt
if hurt > 0:
print(f"\nCases where stepback hurt retrieval:")
for r in results:
if r["improvement"] < 0:
print(f" Query: {r['query'][:80]}")
print(f" Stepback: {r['stepback_query'][:80]}")
print(f" Recall: {r['standard_recall']:.2f} -> {r['stepback_recall']:.2f}")
return {"per_query": results, "aggregate": {
"avg_standard_recall": avg_standard,
"avg_stepback_recall": avg_stepback,
"helped": helped, "hurt": hurt, "neutral": neutral
}}
Key insight
Stepback prompting tends to hurt retrieval precision when the original query is already well-matched to document language (like keyword-heavy technical queries against API docs), and tends to help significantly when there's a vocabulary gap between how users ask questions and how documents are written. Always run this evaluation on 50+ representative queries before deploying stepback prompting to production.
For a deeper treatment of RAG evaluation metrics beyond just recall, including faithfulness and answer relevance, see Evaluating RAG Systems: Precision, Recall, and Faithfulness.
Symptom: Stepback query becomes something like "What are machine learning techniques?" — so broad it retrieves everything and provides no signal.
Cause: Insufficient few-shot examples, or the model isn't calibrated for your domain.
Fix: Add more domain-specific examples that demonstrate the right level of abstraction. The stepback should go up one level of generality, not all the way to the root concept. Also add this to your prompt: "The stepback question should still be specific enough to narrow retrieval to a relevant subdomain."
Symptom: Original: "What was the learning rate in the GPT-3 paper?" → Stepback: "What learning rate was used in GPT-3 pretraining?" — essentially the same query with slightly different phrasing.
Cause: The model is doing paraphrase rather than abstraction.
Fix: Make the abstraction instruction more explicit. Add to your prompt: "The stepback question must operate at a more general, conceptual level — it should be answerable without knowing the specific case the original question refers to." And add examples that show bigger jumps in abstraction level.
Symptom: The final answer correctly explains the general concept but misses the specific detail the user was asking about.
Cause: Too many stepback chunks relative to original query chunks, or the LLM anchor-biases toward the background context.
Fix: Either (a) reduce k_stepback relative to k_original, (b) use the [SPECIFIC] / [BACKGROUND] labels in your answer prompt and explicitly instruct the model to prioritize specific context for factual questions, or (c) add a reranking step that scores final candidates against the original query.
Symptom: Occasionally the stepback generation returns something malformed, empty, or identical to the input.
Fix: Add validation:
def generate_stepback_query_safe(
original_query: str,
client,
fallback_to_original: bool = True
) -> tuple[str, bool]:
"""Returns (stepback_query, is_valid)."""
try:
stepback = generate_stepback_query(original_query, client)
# Validation checks
if not stepback or len(stepback.strip()) < 10:
logger.warning("Stepback generation returned empty/short result")
return (original_query if fallback_to_original else "", False)
# Check if it's basically the same as the original
original_words = set(original_query.lower().split())
stepback_words = set(stepback.lower().split())
overlap = len(original_words & stepback_words) / len(original_words)
if overlap > 0.85: # 85% word overlap = basically the same
logger.warning(f"Stepback too similar to original (overlap={overlap:.2f})")
return (original_query if fallback_to_original else "", False)
return (stepback, True)
except Exception as e:
logger.error(f"Stepback generation failed: {e}")
return (original_query if fallback_to_original else "", False)
Symptom: You apply stepback universally and see it consistently hurt performance on certain query categories.
Cause: Stepback prompting is genuinely not helpful for (a) simple factual lookups where the query language matches document language, (b) queries that are already at a general/conceptual level, and (c) navigation queries like "show me section 4.2 of the manual."
Fix: Build a lightweight query classifier that routes queries to stepback-enhanced vs. standard retrieval. This is the pattern described in Adaptive Retrieval: Dynamically Adjusting Chunk Count and Search Strategy.
Stepback prompting solves a real and common failure mode: the specificity mismatch between how users ask questions and how knowledge gets documented. The technique is elegant in its simplicity — one additional LLM call that generates a more general version of the query, which you then use alongside the original for a broader retrieval sweep.
Here's what you've built and learned:
Natural extensions to explore:
Combine with hybrid search: Stepback queries work particularly well with semantic search but you might want keyword search for the original query. The Hybrid Search article covers combining these signals, and you can fuse results using Reciprocal Rank Fusion.
Use stepback in agentic loops: In an agentic RAG system that can query-reflect-retry, stepback prompting can be triggered automatically when the initial retrieval scores below a confidence threshold. See Agentic RAG: Building Self-Correcting Retrieval Pipelines for the broader pattern.
Combine with contextual compression: After you retrieve the broader pool of chunks via stepback, use contextual compression to trim the stepback chunks down to only the portions actually relevant to the specific question before passing everything to the LLM. Contextual Compression in RAG covers this in detail.
Stepback prompting is a 30-minute implementation that can meaningfully improve the breadth of your RAG system's retrieval. The biggest payoff comes in domains with formal, structured documentation — clinical literature, legal texts, technical standards — where the vocabulary gap between how users ask questions and how answers are written is consistently wide.