Fair Retrieval Evaluation: Static Lookup vs. SNOMED Knowledge Graph
Architectural Overview
A retrieval comparison that looks fair on paper — same scenarios, same scoring, both providers — can still hide a confound. Building one for MediCoord's two symptom-understanding retrieval backends took a shared ranking fix, a split hit-rate/recall metric to tell coverage failures from ranking failures apart, a vocabulary-neutral control scenario set, and a caught-and-corrected stale number. The eval design process is as much the content here as the final numbers.
See case study 'Symptom-Understanding Retrieval: CTAS Lookup + SNOMED Knowledge Graph' for why both retrieval backends (a static CTAS alias table, a SNOMED CT knowledge graph in Neo4j) exist and how the switch between them works. This case study starts one level down: how do you compare two retrieval systems fairly once you suspect — correctly, as it turns out — that they share their core retrieval mechanism underneath architecturally different implementations.
The Problem
The first retrieval comparison run had a real, if disclosed, bias baked in: its scenario messages were written using v1's own alias vocabulary, so a system that matches on that exact vocabulary was always going to look artificially strong. Disclosing a bias in code comments isn't the same as controlling for it — nothing in the original eval separated 'v1 retrieves well' from 'v1 retrieves well on text calibrated to v1.' A second problem sat underneath the architecture docs: v1 (flat alias table) and v2 (SNOMED knowledge graph) look structurally distinct, but reading their actual query code side by side shows both retrieve via the same operation — case-insensitive substring containment — just against different vocabularies. Nothing in the design documentation surfaces that; only the code does.
The original scenario set was written against v1's own alias vocabulary — known and commented in the code, but never isolated with an actual control set until this eval redesign.
v1 and v2 look architecturally distinct — a flat table versus a knowledge graph — but both retrieve via literal substring containment. That equivalence only surfaces from reading the query code, not from the architecture documents.
The Architecture Strategy
Before any comparison could be called fair, both providers needed a shared prerequisite fix: both originally used first-match-wins retrieval, so a longer, more specific match sitting later in the data could lose to an earlier, less-specific one purely by insertion order. Both were rebuilt to rank by match specificity instead. Only after that fix landed did the actual eval run: Track A is a deterministic retrieval hit-rate check, 20 isolated single-turn scenarios, identical scoring against both providers. Track B runs DeepEval's faithfulness, contextual precision, and contextual recall metrics (same judge model and methodology as case study 'Two-Track LLM Evaluation' — see that post for how those metrics work) against transcripts generated by replaying the same in-process agent used for the end-to-end eval, so no retrieval-context construction logic is duplicated between the two eval efforts. The change that actually mattered most: a second scenario set, LAY_SCENARIOS, was built specifically to avoid every one of v1's alias/name substrings — enforced programmatically against the real alias table, not by hand-checking — but it sat built and unexecuted for two weeks before this eval redesign finally ran it.
def score_hit(ctx, expected_complaint):
"""Selection accuracy: given the candidates, did the FINAL choice match?"""
if expected_complaint is None:
return not ctx.matched
return ctx.matched and ctx.complaint_name == expected_complaint
def score_recall(candidates, expected_complaint):
"""Coverage: was the right concept ANYWHERE in the candidate set,
regardless of which one got selected? Separates 'never found it'
from 'found it, picked wrong' — collapsing these into one number
is exactly what a naive hit-rate metric does."""
if candidates is None:
return None
if expected_complaint is None:
return len(candidates) == 0
return expected_complaint in candidatesAlternatives Considered
Rejected in favor of a split recall (was the right concept anywhere among the candidates) / hit (was it the one actually selected) metric pair. A single hit-rate number conflates two different defects — 'never found it' and 'found it, picked wrong' — that need different fixes. In practice this diagnosis mattered: v2's hit-rate and recall came back numerically identical, which is itself proof every v2 miss in that scenario set is a coverage failure, not a ranking failure.
Lessons Learned
Naming a bias in a code comment doesn't neutralize it. LAY_SCENARIOS existed, tested, and ready for two weeks before it was finally executed — eval debt accumulates exactly like code debt, silently, until someone runs the thing that was already built.
The original v2 hit-rate figure (8/20, 40%) predated the shared ranking fix above and was never re-run afterward — it propagated into every downstream summary and an already-published write-up before the re-run caught it. The corrected number is 7/20 (35%), and the ranking fix turned out to change nothing here: recall and hit-rate came back identical, meaning every miss was a coverage failure the fix had no way to touch.
This eval measures isolated single-turn retrieval quality. It says nothing on its own about end-to-end triage outcome — see case study 'Symptom-Understanding Retrieval' for that separate, complementary result, where the system with the worse retrieval score wins on the metric that matters. Track B's v2 numbers were generated before this eval's Track A re-run and have not themselves been independently re-verified against the same ranking-fix code path; the ranking fix should only affect which candidate wins when multiple match, not whether DeepEval scores the resulting context as faithful, but that's an inference, not a re-run result, and is stated here as an open item rather than a settled one.
- Corrected: v2 (neo4j) Track A hit-rate is 35% (7/20), not the 40% (8/20) previously quoted — the original figure was stale, predating the shared ranking fix. v1 (static) scores 100% (20/20) on the same scenario set.
- v2's recall is also 35% — numerically identical to its hit-rate, proving every miss is a coverage failure (the concept was never among the candidates), not a selection/ranking failure.
- Vocabulary-neutral control (LAY_SCENARIOS, n=10, run for the first time): both v1 and v2 score 0% (0/10). Neither system does semantic retrieval — both match via literal substring containment, just against different vocabularies. v1's 100% on the main set is a vocabulary-overlap artifact, not evidence of a better retrieval mechanism.
- Track B (DeepEval): faithfulness 96.2% (v1) vs. 100% (v2); contextual precision 89.6% (v1) vs. 33.7% (v2); contextual recall 100% (v1) vs. 33.3% (v2) — v1 wins every axis except faithfulness, where both sit near ceiling.
- Track A and Track B both run against the same 20-scenario main set, both providers, identical scoring code — no per-provider special-casing.
- Track B's judge model is gpt-4o-mini, the same model and methodology as case study 'Two-Track LLM Evaluation' — see that post for how faithfulness/contextual precision/recall are computed; not re-explained here.
- LAY_SCENARIOS: 10 vocabulary-neutral scenarios, verified programmatically to contain zero substring overlap with v1's real alias table before being trusted as a control.
- Explicitly not re-run: Track B's v2 numbers predate the ranking fix validated by the Track A re-run above and have not been independently re-verified against it — stated directly rather than silently assumed unaffected.