Corrective RAG
Naive RAG answers no matter what it retrieved. This graph inserts a judge between retrieval and generation: if the context can't answer the question, the pipeline refuses — cheaply, honestly, and identically every time.
The graph
Implemented in services/rag_graph_service.py as a LangGraph StateGraph compiled once at import (module page); exposed via POST /agents/{id}/documents/ask.
| Node | LLM? | Does |
|---|---|---|
retrieve_node | embedding only | agent-scoped search; produces retrieved_chunks + slim sources |
evaluate_node | ✅ temp 0 | judges sufficiency → {has_answer, confidence, reason}; sets route; skips the LLM entirely when nothing was retrieved |
answer_node | ✅ temp 0.2 | generates from context only, in the agent's name/language/tone |
refusal_node | ❌ | returns "I could not find this information in the uploaded documents." |
State flows through a RAGGraphState TypedDict; each node returns a partial update that LangGraph merges — the code-level walkthrough is on the service page.
Walking both paths
curl -X POST http://localhost:8001/agents/1/documents/ask \
-H "Content-Type: application/json" \
-d '{"question": "What is the premium support SLA?", "top_k": 3}'{
"question": "What is the premium support SLA?",
"answer": "Premium customers have a 4-hour support SLA.",
"sources": [
{"filename": "sla.txt", "document_id": 1, "chunk_index": 0, "score": 0.31}
],
"retrieval_evaluation": {
"has_answer": true,
"confidence": 0.95,
"reason": "The context states the premium SLA directly."
}
}Cost profile
| Path | Embeddings | Chat completions |
|---|---|---|
| Answered | 1 | 2 (evaluator + answer) |
| Refused with chunks | 1 | 1 (evaluator) |
| Refused, no chunks | 1 | 0 |
Unanswerable questions are the cheapest, inverting naive RAG's economics — you stop paying for hallucinations.
Two layers of grounding
The gate is not the only defense. Even when routing proceeds to answer_node, the answer prompt itself commands: use ONLY the provided context, do not invent, and emit the exact refusal string if the answer isn't there. Belt and suspenders — an evaluator false-positive still tends to produce a refusal rather than an invention.
Observability
Every ask logs one ToolCall named langgraph_corrective_rag whose output JSON contains the answer, sources, and evaluation — so refusal rates and evaluator verdicts are queryable per agent:
curl http://localhost:8001/agents/1/tool-calls | jq \
'[.[] | select(.tool_name=="langgraph_corrective_rag")] | length'Per-node latencies are not recorded individually (single ToolCall wraps the whole graph) — a noted improvement (service page).
Why "corrective" — and what fuller CRAG would add
The name comes from the Corrective-RAG pattern: evaluate retrieval, and correct course when it is inadequate. This implementation corrects by refusing — the safest correction. The fuller pattern adds a retry loop (rewrite the query, re-retrieve, re-evaluate) and/or fallback sources before giving up; the graph's conditional-edge structure makes that a small extension (Roadmap).
Related pages
- Service: RAG Graph — code-level detail
- Evaluation — the judge itself
- RAG Tutorial — hands-on walkthrough