Skip to main content

FHIR R4 Terminology Server

medterm4ds includes a FHIR R4 terminology server that exposes standard terminology operations plus a custom text-to-code search backed by UMLS, BM25, and fine-tuned SapBERT embeddings.

Starting the server​

pip install -e '.[fhir]'
export MEDTERM4DS_DB=/mnt/d/medterm4ds/data/umls_current.duckdb
python -m medterm4ds.apps.fhir_api

The server runs on http://127.0.0.1:8001/fhir/ (localhost-only by default; see SECURITY.md).

Response formats​

Per FHIR R4 §4.7.1.1 the server supports both JSON (default) and XML. Negotiate via the _format query parameter OR the Accept header:

# JSON (default)
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$lookup?system=http://snomed.info/sct&code=44054006"

# XML via _format query param
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$lookup?system=http://snomed.info/sct&code=44054006&_format=xml"

# XML via Accept header
curl -H "Accept: application/fhir+xml" \
"http://127.0.0.1:8001/fhir/CodeSystem/\$lookup?system=http://snomed.info/sct&code=44054006"

The correct FHIR MIME types are used: application/fhir+json and application/fhir+xml. Error responses honor the same negotiation.

Capability discovery​

GET /fhir/metadata returns a FHIR R4 CapabilityStatement advertising the server's supported operations, code systems, and search parameters per §3.2.1.0. Use ?mode=terminology for a TerminologyCapabilities resource per §4.7.1.1 item 5.

curl "http://127.0.0.1:8001/fhir/metadata"
curl "http://127.0.0.1:8001/fhir/metadata?mode=terminology"

The CapabilityStatement includes:

  • format: ["json", "xml"] — both response formats supported.
  • extension: capabilitystatement-supported-system — one entry per supported code system URI (sourced from the canonical SYSTEM_TO_FHIR_URI registry).
  • rest[].interaction: [{code: batch}, {code: transaction}] — the POST /fhir batch endpoint is advertised per §3.2.1.0.4.
  • Per-resource operation blocks with canonical HL7 OperationDefinition URIs (http://hl7.org/fhir/OperationDefinition/CodeSystem-lookup, etc.) so clients can confirm operations are standard FHIR rather than server-local.
  • implementation.url reflects the deployment scheme + host + port (honors MEDTERM4DS_API_HOST, MEDTERM4DS_API_SCHEME, MEDTERM4DS_FHIR_API_PORT).

Supported operations​

$lookup​

Returns the display name and custom properties for a code.

curl "http://127.0.0.1:8001/fhir/CodeSystem/\$lookup?system=http://snomed.info/sct&code=44054006"

Custom properties returned:

PropertyDescription
patient-friendlyPatient-friendly display name (e.g., "Diabetes Type 2")
canonical-codeICD-10 canonical code for association lookup (e.g., "E11")
canonical-systemCanonical system (e.g., "icd10")
ttyRxNorm term type (SCD, IN, etc.)
match-typeHow the friendly name was resolved

$validate-code​

curl "http://127.0.0.1:8001/fhir/CodeSystem/\$validate-code?system=http://snomed.info/sct&code=44054006"
# → result: true

curl "http://127.0.0.1:8001/fhir/CodeSystem/\$validate-code?system=http://snomed.info/sct&code=FAKE"
# → result: false

$translate​

Maps a code from one system to another.

curl "http://127.0.0.1:8001/fhir/ConceptMap/\$translate?system=http://snomed.info/sct&code=44054006&targetsystem=http://hl7.org/fhir/sid/icd-10-cm"
# → match: E11 (Type 2 diabetes mellitus)

$subsumes​

Checks if one code is an ancestor of another in the hierarchy.

curl "http://127.0.0.1:8001/fhir/CodeSystem/\$subsumes?system=http://snomed.info/sct&codeA=73211009&codeB=44054006"
# → outcome: subsumes (Diabetes subsumes Type 2 diabetes)

Outcomes: equivalent, subsumes, subsumed-by, not-subsumed.

$expand​

Expands ValueSets — supports text filter, intensional definitions, and explicit lists.

# Text filter (EHR autocomplete)
curl "http://127.0.0.1:8001/fhir/ValueSet/\$expand?filter=diabetes&count=10"

# Intensional: all descendants of a concept
curl -X POST "http://127.0.0.1:8001/fhir/ValueSet/\$expand" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType":"ValueSet","compose":{"include":[{"system":"http://snomed.info/sct","filter":[{"property":"concept","op":"is-a","value":"73211009"}]}]}}'

# SNOMED fhir_vs URL shorthand
curl "http://127.0.0.1:8001/fhir/ValueSet/\$expand?url=http://snomed.info/sct/73211009?fhir_vs=isa"

Performance contract for ?fhir_vs=isa and intensional is-a filters: descendant walks use layer-by-layer BFS bounded by FHIR_VS_MAX_DEPTH (default 5 levels). Covers every clinical value-set definition we've seen; deeper hierarchies need pre-computed closure (planned). When the depth or count cap is hit, the response includes the canonical HL7 extension http://hl7.org/fhir/StructureDefinition/valueset-toocostly with valueBoolean: true so clients can detect partial expansions.

$closure​

Pre-computes subsumption relationships for O(1) batch checks.

# Initialize
curl -X POST "http://127.0.0.1:8001/fhir/CodeSystem/\$closure" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType":"Parameters","parameter":[{"name":"name","valueString":"my-closure"}]}'

# Add concepts
curl -X POST "http://127.0.0.1:8001/fhir/CodeSystem/\$closure" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType":"Parameters","parameter":[{"name":"name","valueString":"my-closure"},{"name":"concept","valueCoding":{"system":"http://snomed.info/sct","code":"73211009","display":"Diabetes"}},{"name":"concept","valueCoding":{"system":"http://snomed.info/sct","code":"44054006","display":"Type 2 diabetes"}}]}'

$search (custom operation)​

Text-to-code search with three modes:

# Lexical (BM25, ~1ms) — default
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$search?query=diabetes&searchMode=lexical"

# Semantic (SapBERT + FAISS, ~70ms warm) — catches novel phrasings
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$search?query=high+blood+sugar&searchMode=semantic"
# → Hyperglycemia (0.80), Elevated Blood Glucose Level (0.80)

# Hybrid (reciprocal-rank fusion, ~130-170ms warm)
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$search?query=metformin+pill&searchMode=hybrid"
# → Metformin Pill (1.00, certain)

Each result includes search.score and a match-grade extension (certain / probable / possible), modeled after FHIR Patient $match. The response carries a server-local expansion.search.mode field (NOT part of the FHIR R4 ValueSet.expansion spec) that echoes the requested mode (lexical, semantic, or hybrid). hybrid will internally fall back to semantic-only if BM25 returns no candidates, but the mode label stays hybrid.

$extract (custom operation)​

Extract coded medical concepts from free text. NER (GLiNER) + clinical NLP (medspaCy ConText for negation/uncertainty/historical) + code resolution via the same $search machinery.

# Default: return coded concepts (negated/uncertain excluded)
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$extract?text=Patient%20has%20T2DM.%20No%20CKD."

# Terms only (skip code resolution)
curl "http://127.0.0.1:8001/fhir/CodeSystem/\$extract?text=...&format=terms"

Input text is capped at MEDTERM4DS_MAX_EXTRACT_TEXT_CHARS (default 100000 chars — ~50 pages of clinical text). POST bodies larger than that return 400 with a clear OperationOutcome.

/health (liveness probe)​

curl "http://127.0.0.1:8001/health"
# → {"status":"ok","ready":true}

Pure async, no DB/executor/model touch. Returns in under 5ms even under peak load. Use this for liveness/readiness probes — /fhir/metadata works too but does JSON building that could regress.

Batch endpoint (POST /fhir)​

Submit a FHIR R4 batch Bundle to execute multiple operations in one HTTP round-trip. Per-entry error isolation per §3.7: a malformed entry produces a 4xx OperationOutcome for THAT entry only; other entries process independently.

curl -X POST "http://127.0.0.1:8001/fhir" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Bundle",
"type": "batch",
"entry": [
{"request": {"method": "GET", "url": "CodeSystem/$lookup?system=http://snomed.info/sct&code=44054006"}},
{"request": {"method": "GET", "url": "CodeSystem/$validate-code?system=http://snomed.info/sct&code=FAKE"}},
{"request": {"method": "GET", "url": "ValueSet/$expand?filter=diabetes&count=5"}}
]
}'

The response is a Bundle with type=batch-response, one entry per request entry, in the same order. Each entry carries response.status and either resource (success — the FHIR resource) or resource (error — an OperationOutcome). Supported operations in batch: $lookup, $validate-code (CodeSystem + ValueSet), $subsumes, $closure, $expand, $translate.

medterm4ds is read-only; type=transaction is accepted and processed as batch (no write operations to roll back). Batch processing is sequential per FHIR R4 §3.7 — the value is HTTP-roundtrip amortization, not throughput parallelism.

System URI mapping​

Internal sourceFHIR canonical URI
SNOMEDCT_UShttp://snomed.info/sct
RXNORMhttp://www.nlm.nih.gov/research/umls/rxnorm
ICD10CMhttp://hl7.org/fhir/sid/icd-10-cm
ICD10PCShttp://hl7.org/fhir/sid/icd-10-pcs
LNChttp://loinc.org
CPThttp://www.ama-assn.org/go/cpt
HCPCShttp://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets
CVXhttp://hl7.org/fhir/sid/cvx

OID aliases (e.g., urn:oid:2.16.840.1.113883.6.96 for SNOMED) are also accepted.

Deployment​

The container builds lookup.duckdb from UMLS RRF files using your own NLM API key — fully license-compliant, no UMLS data is redistributed.

# One-command rebuild + restart (scripts/rebuild_fhir_docker.sh)
scripts/rebuild_fhir_docker.sh

# Or manual:
docker build -f deploy/hf-spaces/fhir-server/Dockerfile -t medterm4ds-fhir .
docker run -p 8001:7860 \
-e UMLS_API_KEY=your_umls_api_key \
-e HF_TOKEN=your_hf_token \
-v fhir4ds-data:/data \
medterm4ds-fhir

First start takes ~10 minutes (download UMLS RRF from NLM + build + download search indexes). Subsequent starts with cached volume: ~3 seconds. The image ships with a HEALTHCHECK hitting /health every 30s — docker ps shows health status.

Without HF_TOKEN, the server still works for all operations except $search (BM25 + SapBERT indexes require HF download).

Tunable env vars​

Set at container start. All optional unless noted.

VarDefaultPurpose
UMLS_API_KEY(required)NLM UTS API key — used to download UMLS RRF and build lookup.duckdb.
HF_TOKEN(optional)Hugging Face token — only needed if MEDTERM4DS_HF_DATASET is private.
MEDTERM4DS_API_HOST127.0.0.1 (local) / 0.0.0.0 (HF Spaces)Bind host. Docker forces 0.0.0.0 — see SECURITY.md for auth implications.
MEDTERM4DS_FHIR_API_PORT8001 (local) / 7860 (HF Spaces)Bind port. HF Spaces requires 7860.
MEDTERM4DS_SEARCH_INDEX_DIR(built-in default)Directory containing <category>_bm25.json (6 categories). $search lexical/hybrid returns 503 if missing.
MEDTERM4DS_EMBEDDING_MODEL_DIR(built-in default)SapBERT model dir (must contain model.safetensors + config.json). $search semantic/hybrid returns 503 if missing.
MEDTERM4DS_EMBEDDING_SPACElatest acceptedPin the SERVING embedding space (e.g. esp_f6c5c18d5f3ee3b5). A set-but-unknown value FAILS LOUD at load naming the accepted values (no silent fallback — rotation-safe); unset = the default serving space. cache-info reports serving_space + serving_space_source.
MEDTERM4DS_FHIR4PX_BASELINE(built-in default)Directory containing patient_friendly_<source>.json (5 sources). $lookup skips patient-friendly properties if missing.
FHIR_VS_MAX_DEPTH5Max depth for $expand?fhir_vs=isa descendant walk. Covers clinical value-set definitions; deeper needs pre-computed closure (planned).
MEDTERM4DS_MAX_EXTRACT_TEXT_CHARS100000Max chars for $extract input text. Caps NER executor starvation from megabyte-text inputs.

The startup banner prints every tunable env var with its current value, so you can verify config at boot.

Hugging Face Spaces​

The Dockerfile is compatible with HF Spaces (Docker SDK, port 7860). Set UMLS_API_KEY and HF_TOKEN as Space secrets. See deploy/hf-spaces/ for details.

Data licensing​

DataSourceLicensed?
lookup.duckdbBuilt from UMLS RRF (user's NLM key)User's own UMLS license
patient_friendly_*.jsonHF dataset (derived)Derived
bm25/*_bm25.jsonHF dataset (derived)Derived
sapbert/HF dataset (derived)Fine-tuned model

Conformance testing​

make fhir-conformance

2546 conformance probes across 18 FHIR R4 terminology-service spec chunks (terminology-service.html, codesystem.html, valueset.html, conceptmap.html, and per-operation definition pages). Covers all 8 operations — $lookup, $validate-code, $translate, $subsumes, $expand, $closure, $search, $extract — plus error paths (400 missing params, 422 invalid count, 503 service starting, 503 search assets missing, 404 unknown resource, 405 write-rejection). Validated structurally via fhir.resources pydantic models.

Demo notebook​

See notebooks/fhir_terminology_server_demo.ipynb for a complete walkthrough with real clinical examples.