Two-Track LLM Evaluation: Groundedness + Faithfulness
Architectural Overview
An LLM's output can't be verified by reading the code that calls it, so Pass 2's generator output is checked by two independent tracks, each catching a different class of unfaithfulness: a zero-cost deterministic substring check running on every live response, and an offline LLM-as-judge pass scoring subtler fabrications a string match can't see.
RAG evaluation frameworks like RAGAS conventionally split into two metric families: retriever-side (context precision, context recall — did retrieval fetch the right facts) and generator-side (faithfulness, answer relevancy — given those facts, did the model's output stay true to them). MediCoord's Pass 2 architecture makes this split unusually clean: retrieval isn't a semantic/embedding search with ranking uncertainty — it's a deterministic Python lookup (find_nearest_facilities()). The correct facility is a database fact, not a probabilistic retrieval result, which collapses the retriever-eval half of that framework to a non-problem: there's no ranking or recall to score because there's nothing to rank. What's left to evaluate is purely the generator: given a fact already known to be correct, does Pass 2's LLM stay faithful to it, or does it drift, paraphrase incorrectly, or invent details the fact never contained? That's a narrower, generator-only evaluation problem than most RAG eval guides assume — and it's why this pipeline needed two tailored approaches rather than adopting a generic metric suite wholesale. RAGAS is referenced here as the conceptual vocabulary source for the retriever-vs-generator split; the actual implementation uses DeepEval's FaithfulnessMetric, not the ragas library.
The Problem
Two different failure classes need two different detection strategies for that generator-only faithfulness question. A wrong facility name is a simple, exact substitution — cheap to catch deterministically. A wrong address, or a rehab centre mischaracterized as an ER, is subtler unfaithfulness a substring match can't see, but running a judge-LLM call on every live production request adds a third round-trip's cost and latency to every triage response.
A wrong name is a binary, checkable fact; a wrong address or mischaracterized category requires judgment a string match can't provide.
A judge-LLM call in the live request path adds a third round-trip's worth of cost and latency to every triage response, on top of the two passes case study 1 already documents.
The Architecture Strategy
Two tracks, matched to what each failure class actually needs — not a generic RAGAS-style metric suite applied uniformly, but a cheap deterministic check for exact-fact grounding and an LLM-as-judge pass (DeepEval's FaithfulnessMetric, the generator-side metric RAGAS's vocabulary would call faithfulness) for the subtler cases only a semantic judge can catch. Track A (online, deterministic, zero marginal cost): check_facility_groundedness() runs on every Pass-2 response — exact facility-name substring match against the deterministic lookup result, no LLM involved. Track B (offline, LLM-as-judge): 100 requests replayed through the real /chat endpoint and saved as a purpose-built transcript dataset; 89 that resolved to a facility recommendation scored offline with DeepEval's FaithfulnessMetric (gpt-4o-mini judge — chosen for cost, bounded by a stated $5-credit constraint noted in the Sprint 17 discussion notes, not claimed as the best available judge) at a 0.7 pass threshold, against build_retrieval_context()'s facts-only restatement.
def build_retrieval_context(facility: dict) -> list[str]:
"""
Pure factual restatement — name, address, distance only.
Deliberately NOT the full grounding-message text (which also
contains instructions like "use this exact name"): DeepEval's
FaithfulnessMetric extracts "truths" from retrieval_context via
its own LLM call, and instructional text would pollute that
extraction.
"""
return [
f"{facility['name']} is located at {facility['address']}, "
f"{facility['distanceKm']} km away."
]
def score_transcript(transcript: dict, metric) -> dict | None:
facility = transcript.get("recommended_facility")
if facility is None:
return None
test_case = LLMTestCase(
input=transcript["message"],
actual_output=transcript["response_text"],
retrieval_context=build_retrieval_context(facility),
)
metric.measure(test_case)
return {
"message": transcript["message"],
"score": metric.score,
"success": metric.success,
"reason": metric.reason,
}
def summarize_scores(results: list[dict]) -> dict:
if not results:
return {"count": 0, "mean_score": 0.0, "pass_rate": 0.0}
count = len(results)
mean_score = sum(r["score"] for r in results) / count
pass_rate = sum(1 for r in results if r["success"]) / count
return {"count": count, "mean_score": mean_score, "pass_rate": pass_rate}Alternatives Considered
Rejected: cost and latency — a third LLM call in the hot path.
Rejected: catches wrong names, misses wrong addresses or category mischaracterizations that still misroute a patient.
Tried and rejected: DeepEval's FaithfulnessMetric extracts "truths" from retrieval_context via its own LLM call; instructional text like "use this exact name" pollutes that extraction. Rebuilt as a pure factual restatement (name, address, distance only).

Lessons Learned
Feeding the judge instructional text instead of pure facts corrupts its own truth-extraction step. A lesson about the eval instrumentation itself, not the system under test: what you feed a judge model as ground truth needs the same rigor as what you feed the system being judged.
Track B measures faithfulness at a point in time (an offline replay), not continuously — it doesn't run on live production traffic today. Premature-classification rate (whether the model should have asked another question instead of routing) still has no ground-truth label; that's explicitly deferred to Sprint 9's prompt-evaluation work, not silently dropped from scope.
- Track A — 0 hallucinated facilities across 106 grounded-response checks — 100% groundedness.
- Track B — DeepEval Faithfulness score of 0.956 (96.6% pass rate at a 0.7 threshold) across 89 facility-grounded responses — catches fabricated details a name-only match cannot see.
- Premature-classification rate is not yet measurable — no historical baseline exists (the abandoned single-pass prototype was never instrumented).
- Both tracks run against a dedicated staging eval environment — a separate Supabase project and preview backend seeded from real facility data, never live user traffic.
- Track A: 106 classifications had a facility present, all 106 grounded. Window: 2026-07-11, about 15 minutes, exercised via a 4-turn manual smoke test plus a 100-request single-thread synthetic conversation run.
- Track B: of the 100 replayed requests, 89 returned a recommended facility and were scored (the rest resolved to a clarifying follow-up question instead). Window: 2026-07-12, single-thread run against the eval Supabase project.