Skip to content
3 min read · 620 words

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.

Rendering diagram…
NodeLLM?Does
retrieve_nodeembedding onlyagent-scoped search; produces retrieved_chunks + slim sources
evaluate_node✅ temp 0judges sufficiency → {has_answer, confidence, reason}; sets route; skips the LLM entirely when nothing was retrieved
answer_node✅ temp 0.2generates from context only, in the agent's name/language/tone
refusal_nodereturns "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

bash
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}'
json
{
  "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

PathEmbeddingsChat completions
Answered12 (evaluator + answer)
Refused with chunks11 (evaluator)
Refused, no chunks10

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:

bash
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).