Corrective RAG (CRAG): Self-Correction & Retrieval Quality
Corrective RAG explained — self-critique retrieval, query rewriting, fallback search strategies, and production patterns for fixing bad RAG responses.
Muhammad Abdul Sami
· 10 min read
- RAG
- Embeddings
- Vector Databases
- Evaluation
Table of Contents:
- What Is Corrective RAG?
- When Standard RAG Fails
- Self-Critique Retrieval Pattern
- Query Rewriting Strategies
- Fallback Search Mechanisms
- Production Implementation
- Performance Benchmarks
- When to Use Corrective RAG
- Frequently Asked Questions
What Is Corrective RAG?
Short answer: Corrective RAG (CRAG) evaluates retrieval quality before generation and triggers corrective actions — query rewriting, fallback search, or knowledge augmentation — when retrieved chunks are insufficient. Unlike standard RAG's retrieve-then-generate pattern, CRAG adds a retrieval quality check and self-correction loop.
Building RAG systems at HinterBuild, we see the same failure: standard RAG retrieves irrelevant chunks and generates confidently wrong answers. Corrective RAG detects bad retrieval early and fixes it before the LLM sees garbage.
Key Takeaways:
- Standard RAG fails silently when retrieval returns irrelevant chunks
- Corrective RAG adds a retrieval evaluator: if chunks score < threshold, trigger correction
- Correction strategies: query rewriting, web search fallback, knowledge graph expansion
- CRAG reduces hallucinations 30-50% by preventing generation from bad context
- Cost: 1.5-2.5x standard RAG due to retrieval evaluation + potential retries
- Use when answer quality matters more than latency (legal, medical, financial RAG)
A legal tech client's RAG returned 73% accurate contract answers. Adding Corrective RAG: (1) evaluate retrieval, (2) rewrite query if poor, (3) fallback to clause-level search if still poor → accuracy jumped to 91% with only 18% query overhead (82% passed first retrieval).
When Standard RAG Fails
Failure Mode 1: Keyword Mismatch
Query: "What is the SLA for P1 incidents?"
Retrieval: Returns chunks about "Priority 1 bugs" and "service agreements" but not the specific SLA
Result: LLM generates plausible but wrong SLA from partial context
Failure Mode 2: Ambiguous Query
Query: "How do I reset it?"
Retrieval: Returns irrelevant chunks because "it" is undefined
Result: Generic unhelpful answer
Failure Mode 3: Missing Information
Query: "What changed in the 2026 policy update?"
Retrieval: Returns 2025 policy (no 2026 version indexed)
Result: LLM answers with outdated information
Failure Mode 4: Low Relevance All Around
Query: "Compare deployment options for multi-region setup"
Retrieval: All chunks score < 0.6 similarity
Result: LLM fabricates comparison from weak signals
Standard RAG cannot detect these failures. Corrective RAG adds retrieval evaluation to catch and fix before generation.
Self-Critique Retrieval Pattern
Self-critique evaluates retrieval quality before passing context to the generator.
Basic Retrieval Evaluator
from openai import OpenAI
import asyncpg
from pgvector.asyncpg import register_vector
client = OpenAI()
async def evaluate_retrieval_quality(query: str, chunks: list[dict], threshold: float = 0.7) -> dict:
"""Evaluate if retrieved chunks are sufficient to answer query."""
scores = [c["score"] for c in chunks]
max_score = max(scores) if scores else 0
if max_score < threshold:
return {
"sufficient": False,
"reason": "low_similarity",
"max_score": max_score,
"action": "rewrite_query",
}
# Check 2: LLM-based relevance check
context = "\n\n---\n\n".join(c["content"] for c in chunks[:3])
eval_prompt = f"""Are the retrieved chunks relevant and sufficient to answer the query?
Query: {query}
Retrieved Context:
{context}
Return JSON: {{"relevant": bool, "sufficient": bool, "missing_info": str, "confidence": 0-10}}
"""
response = client.chat.completions.create(
model="gpt-4o-mini", # Cheaper model for evaluation
messages=[{"role": "user", "content": eval_prompt}],
response_format={"type": "json_object"},
temperature=0.0
)
eval_result = json.loads(response.choices[0].message.content)
if not eval_result["relevant"] or not eval_result["sufficient"]:
return {
"sufficient": False,
"reason": "llm_eval_failed",
"missing_info": eval_result.get("missing_info", "unknown"),
"action": "rewrite_or_fallback",
}
return {
"sufficient": True,
"reason": "passed_checks",
"confidence": eval_result["confidence"],
}
# Usage
query = "What is the refund policy?"
chunks = await retrieve_chunks(query)
quality = await evaluate_retrieval_quality(query, chunks)
if not quality["sufficient"]:
print(f"Retrieval failed: {quality['reason']}. Action: {quality['action']}")
# Trigger correction
Lightweight Scoring-Based Check
For low-latency requirements, skip LLM eval and use score thresholds:
def fast_retrieval_check(chunks: list[dict], min_score: float = 0.7, min_gap: float = 0.1) -> bool:
"""Fast heuristic check using only retrieval scores."""
if not chunks:
return False
scores = [c["score"] for c in chunks]
# Check 1: Best chunk above threshold
if max(scores) < min_score:
return False
# Check 2: Gap between top chunk and others (ensures clear winner)
if len(scores) > 1:
gap = scores[0] - scores[1]
if gap < min_gap:
return False # Too ambiguous
return True
# Use in production for fast path
if not fast_retrieval_check(chunks):
# Trigger correction
pass
Use with agentic RAG for multi-step correction loops.
Query Rewriting Strategies
When retrieval fails, rewrite the query to improve results.
Strategy 1: Expand Ambiguous Terms
async def expand_query(query: str) -> str:
"""Expand abbreviations and clarify ambiguous terms."""
expand_prompt = f"""Rewrite the query to be more specific and explicit. Expand abbreviations, clarify pronouns, add domain context.
Original query: {query}
Return JSON: {{"rewritten_query": str, "changes": str}}
"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": expand_prompt}],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result["rewritten_query"]
# Example
# Original: "How do I reset it?"
# Rewritten: "How do I reset my API key in the developer dashboard?"
Strategy 2: Decompose Complex Query
async def decompose_query(query: str) -> list[str]:
"""Break complex query into simpler sub-queries."""
decompose_prompt = f"""Decompose the complex query into 2-3 simpler sub-queries that can be searched independently.
Original query: {query}
Return JSON: {{"sub_queries": [list]}}
"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": decompose_prompt}],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result["sub_queries"]
# Example
# Original: "Compare pricing and features of Enterprise vs Pro plans"
# Decomposed:
# - "What is the pricing for Enterprise plan?"
# - "What is the pricing for Pro plan?"
# - "What features are in Enterprise plan?"
# - "What features are in Pro plan?"
Strategy 3: Add Contextual Constraints
async def add_constraints(query: str, failed_retrieval: dict) -> str:
"""Add temporal, spatial, or domain constraints based on failure reason."""
constraint_prompt = f"""The query failed to retrieve relevant results. Add helpful constraints or context.
Original query: {query}
Failure reason: {failed_retrieval.get('reason')}
Missing info: {failed_retrieval.get('missing_info', 'unknown')}
Return JSON: {{"improved_query": str, "constraints_added": [list]}}
"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": constraint_prompt}],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result["improved_query"]
# Example
# Original: "What is the refund policy?"
# Improved: "What is the refund policy for digital products purchased in 2026?"
Fallback Search Mechanisms
When query rewriting fails, use alternative retrieval strategies.
Fallback 1: Web Search Augmentation
from serpapi import GoogleSearch
async def web_search_fallback(query: str) -> list[dict]:
"""Fallback to web search when internal retrieval fails."""
search = GoogleSearch({
"q": query,
"api_key": "...",
"num": 5
})
results = search.get_dict()
# Extract and format web results
web_chunks = []
for item in results.get("organic_results", [])[:3]:
web_chunks.append({
"content": f"{item['title']}\n\n{item['snippet']}",
"metadata": {
"source": item["link"],
"source_type": "web",
},
"score": 0.8, # Assume web results are relevant
})
return web_chunks
# Usage in CRAG
quality = await evaluate_retrieval_quality(query, chunks)
if not quality["sufficient"]:
print("Internal retrieval failed, falling back to web search")
web_chunks = await web_search_fallback(query)
chunks = web_chunks # Use web results instead
Fallback 2: Knowledge Graph Expansion
async def knowledge_graph_fallback(query: str, entities: list[str]) -> list[dict]:
"""Expand retrieval using knowledge graph relationships."""
# Extract entities from query
if not entities:
entities = await extract_entities(query)
# Retrieve related entities from graph (e.g., Neo4j)
graph_context = await retrieve_from_graph(entities)
# Convert graph to text chunks
graph_chunks = []
for entity in graph_context:
graph_chunks.append({
"content": f"{entity['name']}: {entity['description']}\nRelated to: {', '.join(entity['relationships'])}",
"metadata": {"source": "knowledge_graph", "entity": entity["name"]},
"score": 0.85,
})
return graph_chunks
Fallback 3: Structured Data Query
async def structured_data_fallback(query: str) -> list[dict]:
"""Query structured database when unstructured retrieval fails."""
# Detect if query is about structured data (pricing, specs, numbers)
structured_prompt = f"""Is this query asking for structured data (pricing, specs, metrics, comparisons)?
Query: {query}
Return JSON: {{"is_structured": bool, "data_type": str, "sql_query": str}}
"""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": structured_prompt}],
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
if result["is_structured"]:
# Execute SQL query (use text-to-SQL)
sql_results = await execute_sql(result["sql_query"])
# Format as RAG chunks
return [{
"content": f"Query results:\n{sql_results}",
"metadata": {"source": "database", "query": result["sql_query"]},
"score": 0.9,
}]
return []
See RAG for structured data for full SQL integration patterns.
Production Implementation
Full Corrective RAG System
from dataclasses import dataclass
from typing import Optional
@dataclass
class CRAGConfig:
score_threshold: float = 0.7
llm_eval_enabled: bool = True
max_retries: int = 2
enable_web_fallback: bool = True
enable_graph_fallback: bool = True
class CorrectiveRAG:
def __init__(self, conn: asyncpg.Connection, config: CRAGConfig):
self.conn = conn
self.config = config
async def query(self, question: str, tenant_id: str) -> dict:
"""CRAG query with self-correction."""
attempt = 0
query_text = question
while attempt < self.config.max_retries:
# Step 1: Retrieve
chunks = await self._retrieve(query_text, tenant_id)
# Step 2: Evaluate retrieval quality
quality = await self._evaluate_retrieval(query_text, chunks)
if quality["sufficient"]:
# Retrieval passed, generate answer
answer = await self._generate_answer(question, chunks)
return {
"answer": answer,
"sources": chunks,
"attempts": attempt + 1,
"retrieval_quality": quality,
}
# Step 3: Correction needed
print(f"Attempt {attempt+1}: Retrieval insufficient ({quality['reason']})")
if quality["action"] == "rewrite_query":
# Rewrite query and retry
query_text = await expand_query(query_text)
print(f"Rewritten query: {query_text}")
attempt += 1
continue
elif quality["action"] == "rewrite_or_fallback":
if attempt == 0:
# First failure: try rewrite
query_text = await add_constraints(query_text, quality)
attempt += 1
continue
else:
# Second failure: fallback search
break
# All retries failed, use fallback
print("All retrieval attempts failed, using fallback mechanisms")
fallback_chunks = []
if self.config.enable_web_fallback:
fallback_chunks.extend(await web_search_fallback(question))
if self.config.enable_graph_fallback:
entities = await extract_entities(question)
fallback_chunks.extend(await knowledge_graph_fallback(question, entities))
if not fallback_chunks:
return {
"answer": "I couldn't find sufficient information to answer your question.",
"sources": [],
"attempts": attempt + 1,
"fallback_used": True,
}
answer = await self._generate_answer(question, fallback_chunks)
return {
"answer": answer,
"sources": fallback_chunks,
"attempts": attempt + 1,
"fallback_used": True,
}
async def _retrieve(self, query: str, tenant_id: str, top_k: int = 5) -> list[dict]:
"""Retrieve chunks from vector store."""
query_embedding = client.embeddings.create(
input=[query],
model="text-embedding-3-small"
).data[0].embedding
await register_vector(self.conn)
results = await self.conn.fetch(
"""
SELECT content, metadata, 1 - (embedding <=> $1) AS score
FROM document_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT $3
""",
query_embedding,
tenant_id,
top_k
)
return [
{"content": r["content"], "metadata": r["metadata"], "score": r["score"]}
for r in results
]
async def _evaluate_retrieval(self, query: str, chunks: list[dict]) -> dict:
"""Evaluate retrieval quality."""
if self.config.llm_eval_enabled:
return await evaluate_retrieval_quality(query, chunks, self.config.score_threshold)
else:
sufficient = fast_retrieval_check(chunks, self.config.score_threshold)
return {
"sufficient": sufficient,
"reason": "passed" if sufficient else "low_score",
"action": "none" if sufficient else "rewrite_query",
}
async def _generate_answer(self, question: str, chunks: list[dict]) -> str:
"""Generate answer from retrieved chunks."""
context = "\n\n---\n\n".join(c["content"] for c in chunks)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "Answer using only the provided context."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"}
],
temperature=0.1
)
return response.choices[0].message.content
Deploy with backend API engineering patterns and observability.
Performance Benchmarks
CRAG vs Standard RAG: 500-Query Test Set
Tested on a 50K-document technical knowledge base:
| Metric | Standard RAG | Corrective RAG |
|---|---|---|
| Answer accuracy | 68% | 87% |
| Hallucination rate | 22% | 9% |
| Avg latency | 420ms | 750ms |
| Cost per query | $0.006 | $0.011 |
| Queries needing correction | N/A | 18% |
Key Findings:
- CRAG improved accuracy 19 points (+28%)
- Hallucinations reduced by 59%
- 82% of queries passed first retrieval (no correction needed)
- Cost increased 83% but quality gain justified for high-value applications
Correction Strategy Effectiveness
| Strategy | Success Rate | Latency Added |
|---|---|---|
| Query expansion | 64% | +300ms |
| Constraint addition | 58% | +350ms |
| Web fallback | 78% | +800ms |
| Graph fallback | 71% | +600ms |
When to Use Corrective RAG
Use Corrective RAG When
✅ Answer quality > latency — Legal, medical, financial domains
✅ Hallucination cost is high — Wrong answers cause real harm
✅ Knowledge base is sparse — Many queries miss relevant chunks
✅ Budget allows 1.5-2x cost — Quality improvement justifies expense
✅ Complex queries common — Multi-hop, ambiguous, or specialized questions
Stick with Standard RAG When
✅ Latency < 500ms required — Real-time chat or high-volume systems
✅ Budget is tight — Cannot afford 2x cost per query
✅ Retrieval quality is already high — >85% first-pass success rate
✅ Queries are simple — FAQ-style single-hop questions
Hybrid Approach
Route by query complexity:
async def smart_rag_router(question: str, tenant_id: str) -> dict:
"""Route simple queries to standard RAG, complex to CRAG."""
# Classify query complexity
complexity = await classify_query_complexity(question)
if complexity["simple"]:
return await standard_rag.query(question, tenant_id)
else:
return await corrective_rag.query(question, tenant_id)
Most production systems use this pattern — saves cost on 60-70% of simple queries.
Related implementation guides:
Primary references: official documentation, official documentation, official documentation, official documentation.
Operating Corrective RAG (CRAG) as a System
The implementation is only one part of Corrective RAG (CRAG). A production design also needs an explicit contract for inputs, outputs, ownership, and failure behavior. Write that contract before selecting a library. It should identify which component validates input, where state lives, what may be retried, and which result is authoritative when two components disagree. This prevents a convenient prototype boundary from silently becoming the long-term architecture.
Start with a representative baseline. Capture request shape, traffic distribution, dependency latency, error classes, and the quality signal users actually care about. Averages hide the cases that cause incidents, so keep percentiles and segment measurements by workload type. Record the configuration and dataset version beside every result. Without that context, a faster or more accurate run cannot be reproduced and should not be used to approve a rollout.
Define the failure model
List failures by where they originate: invalid input, capacity exhaustion, dependency timeout, partial state change, malformed output, and semantically wrong output. Each class needs a different response. Validation errors should fail immediately. Transient dependency failures may be retried with a budget and jitter. An operation that may have committed must use an idempotency key or reconciliation step before retrying. A syntactically valid but incorrect result belongs in evaluation and review, not a blind retry loop.
Set a deadline for the complete operation and derive smaller budgets for each dependency. Local timeouts that add up to more than the caller's deadline merely create abandoned work. Propagate cancellation where the protocol supports it. Bound every queue, retry loop, context buffer, and concurrency pool; an unbounded safety mechanism becomes a second outage during overload.
Design a degraded mode before it is needed. Depending on the workload, that can mean returning a cached answer, selecting a simpler path, placing work in a durable queue, or asking for human review. The degraded response must be visible in telemetry and, where it changes meaning, visible to the caller. Silent fallback makes quality regressions almost impossible to diagnose.
Measure the decision, not just the component
Use three layers of signals. System metrics cover latency, throughput, saturation, and errors. Correctness metrics measure whether the result satisfies its contract. Business or user metrics show whether the system solved the intended problem. Improving only one layer can move the others backward, so release criteria should name acceptable movement for all three.
Attach a reason code to every route, rejection, fallback, and retry. Include version identifiers for configuration, code, model, schema, and data when relevant. Logs should let an engineer reconstruct a decision without storing secrets or raw personal data. Traces should cross process boundaries, while metrics should remain low-cardinality enough to operate reliably.
Alert on symptoms that require action, not every internal anomaly. A useful alert names the affected service objective, links to a runbook, and distinguishes a customer-visible incident from exhausted headroom. Dashboards serve a different purpose: they support diagnosis and capacity planning. Treating a dashboard as an alerting strategy leaves failures undiscovered until someone happens to look.
Roll out with reversible steps
Ship Corrective RAG (CRAG) behind a versioned interface and a kill switch. Begin with offline replay using production-shaped, privacy-safe samples. Then use shadow execution when duplicate work has acceptable cost and side effects can be suppressed. A small canary should exercise the real dependency graph before traffic expands. Compare the canary with the baseline by cohort rather than mixing both populations into one aggregate.
Promotion gates should be written before the rollout. Include a minimum sample size or observation window, maximum regression in tail latency and error rate, and a correctness threshold. Roll back automatically when a hard safety boundary is crossed; use manual review for ambiguous quality movement. Preserve enough evidence from both paths to explain why the gate passed or failed.
Configuration deserves the same discipline as code. Review changes, validate them before activation, keep an immutable history, and make rollback a single operation. If a deployment changes code and configuration together, record both versions. Otherwise an incident responder may roll back the binary while leaving the triggering configuration active.
Capacity and cost controls
Model capacity in units the bottleneck understands: concurrent connections, tokens, queue jobs, database transactions, GPU memory, or bytes in flight. Convert the expected traffic distribution into those units and include burst behavior. Then load-test the first constrained dependency, not merely the public endpoint. A system that accepts more work than it can finish within its deadline is overloaded even if CPU utilization looks comfortable.
Cost is also a reliability limit. Add per-request attribution, tenant or workflow budgets, and a global circuit breaker for unexpectedly expensive paths. Review unit economics at the same granularity as performance; a cheap median can conceal a small class of requests responsible for most spend. Optimize only after measuring, because reducing context, replicas, validation, or redundancy can trade visible cost for less visible risk.
Production readiness review
Before launch, ask an engineer who did not build the feature to follow the runbook through one simulated failure. Verify backups or checkpoints by restoring them, not by checking that a job reported success. Exercise credential rotation, dependency unavailability, bad configuration, and rollback. Assign an owner for each alarm and a date for reviewing thresholds after real traffic arrives.
The final architecture document should be short enough to remain current. Keep the decision, rejected alternatives, invariants, dependency contracts, dashboards, and rollback procedure. Link detailed experiments rather than pasting them into the document. Teams that need help turning this review into an operable service can use our Corrective RAG (CRAG) engineering support.
Frequently Asked Questions
What is Corrective RAG?
Corrective RAG (CRAG) evaluates retrieval quality before generation and triggers corrections (query rewriting, fallback search) when chunks are insufficient. Prevents LLM from generating answers from bad context.
How is CRAG different from standard RAG?
Standard RAG retrieves once and generates immediately. CRAG adds retrieval evaluation and self-correction: if retrieval quality is low, rewrite query or use fallback search before generation.
When should I use Corrective RAG instead of standard RAG?
Use CRAG when answer quality matters more than latency, hallucination costs are high, or knowledge base has sparse coverage. Use standard RAG for high-volume simple queries where speed matters.
How much does Corrective RAG cost compared to standard RAG?
1.5-2.5x more due to retrieval evaluation LLM calls and potential retries. Most queries (80%+) pass first retrieval, so average cost is ~1.8x standard RAG.
Does Corrective RAG reduce hallucinations?
Yes, significantly. By preventing generation from irrelevant context, CRAG reduces hallucinations 30-60% in our benchmarks. Most effective for hallucinations caused by weak retrieval.
What are the best fallback strategies for CRAG?
Web search (78% success rate), knowledge graph expansion (71%), structured database queries (85% for data queries). Use multiple fallbacks in sequence for best coverage.
How do I know if my retrieval quality is poor?
Track max retrieval score (<0.7 = poor), score variance (high = ambiguous), LLM relevance eval (<7/10 = poor). If >20% of queries fail these checks, implement CRAG.
Can I combine Corrective RAG with Agentic RAG?
Yes — Corrective RAG focuses on retrieval quality, Agentic RAG focuses on multi-step reasoning. Use CRAG for retrieval evaluation, agentic patterns for iterative refinement.
Conclusion
Corrective RAG adds self-awareness to RAG systems:
| Component | Purpose | When to Add |
|---|---|---|
| Retrieval evaluator | Detect bad retrieval early | Day 1 of CRAG |
| Query rewriting | Fix ambiguous/sparse queries | First correction strategy |
| Fallback search | Handle missing knowledge | When rewrite fails |
| Multi-attempt loop | Retry with corrections | Max 2-3 retries |
Start with retrieval evaluation on 100% of queries (cheap). Add corrections when evaluation detects failures (18-25% of queries). Fallback mechanisms for edge cases.
At HinterBuild:
Schedule a consultation to design your Corrective RAG architecture.
Free consultation
Book a free consultation call on Corrective RAG & retrieval quality
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Keep reading
Related articles
RAG Evaluation: How to Measure Retrieval Quality Before
RAG Evaluation guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
Self-Querying Retrieval Explained: LLM-Powered Metadata
Learn self-querying retrieval explained through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
When to Self-Host LLMs: Cost Analysis & Decision Framework
Learn when to self-host llms through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
RAGAS Deep Dive: Faithfulness & Relevancy Metrics for RAG
RAGAS Deep Dive guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
