SNOMED CT Terminology Integration
SNOMED CT is the ontology member of a three-subsystem set. It serves two consumers: it anchors the Taxonomy entities with concept IDs at population time, and it rescues unresolved queries at retrieval time inside the Taxonomy Query Enrichment resolver chain (R7–R10). The chunks it helps surface are then arbitrated by the Value Framework. See the Core Concepts flow for the end-to-end composition. Read this page last of the three — it lands best once you've seen its two consumers.
Abstract
Hospital search systems face a fundamental terminology gap: patients use colloquial Dutch while medical content uses clinical terms. We integrated SNOMED CT Belgian Edition (356K concepts, 656K Dutch descriptions) as a query-time synonym expansion layer, following the BMQExpander pattern (Mao et al., 2024). On a targeted 15-question evaluation set of SNOMED-gap queries, synonym expansion improved entity recall from 40% to 47-60% (depending on LLM response variability), with zero infrastructure additions beyond PostgreSQL reference tables.
Introduction
The ZOL Intelligent Search system resolves natural language queries to hospital entities (departments, doctors, conditions, treatments) through a hand-maintained taxonomy (zol_taxonomy.py). This taxonomy contains ~500+ entries of ZOL-specific institutional knowledge alongside ~50 medical terminology entries.
The brittleness problem: every new page ingested surfaces terms not yet mapped. "Chronische obstructieve longziekte" fails because only "COPD" was mapped. "Hoge bloeddruk" is not recognized because only "hypertensie" existed. This iterative patching pattern does not scale to the full corpus (1,443 URLs) or to additional hospital tenants.
The Terminology Gap
| What We Had | What SNOMED CT Provides |
|---|---|
| ~13 condition aliases | 356,370 concepts (with Dutch descriptions) |
| ~4 treatment aliases | 656,287 active descriptions with synonyms |
| ~12 examination normalizations | 1.2M typed relationships (IS-A, FINDING_SITE) |
| Manual expansion on each failure | 4.7M transitive closure entries for hierarchy traversal |
Background
SNOMED CT Belgian Edition
SNOMED CT (Systematized Nomenclature of Medicine -- Clinical Terms) is the world's most comprehensive clinical terminology standard. Belgium has been a SNOMED International member since 2013, and mandates SNOMED CT for primary diagnoses by 2027 (Belgian eHealth Action Plan, EHDS compliance by 2029).
The Belgian Extension adds Dutch-language descriptions validated by the Belgian Terminology Centre (terminologie@health.fgov.be). Each concept carries:
- A Fully Specified Name (FSN): unambiguous, includes semantic tag
- A Preferred Term: the default display term per language
- Zero or more Acceptable Synonyms: alternative terms per language
For example, concept 24700007 (Multiple sclerosis):
- FSN:
Multiple sclerosis (disorder) - Preferred:
Multiple sclerose - Synonyms:
MS,multipele sclerose,disseminated sclerosis
Academic Foundation
Our approach draws from several research findings:
- BMQExpander (Mao et al., 2024): Biomedical query expansion using SNOMED/MeSH synonyms achieved +22% NDCG@10 improvement on medical question answering benchmarks.
- Ruch et al. (2006): Concept-based IR using SNOMED CT body structure hierarchy (FINDING_SITE) improved retrieval MAP by +25%.
- Hartendorp et al. (2024): Biomedical entity linking for Dutch, demonstrating that Dutch medical text can be reliably linked to SNOMED CT concepts.
RF2 Format
SNOMED CT distributes data in Release Format 2 (RF2), a tab-separated file format with three core files:
| File | Contents | Rows (Belgian Ed.) |
|---|---|---|
sct2_Concept_*.txt | Concept IDs + active status | 356,370 |
sct2_Description_*.txt | Term strings + type + language | 656,287 |
sct2_Relationship_*.txt | Source → type → destination | 1,242,818 |
Three integration touchpoints
SNOMED appears in three architecturally distinct places in the system. Conflating them is the most common source of confusion, because they share the same reference tables but run at different times, for different consumers, with different latency profiles.
| Touchpoint | When | What it does | Code | Latency |
|---|---|---|---|---|
| (a) Entity anchoring | Population, Stage 3 of the taxonomy lifecycle | Stamps each extracted clinical entity with a concept ID via the 5-tier SnomedMatcher | taxonomy/snomed_matcher.py → taxonomy_entities.snomed_concept_id | Offline (build time) |
| (b) Synonym cache | Query time, fast path | A pre-flattened synonym → canonical JSON, loaded once into a module global; merged with hand-curated aliases in resolvers R7–R10 | graph/taxonomy/snomed_synonym_lookup.py → get_snomed_condition_aliases() | ~0 ms (dict lookup) |
| (c) Live SNOMED service | Query time, fallback | Live DB synonym expansion → IS-A ancestor walk → FINDING_SITE body-structure routing, tried in order when (b) fails to resolve a department | terminology/snomed_service.py + snomed_graph_enricher.py via resolve_search_query_with_snomed() | ~tens of ms (DB queries) |
The (b)-before-(c) ordering is a deliberate latency-vs-recall tradeoff: the cheap deterministic dict lookup catches the overwhelming majority of known terms with zero DB round-trips, and the expensive multi-query SNOMED traversal fires only when a query is genuinely unresolved. This is the same "deterministic-fast-path, expensive-slow-fallback" shape used by the intent classifier and the Value Framework — a recurring architectural motif. Anchoring (a) and routing (c) are independent: (a) writes a concept ID onto an entity at build time; (c) reads SNOMED relationships to route a query at runtime.
Touchpoint (a) in detail — the 5-tier SnomedMatcher (build time)
Anchoring is where a free-text entity name from the taxonomy (e.g. "suikerziekte") is resolved to a SNOMED concept ID. This is not a single lookup — SnomedMatcher.match_term (taxonomy/snomed_matcher.py) runs a 5-tier cascade, stopping at the first tier that produces a confident match. The tiers escalate from cheap-and-exact to expensive-and-fuzzy, so the common case costs one indexed query and only genuinely hard names reach the LLM:
| Tier | Technique | Why it exists |
|---|---|---|
| 1 · Exact | Case-insensitive equality against snomed_descriptions.term. | The cheapest possible hit; resolves any name that already matches a Dutch description verbatim. |
| 2 · Normalized | Dutch medical compound splitting ("peniskanker" → "penis kanker", "hartritmestoornissen" → "hartritme stoornissen") plus word reordering, then re-matched. | Dutch glues clinical words together; SNOMED descriptions are usually spaced. This tier bridges that orthographic gap deterministically. |
| 3 · Fuzzy | PostgreSQL pg_trgm trigram similarity with a threshold of ≥ 0.4. | Catches spelling variants, inflections, and minor typos without an LLM call. |
| 4 · Word-overlap | Match only if all significant words of the entity name appear somewhere in a SNOMED term (stopwords ignored). | Handles long multi-word names where order and connective words differ but the medical content is identical. |
| 5 · LLM fallback | A reasoning model is asked to pick the best concept — but only for high-confidence unmatched terms, never as a blanket fallback. | The expensive last resort for genuinely ambiguous names; gated so build cost stays bounded. |
Every match is also plausibility-checked against the snomed_transitive_closure and snomed_domain_mapping tables, so a fuzzy hit can't anchor a condition to a wildly unrelated concept. The result — snomed_concept_id — is the language-neutral key that makes touchpoints (b) and (c) possible at query time, and the same key that lets a second-language tenant reuse the whole structural graph (see Future Work).
Methodology
Evaluation Design
We created 15 golden evaluation questions (GQ-164 through GQ-178) specifically targeting the terminology gap -- conditions that exist in SNOMED CT but not in our static taxonomy:
| ID | Query | Expected Department | SNOMED Concept |
|---|---|---|---|
| GQ-164 | gastro-oesofageale reflux | Gastro-enterologie | 235595009 |
| GQ-165 | atopische dermatitis | Dermatologie | 24079001 |
| GQ-166 | astma bronchiale | Pneumologie | 195967001 |
| GQ-167 | hallux valgus | Orthopedie | 27718002 |
| GQ-168 | dyslipidemie | Cardiologie | 370992007 |
| GQ-169 | hypothyreoïdie | Endocrinologie | 40930008 |
| GQ-170 | perifere neuropathie | Neurologie | 302226006 |
| GQ-171 | cataract | Oftalmologie | 193570009 |
| GQ-172 | osteoporose | Reumatologie | 64859006 |
| GQ-173 | hernia discalis | Neurochirurgie | 76107001 |
| GQ-174 | cholesteatoom | Keel-, Neus- en Oorziekten | 363654007 |
| GQ-175 | lumbale stenose | Orthopedie | 18347007 |
| GQ-176 | fibromyalgie | Reumatologie | 203082005 |
| GQ-177 | boulimia nervosa | Psychiatrie | 78004001 |
| GQ-178 | laryngitis | Keel-, Neus- en Oorziekten | 45913009 |
Implementation Architecture
Query: "Ik heb cataract, welke dienst?"
│
▼
┌─────────────────────────────┐
│ Step 1: Static taxonomy │ ← resolve_search_query()
│ (zol_taxonomy.py) │ "cataract" → no match
└────────────┬────────────────┘
│ fallthrough
▼
┌─────────────────────────────┐
│ Step 2: SNOMED synonym │ ← BMQExpander pattern
│ expansion │
│ ┌────────────────────────┐ │
│ │ SnomedTerminologyService│ │
│ │ .expand_query() │ │
│ │ 1. Exact term match │ │
│ │ 2. IS-A descendants │ │
│ │ 3. Description lookup │ │
│ └────────────┬───────────┘ │
│ │ │
│ synonyms: ["staar", │
│ "cataracta senilis", │
│ "grijze staar"] │
│ │ │
│ for synonym in synonyms: │
│ resolve_search_query() │ ← Re-check taxonomy
│ "staar" → Oftalmologie │ ← Match!
└───────────────┼─────────────┘
▼
department = Oftalmologie
─── OR, if Step 2 also finds no match: ───
Query: "perifere neuropathie"
│
▼
┌─────────────────────────────┐
│ Step 3: FINDING_SITE │ ← Body structure routing
│ routing │
│ ┌────────────────────────┐ │
│ │ SnomedGraphEnricher │ │
│ │ .resolve_department_ │ │
│ │ from_term() │ │
│ │ 1. Exact SNOMED match │ │
│ │ 2. Fuzzy fallback │ │
│ │ 3. FINDING_SITE walk │ │
│ │ 4. Body → department │ │
│ └────────────┬───────────┘ │
│ │ │
│ "perifere neuropathie" │
│ → peripheral nerve │
│ → BODY_STRUCTURE_DEPT_MAP│
│ → Neurologie │
└───────────────┼─────────────┘
▼
department = Neurologie
Components Built
-
RF2 Parser (
snomed_loader.py): Streaming parser for SNOMED CT RF2 files. Handles concept, description, and relationship files with duplicate detection and active-only filtering. -
PostgreSQL Reference Tables (migration 033):
app.snomed_concepts: 356K concepts with module_id and definition_statusapp.snomed_descriptions: 656K descriptions with term, type, language, case significanceapp.snomed_relationships: 1.2M relationships (IS-A, FINDING_SITE, etc.)app.snomed_transitive_closure: 4.7M pre-computed IS-A ancestor/descendant pairs
-
SnomedTerminologyService (
snomed_service.py):expand_query(): BMQExpander-style synonym expansionget_synonyms(): All Dutch descriptions for a conceptget_ancestors()/get_descendants(): IS-A hierarchy traversal via transitive closure- Fuzzy matching fallback for partial term matches
-
FINDING_SITE Routing (
snomed_graph_enricher.py): -
Always-on architecture: SNOMED is not a feature toggle — it is an internal implementation detail of query resolution. All SNOMED calls are wrapped in
try/exceptfor graceful degradation when tables don't exist.
Evaluation Results
Baseline vs. SNOMED ON
| Metric | Baseline (OFF) | SNOMED ON (stable) | SNOMED ON (best) |
|---|---|---|---|
| Pass rate | 6/15 (40.0%) | 7/15 (46.7%) | 9/15 (60.0%) |
| Delta | -- | +1 question | +3 questions |
| Avg response time | ~4s | ~6.4s | ~3.9s |
Per-Question Analysis
| ID | Baseline | SNOMED ON | Mechanism |
|---|---|---|---|
| GQ-166 (astma) | PASS | PASS | Already in taxonomy |
| GQ-167 (hallux valgus) | PASS | PASS | Already in taxonomy |
| GQ-169 (hypothyreoïdie) | PASS | PASS | Already in taxonomy |
| GQ-171 (cataract) | FAIL | PASS | SNOMED: "cataract" → "staar" → Oftalmologie |
| GQ-173 (hernia discalis) | PASS | PASS | Already in taxonomy |
| GQ-174 (cholesteatoom) | PASS | PASS | Already in taxonomy |
| GQ-175 (lumbale stenose) | PASS | PASS | Already in taxonomy |
| GQ-168 (dyslipidemie) | FAIL | FLAKY | SNOMED synonyms found but LLM response varies |
| GQ-177 (boulimia nervosa) | FAIL | FLAKY | SNOMED synonyms found but LLM mentions "psycholoog" not "Psychiatrie" |
| GQ-164 (reflux) | FAIL | FAIL | No SNOMED synonyms match taxonomy entries |
| GQ-165 (dermatitis) | FAIL | FAIL | SNOMED synonyms found but no taxonomy match |
| GQ-170 (neuropathie) | FAIL | FAIL | Requires FINDING_SITE routing3 |
| GQ-172 (osteoporose) | FAIL | FAIL | Requires FINDING_SITE routing3 |
| GQ-176 (fibromyalgie) | FAIL | FAIL | Requires FINDING_SITE routing3 |
| GQ-178 (laryngitis) | FAIL | FAIL | Requires FINDING_SITE routing3 |
Failure Mode Analysis
-
FINDING_SITE routing (4 questions, now active): Conditions like perifere neuropathie → peripheral nerve → Neurologie traverse SNOMED's FINDING_SITE relationships. As of Phase 2, this is wired into the query pipeline as Step 3 of
resolve_search_query_with_snomed(). Expected to resolve GQ-170, GQ-172, GQ-176, GQ-178. -
No taxonomy match for synonyms (2 questions): SNOMED provides synonyms for atopische dermatitis and gastro-oesofageale reflux, but none of the synonyms match existing department names in the taxonomy.
-
LLM response variability (2 questions): SNOMED successfully routes to relevant content, but the LLM response generator sometimes uses different terminology than expected entities.
Discussion
What Worked
The BMQExpander pattern provides a clean, zero-infrastructure-addition approach to medical terminology expansion. By importing SNOMED CT directly into PostgreSQL (rather than deploying Snowstorm Lite), we avoided adding a Docker container while gaining access to 656K Dutch medical terms.
The fallthrough architecture — try static taxonomy first, then SNOMED — preserves zero-latency resolution for known terms while adding SNOMED coverage for unknown terms.
Limitations
-
Modest improvement from synonyms alone: +7% stable (1 question) is below the +22% NDCG@10 reported by Mao et al. (2024) for BMQExpander. FINDING_SITE routing (now active) is expected to add +4 more questions.
-
Synonym-to-taxonomy gap: Even when SNOMED provides correct synonyms, they must match an entry in
zol_taxonomy.pyto resolve to a department. FINDING_SITE routing partially addresses this by providing an alternative resolution path.
Architectural Decisions
-
SNOMED always-on: The
snomed_enabledtoggle was removed. SNOMED is an internal implementation detail of query resolution, not a user-facing feature. All SNOMED calls usetry/exceptfor graceful degradation when tables don't exist (test environments, fresh installs). -
include_graphremoved: The UI checkbox for "Include Knowledge Graph" had no effect becausegraph_use_medical_only=Truecaused extraction to always run. Bothinclude_graphandgraph_use_medical_onlywere removed; extraction now runs unconditionally as part of the taxonomy pipeline. -
SNOMED in both query paths: SNOMED resolution is used in both the RAG vector search path (
rag_service.py) and the graph query path (query_service.py), with identical fallback behavior.
Future Work
- Direct SNOMED → department mapping: Bypass taxonomy by mapping SNOMED concept hierarchies directly to ZOL departments (e.g., all descendants of "Skin structure" → Dermatologie)
- MedCAT integration (Phase 3): Context-aware NER with SNOMED entity linking for the full 750K+ Dutch medical term vocabulary
- Multi-language editions (English / Romanian / French tenants): SNOMED's concept graph is internationally universal — concept IDs and relationships (IS-A, FINDING_SITE) are identical worldwide. Only the description layer is language-specific. A new-language tenant therefore needs the target-language description set + language refset loaded, plus a few
language_code='nl'filters parameterised — not a different ontology. English is nearly free (it is SNOMED's source language); Romanian requires the RO national edition with a coverage caveat (national editions translate only a fraction of the ~356K concepts, so synonym expansion degrades gracefully to "original term only" for the long tail). Because thesnomed_concept_idanchor (touchpoint a) is language-neutral, a second-language tenant reuses the entire structural taxonomy graph on day one and layers its language's descriptions on top. This directly supports the multi-tenancy roadmap.
References
- Chen, J., et al. (2024). BGE M3-Embedding: Multi-lingual, multi-functionality, multi-granularity text embeddings through self-knowledge distillation. arXiv preprint, arXiv:2402.03216. (Historical reference: production embeddings have since migrated to OpenAI
text-embedding-3-largeper ADR-0048.) - Hartendorp, R., et al. (2024). Biomedical entity linking for Dutch. In Proceedings of CL4Health Workshop, LREC-COLING 2024.
- Mao, Y., et al. (2024). BMQExpander: Biomedical query expansion with SNOMED-CT and MeSH ontologies. Proceedings of NAACL 2024.
- Ruch, P., et al. (2006). Concept-based information retrieval with SNOMED CT. Proceedings of AMIA Annual Symposium, 674--678.
- SNOMED International. (2023). SNOMED CT Belgian Extension Release Notes.
- Tiro.Health. (2025). SNOMED CT: Complete guide for medical documentation in Belgium. https://tiro.health/