Skip to main content

SNOMED CT Terminology Integration

Part of the Knowledge & Retrieval Steering triad

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 HadWhat SNOMED CT Provides
~13 condition aliases356,370 concepts (with Dutch descriptions)
~4 treatment aliases656,287 active descriptions with synonyms
~12 examination normalizations1.2M typed relationships (IS-A, FINDING_SITE)
Manual expansion on each failure4.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:

FileContentsRows (Belgian Ed.)
sct2_Concept_*.txtConcept IDs + active status356,370
sct2_Description_*.txtTerm strings + type + language656,287
sct2_Relationship_*.txtSource → type → destination1,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.

TouchpointWhenWhat it doesCodeLatency
(a) Entity anchoringPopulation, Stage 3 of the taxonomy lifecycleStamps each extracted clinical entity with a concept ID via the 5-tier SnomedMatchertaxonomy/snomed_matcher.pytaxonomy_entities.snomed_concept_idOffline (build time)
(b) Synonym cacheQuery time, fast pathA pre-flattened synonym → canonical JSON, loaded once into a module global; merged with hand-curated aliases in resolvers R7–R10graph/taxonomy/snomed_synonym_lookup.pyget_snomed_condition_aliases()~0 ms (dict lookup)
(c) Live SNOMED serviceQuery time, fallbackLive DB synonym expansion → IS-A ancestor walk → FINDING_SITE body-structure routing, tried in order when (b) fails to resolve a departmentterminology/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:

TierTechniqueWhy it exists
1 · ExactCase-insensitive equality against snomed_descriptions.term.The cheapest possible hit; resolves any name that already matches a Dutch description verbatim.
2 · NormalizedDutch 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 · FuzzyPostgreSQL pg_trgm trigram similarity with a threshold of ≥ 0.4.Catches spelling variants, inflections, and minor typos without an LLM call.
4 · Word-overlapMatch 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 fallbackA 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:

IDQueryExpected DepartmentSNOMED Concept
GQ-164gastro-oesofageale refluxGastro-enterologie235595009
GQ-165atopische dermatitisDermatologie24079001
GQ-166astma bronchialePneumologie195967001
GQ-167hallux valgusOrthopedie27718002
GQ-168dyslipidemieCardiologie370992007
GQ-169hypothyreoïdieEndocrinologie40930008
GQ-170perifere neuropathieNeurologie302226006
GQ-171cataractOftalmologie193570009
GQ-172osteoporoseReumatologie64859006
GQ-173hernia discalisNeurochirurgie76107001
GQ-174cholesteatoomKeel-, Neus- en Oorziekten363654007
GQ-175lumbale stenoseOrthopedie18347007
GQ-176fibromyalgieReumatologie203082005
GQ-177boulimia nervosaPsychiatrie78004001
GQ-178laryngitisKeel-, Neus- en Oorziekten45913009

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

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

  2. PostgreSQL Reference Tables (migration 033):

    • app.snomed_concepts: 356K concepts with module_id and definition_status
    • app.snomed_descriptions: 656K descriptions with term, type, language, case significance
    • app.snomed_relationships: 1.2M relationships (IS-A, FINDING_SITE, etc.)
    • app.snomed_transitive_closure: 4.7M pre-computed IS-A ancestor/descendant pairs
  3. SnomedTerminologyService (snomed_service.py):

    • expand_query(): BMQExpander-style synonym expansion
    • get_synonyms(): All Dutch descriptions for a concept
    • get_ancestors()/get_descendants(): IS-A hierarchy traversal via transitive closure
    • Fuzzy matching fallback for partial term matches
  4. FINDING_SITE Routing (snomed_graph_enricher.py):

    • Maps condition → FINDING_SITE body structure → department
    • 51 curated body structure → department mappings12
    • IS-A hierarchy walk for unmapped body structures (max depth 5)
    • Wired into resolve_search_query_with_snomed() as Step 3 (after synonym expansion)
  5. 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/except for graceful degradation when tables don't exist.

Evaluation Results

Baseline vs. SNOMED ON

MetricBaseline (OFF)SNOMED ON (stable)SNOMED ON (best)
Pass rate6/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

IDBaselineSNOMED ONMechanism
GQ-166 (astma)PASSPASSAlready in taxonomy
GQ-167 (hallux valgus)PASSPASSAlready in taxonomy
GQ-169 (hypothyreoïdie)PASSPASSAlready in taxonomy
GQ-171 (cataract)FAILPASSSNOMED: "cataract" → "staar" → Oftalmologie
GQ-173 (hernia discalis)PASSPASSAlready in taxonomy
GQ-174 (cholesteatoom)PASSPASSAlready in taxonomy
GQ-175 (lumbale stenose)PASSPASSAlready in taxonomy
GQ-168 (dyslipidemie)FAILFLAKYSNOMED synonyms found but LLM response varies
GQ-177 (boulimia nervosa)FAILFLAKYSNOMED synonyms found but LLM mentions "psycholoog" not "Psychiatrie"
GQ-164 (reflux)FAILFAILNo SNOMED synonyms match taxonomy entries
GQ-165 (dermatitis)FAILFAILSNOMED synonyms found but no taxonomy match
GQ-170 (neuropathie)FAILFAILRequires FINDING_SITE routing3
GQ-172 (osteoporose)FAILFAILRequires FINDING_SITE routing3
GQ-176 (fibromyalgie)FAILFAILRequires FINDING_SITE routing3
GQ-178 (laryngitis)FAILFAILRequires FINDING_SITE routing3

Failure Mode Analysis

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

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

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

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

  2. Synonym-to-taxonomy gap: Even when SNOMED provides correct synonyms, they must match an entry in zol_taxonomy.py to resolve to a department. FINDING_SITE routing partially addresses this by providing an alternative resolution path.

Architectural Decisions

  1. SNOMED always-on: The snomed_enabled toggle was removed. SNOMED is an internal implementation detail of query resolution, not a user-facing feature. All SNOMED calls use try/except for graceful degradation when tables don't exist (test environments, fresh installs).

  2. include_graph removed: The UI checkbox for "Include Knowledge Graph" had no effect because graph_use_medical_only=True caused extraction to always run. Both include_graph and graph_use_medical_only were removed; extraction now runs unconditionally as part of the taxonomy pipeline.

  3. 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 the snomed_concept_id anchor (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-large per 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/

Footnotes

  1. ENT structures (ear, nose, throat, larynx) are mapped to "NKO" in BODY_STRUCTURE_TO_DEPARTMENT. The canonical department name in zol_taxonomy.py is "Keel-, Neus- en Oorziekten"; "NKO" is resolved as an alias at query time via resolve_department().

  2. Eye structures are mapped to "Oogheelkunde" in BODY_STRUCTURE_TO_DEPARTMENT. The canonical department name is "Oftalmologie"; "Oogheelkunde" is resolved as an alias at query time via resolve_department().

  3. These evaluation results are from the Phase 1 (synonym-only) run, prior to FINDING_SITE routing being wired into the pipeline. FINDING_SITE routing was integrated in Phase 2 as Step 3 of resolve_search_query_with_snomed() and is expected to resolve these questions. A post-integration re-evaluation has not yet been run. 2 3 4