Skip to main content

Book 3 · The Senior AI Engineer · 8 min read

CRAG: Corrective RAG Explained with Python Code

A retrieval score of 0.86 does not guarantee the chunk answers the question. CRAG grades each retrieved chunk as RELEVANT, PARTIAL, or IRRELEVANT before the model generates — preventing type mismatches where a high-scoring chunk answers a different question than the one asked.

CRAG: Corrective RAG Explained with Python Code

A retriever can return the right chunk at the wrong relevance level. The model does not know this — it generates from whatever it receives. CRAG (Corrective RAG) sits between retrieval and generation and grades the evidence before the model speaks from it.

The Valid Failure

ShopBot's retriever scored the silk saree chunk at 0.86 for a query about silk fabrics — the highest retrieval score in a fortnight. The chunk contained accurate product information. The model received it.

Then it generated a response that included jewellery recommendations. No jewellery was in the retrieved chunk. The model had correctly read the fabric information and then continued from training memory — elaborating beyond what it was given.

The retrieval score was 0.86. The retrieved chunk was relevant. The failure was a type mismatch: the chunk answered the fabric question, but the query also implicitly expected accessory styling advice that was not in the catalog.

The problem was not the chunk. The problem was that the system had no mechanism to check whether the chunk actually answered the question before passing it to the model. CRAG provides that mechanism.

How CRAG Works

CRAG inserts a grading step between retrieval and generation:

Query → Retrieve chunks → Grade each chunk → Route based on grade → Generate
                              ↓
                   RELEVANT → pass to LLM directly
                   PARTIAL  → retrieve additional chunks from web or wider index
                   IRRELEVANT → discard, retrieve again or refuse

The grader is a small LLM call that reads the query and each retrieved chunk and returns one of three verdicts.

Implementation

from openai import OpenAI
from enum import Enum

client = OpenAI()

class CRAGVerdict(str, Enum):
    RELEVANT = "RELEVANT"
    PARTIAL = "PARTIAL"
    IRRELEVANT = "IRRELEVANT"

GRADER_PROMPT = """You are a retrieval quality grader.

Query: {query}

Retrieved chunk:
{chunk}

Grade this chunk for relevance to the query.
Return ONLY one word:
- RELEVANT: the chunk directly and fully answers the query
- PARTIAL: the chunk partially answers the query but is missing key information
- IRRELEVANT: the chunk does not address the query

Grade:"""

def grade_chunk(query: str, chunk: str) -> CRAGVerdict:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        messages=[
            {"role": "user", "content": GRADER_PROMPT.format(
                query=query, chunk=chunk
            )}
        ],
    )
    verdict_str = response.choices[0].message.content.strip().upper()
    try:
        return CRAGVerdict(verdict_str)
    except ValueError:
        return CRAGVerdict.PARTIAL  # default to PARTIAL on unexpected output

def crag_retrieve_and_grade(query: str) -> tuple[list[dict], CRAGVerdict]:
    chunks = retrieve(query)
    if not chunks:
        return [], CRAGVerdict.IRRELEVANT

    # Grade the top chunk (most relevant)
    top_chunk = chunks[0]
    verdict = grade_chunk(query, top_chunk["text"])
    return chunks, verdict

Routing Based on Verdict

def crag_pipeline(query: str) -> str:
    chunks, verdict = crag_retrieve_and_grade(query)

    if verdict == CRAGVerdict.RELEVANT:
        # Evidence is sufficient — generate directly
        return generate(query, chunks)

    elif verdict == CRAGVerdict.PARTIAL:
        # Evidence is incomplete — try to supplement
        extra_chunks = retrieve_broader(query, top_k=5)
        all_chunks = chunks + extra_chunks
        # Deduplicate
        seen = set()
        unique_chunks = []
        for c in all_chunks:
            if c["text"] not in seen:
                seen.add(c["text"])
                unique_chunks.append(c)
        return generate(query, unique_chunks[:5])

    else:  # IRRELEVANT
        return "I don't have enough information about that in our catalog."

What the Grader Actually Catches

The grader catches a specific failure that retrieval scores alone cannot detect: type mismatch.

A query like "What accessories would pair with this silk saree?" retrieves the saree chunk at 0.86 (high score). The chunk contains fabric, care, and sizing information. It does not contain accessory information. The cosine similarity score is high because "saree" appears in both. The relevance is PARTIAL — the chunk answers what the saree is, but not what accessories pair with it.

Without CRAG: model generates from the saree chunk, adds jewellery recommendations from training memory, hallucinates.

With CRAG: grader returns PARTIAL. System retrieves broader catalog. If accessory chunks exist, they are included. If not, the system responds with what it knows and acknowledges the gap.

What the Numbers Showed

After adding CRAG to the ShopBot pipeline in Book 3:

Failure class Before CRAG After CRAG
Type mismatch (PARTIAL verdict) Hallucination in 68% of cases Supplementary retrieval in 100%
IRRELEVANT chunks sent to LLM 12% of queries 0% (grader filters before LLM)
RAGAS Faithfulness (affected queries) 0.72 0.87

The cost: one additional LLM call per query (the grader). At gpt-4o-mini prices, this added approximately ₹0.002 per query — less than 15% overhead on the total query cost.

CRAG in a LangGraph Pipeline

In Book 3, CRAG was implemented as a node in a LangGraph StateGraph:

from langgraph.graph import StateGraph, END
from typing import TypedDict

class RAGState(TypedDict):
    query: str
    chunks: list[dict]
    crag_verdict: str
    answer: str

def retrieve_node(state: RAGState) -> RAGState:
    state["chunks"] = retrieve(state["query"])
    return state

def grade_node(state: RAGState) -> RAGState:
    if not state["chunks"]:
        state["crag_verdict"] = "IRRELEVANT"
        return state
    verdict = grade_chunk(state["query"], state["chunks"][0]["text"])
    state["crag_verdict"] = verdict.value
    return state

def route_after_grade(state: RAGState) -> str:
    if state["crag_verdict"] == "RELEVANT":
        return "generate"
    elif state["crag_verdict"] == "PARTIAL":
        return "expand_retrieve"
    else:
        return "refuse"

graph = StateGraph(RAGState)
graph.add_node("retrieve", retrieve_node)
graph.add_node("grade", grade_node)
graph.add_node("generate", generate_node)
graph.add_node("expand_retrieve", expand_node)
graph.add_node("refuse", refuse_node)
graph.add_conditional_edges("grade", route_after_grade)

The Valid Knowledge

  • Retrieval score ≠ relevance: a high cosine similarity score means the query and chunk are semantically close — not that the chunk answers the query. CRAG distinguishes these.
  • The grader catches type mismatches: when the user asks about X and the retrieved chunk is about the context around X (not X itself), the grader returns PARTIAL and triggers re-retrieval.
  • One additional LLM call is the cost: at gpt-4o-mini scale this is negligible per-query. The quality gain on affected queries (12–68% that involve PARTIAL or IRRELEVANT evidence) substantially outweighs the cost.
  • IRRELEVANT → refuse, not hallucinate: the most important routing decision. When the grader returns IRRELEVANT, the correct response is to tell the user there is no information — not to generate from insufficient evidence.

This is the concept. The book is the system.

Book 3: The Senior AI Engineer

Articles give you understanding. The book gives you a working system — full production code, RAGAS evaluation scores, and the patterns that hold up at 11 PM when something breaks.