Skip to main content

Book 3 · The Senior AI Engineer · 7 min read

How to Evaluate RAG by Route, Not Just Overall Score

Blended Faithfulness 0.88 hid Route D at 0.79 — a compound failure on vague + PARTIAL queries affecting 9% of traffic. Route tagging in LangGraph state, per-route RAGAS slicing, and a WATCH flag on the compound path made the failure visible, named it, and produced a targeted 16-line fix.

How to Evaluate RAG by Route, Not Just Overall Score

A blended RAGAS score hides failures. Faithfulness 0.88 across all queries looks healthy. Sliced by route, it might show Route D at Faithfulness 0.79 — a compound failure affecting 9–10% of production traffic, invisible in the aggregate.

The Valid Failure

After adding HyDE, CRAG, and LangGraph routing in Book 3, ShopBot's blended RAGAS scores improved:

  • Faithfulness: 0.88
  • Context Precision: 0.91

The team considered the system production-ready. Then RAGAS Monday ran, and someone added a route column to the analysis for the first time.

Route Description % of traffic Faithfulness Context Precision
A Standard specific query 61% 0.91 0.93
B Multi-query decomposition 18% 0.89 0.91
C HyDE vague query 12% 0.87 0.90
D HyDE + CRAG PARTIAL compound 9% 0.79 0.74

Route D was pulling the blended score up to 0.88 by being averaged against the 91% of queries on Routes A–C that were performing well. Route D at Faithfulness 0.79 meant 21% of claims in Route D answers were not supported by retrieved context. 9% of production traffic × 21% unfaithful answers = roughly 1 in 50 production responses containing fabricated information.

What Route D Was

Route D was the compound failure path: a vague query (routed to HyDE) where the CRAG grader returned PARTIAL — meaning the retrieved chunk partially answered the query but was missing key information.

HyDE generated a hypothetical, retrieved a chunk. CRAG graded it PARTIAL. The expand_retrieve node ran and found additional chunks — but the expanded chunks were thematically related to the vague query rather than specific to the product the hypothetical had described. The model generated from three loosely-related chunks and filled gaps with training memory.

The fix was not in the retrieval — it was in the expand_retrieve node, which was not filtering expanded chunks by the original query's specific terms.

Implementing Route Tagging

Tag every query with its route in the LangGraph state:

class ShopBotState(TypedDict):
    query: str
    route: str           # A | B | C | D
    is_vague: bool
    crag_verdict: str
    sub_queries: list[str]
    chunks: list[dict]
    answer: str

def classify_node(state: ShopBotState) -> ShopBotState:
    state["is_vague"] = is_vague_query(state["query"])
    return state

def generate_node(state: ShopBotState) -> ShopBotState:
    # Derive route from state before generating
    state["route"] = derive_route(state)
    state["answer"] = generate(state["query"], state["chunks"])
    return state

def derive_route(state: ShopBotState) -> str:
    has_sub_queries = bool(state.get("sub_queries"))
    is_vague = state.get("is_vague", False)
    verdict = state.get("crag_verdict", "")

    if has_sub_queries:
        return "B"
    elif is_vague and verdict in ("PARTIAL", "IRRELEVANT"):
        return "D"  # compound failure path
    elif is_vague:
        return "C"
    else:
        return "A"

Building the Per-Route Evaluation Dataset

Log the route with every production query:

import csv
from datetime import datetime

def log_query_event(
    query: str,
    route: str,
    answer: str,
    chunks: list[dict],
    session_id: str,
):
    with open("query_log.csv", "a", newline="") as f:
        writer = csv.writer(f)
        writer.writerow([
            datetime.utcnow().isoformat(),
            session_id,
            route,
            query,
            answer,
            json.dumps([c["text"] for c in chunks]),
        ])

On RAGAS Monday, slice the log by route:

import pandas as pd
from ragas import evaluate
from ragas.metrics import faithfulness, context_precision
from datasets import Dataset

df = pd.read_csv("query_log.csv", names=[
    "timestamp", "session_id", "route", "question", "answer", "contexts"
])

for route in ["A", "B", "C", "D"]:
    route_df = df[df["route"] == route].sample(min(50, len(df[df["route"] == route])))

    if len(route_df) < 5:
        print(f"Route {route}: insufficient data ({len(route_df)} queries)")
        continue

    dataset = Dataset.from_dict({
        "question": route_df["question"].tolist(),
        "answer": route_df["answer"].tolist(),
        "contexts": [json.loads(c) for c in route_df["contexts"].tolist()],
    })

    result = evaluate(dataset, metrics=[faithfulness, context_precision])
    print(f"Route {route} ({len(route_df)} queries):")
    print(f"  Faithfulness:      {result['faithfulness']:.3f}")
    print(f"  Context Precision: {result['context_precision']:.3f}")

Per-Route Targets

Set targets per route, not just overall:

Route Faithfulness target Context Precision target Action if below
A ≥ 0.90 ≥ 0.92 Investigate chunking or threshold
B ≥ 0.87 ≥ 0.89 Check sub-query merging logic
C ≥ 0.85 ≥ 0.88 Check HyDE prompt quality
D ≥ 0.84 ≥ 0.86 WATCH — compound failure path

Route D is flagged WATCH because it is the most complex path. A drop below target triggers investigation before the next release, not after.

The Fix for Route D

After identifying Route D as the failing route, the 16-line fix added a specificity filter to the expand_retrieve node:

def expand_retrieve_node(state: ShopBotState) -> ShopBotState:
    # Use sub-queries from CRAG verdict context, not the original vague query
    specific_terms = extract_specific_terms(state["chunks"][0]["text"])
    refined_query = f"{state['query']} {' '.join(specific_terms)}"

    extra = retrieve(refined_query, top_k=5)
    # ...

Result: Route D Faithfulness moved from 0.79 to 0.86 in two weeks. Two files changed. The fix was targeted because the diagnosis was precise.

The Valid Knowledge

  • Blended RAGAS scores hide route-specific failures: a 0.88 blended score with Route D at 0.79 is not a healthy system — it is a system with a concentrated failure affecting 9% of traffic.
  • Route tagging costs nothing and enables everything: adding a route field to the LangGraph state and logging it turns every RAGAS Monday into a diagnostic session rather than a report card.
  • The compound failure path needs a WATCH flag: any route that combines multiple conditional steps (vague + PARTIAL) will accumulate more failure modes than single-step routes. Monitor it more closely.
  • Targeted fixes require targeted diagnosis: "improve Route D" is actionable; "improve the system" is not. Per-route evaluation turns a general quality problem into a specific engineering task.

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.