MediCoord AI Engineering
Back to Technical Index

Two-Track LLM Evaluation: Groundedness + Faithfulness

Published: July 11, 2026Updated: July 14, 2026Written by MediCoord Core Platform Team7 Min Read

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.

Exact Substitution vs. Semantic Drift

A wrong name is a binary, checkable fact; a wrong address or mischaracterized category requires judgment a string match can't provide.

Judge Calls Aren't Free

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.

PYTHON Sample
run_faithfulness_eval.py
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

LLM-as-judge on every live request

Rejected: cost and latency — a third LLM call in the hot path.

Deterministic-only, skip the judge entirely

Rejected: catches wrong names, misses wrong addresses or category mischaracterizations that still misroute a patient.

Feed the full grounding-message text (with instructions) as DeepEval's retrieval_context

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

System Flow
Diagram of the two-track evaluation flow: a deterministic groundedness check on every live response, an offline replay building a transcript dataset, DeepEval faithfulness scoring against a facts-only context, and score aggregation
FIG 5.1: TWO-TRACK EVALUATION FLOW
01
Live Response, Track A Gate
Every Pass-2 response passes through check_facility_groundedness(), an exact substring match, zero LLM cost.
02
Offline Replay
100 requests replayed through /chat, transcripts saved as a purpose-built dataset (89 scoreable).
03
DeepEval Faithfulness Scoring
Each transcript scored against a facts-only retrieval_context (name, address, distance) using gpt-4o-mini as judge.
04
Aggregate
Mean score and pass rate computed at a 0.7 threshold.

Lessons Learned

Retrieval-context pollution

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.

Tradeoff

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.

Result
Measured: Simulated Load
  • Track A0 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).
Methodology
  1. 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.
  2. 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.
  3. 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.