Skip to main content
fi-fhir docs

Terminology Management

fi-fhir includes a comprehensive terminology subsystem for managing healthcare code systems, custom mappings, and semantic search. This guide covers database setup, vocabulary loading, and code mapping operations.

Overview

Healthcare integration requires translating local codes (lab codes, procedure codes, diagnosis codes) to standard terminologies like LOINC, SNOMED CT, RxNorm, and ICD-10-CM. fi-fhir's terminology subsystem provides:

  • Vocabulary Loading: Import standard terminologies (RxNorm, LOINC, UMLS, ICD-10-CM)
  • Custom Mappings: Upload and manage local-to-standard code mappings
  • Semantic Search: Find codes by meaning using LLM embeddings
  • Autoroute Resolution: LLM-powered mapping suggestions when exact matches aren't found
  • Version Management: Track and switch between terminology versions

Supported Vocabularies

VocabularyDescriptionUse Case
RxNormDrug terminologyMedication normalization
LOINCLaboratory codesLab result standardization
SNOMED CTClinical termsClinical concept mapping
ICD-10-CMDiagnosis codesDiagnosis classification
UMLSUnified Medical Language SystemCross-vocabulary mapping

Getting Started

Database Setup

The terminology subsystem requires a PostgreSQL database. Initialize it with:

# Initialize terminology database
fi-fhir terminology init --db "$DATABASE_URL"

# Check initialization status
fi-fhir terminology status --db "$DATABASE_URL"

The init command creates the necessary tables for storing vocabulary data, mappings, and metadata.

Environment Variables

Configure the terminology subsystem using environment variables:

# Required: PostgreSQL connection
export FI_FHIR_TERMINOLOGY_DB_URL="postgres://user:pass@localhost:5432/terminology"

# Optional: Vector database for semantic search
export QDRANT_URL="http://localhost:6333"

# Optional: Embedding service for semantic search
export LLM_EMBEDDING_BASE_URL="http://localhost:8000/v1"
export LLM_EMBEDDING_MODEL="text-embedding-3-small"

# Optional: webhook notifications for pending autoroute review
export FI_FHIR_TERMINOLOGY_AUTOROUTE_NOTIFY_WEBHOOK="https://hooks.example.com/terminology-review"
VariableRequiredDescription
FI_FHIR_TERMINOLOGY_DB_URLYesPostgreSQL connection string
QDRANT_URLNoQdrant vector database URL for semantic search
LLM_EMBEDDING_BASE_URLNoEmbedding API endpoint
LLM_EMBEDDING_MODELNoModel for generating embeddings
LLM_EMBEDDING_TIMEOUTNoTimeout for embedding requests (default: 30s)
FI_FHIR_TERMINOLOGY_AUTOROUTE_SWEEP_INTERVALNoPending autoroute expiry sweep cadence (default: 15m; 0 disables)
FI_FHIR_TERMINOLOGY_AUTOROUTE_NOTIFY_WEBHOOKNoWebhook for pending review notifications (empty disables)
FI_FHIR_TERMINOLOGY_AUTOROUTE_NOTIFY_INTERVALNoReview notification scan cadence (default: 15m)
FI_FHIR_TERMINOLOGY_AUTOROUTE_NOTIFY_MIN_CONFIDENCENoConfidence floor for notifications (default: 0.90)
FI_FHIR_TERMINOLOGY_AUTOROUTE_NOTIFY_TIMEOUTNoPer-attempt webhook delivery timeout (default: 5s)

Loading Vocabularies

Load standard vocabularies from their official distribution files.

RxNorm

RxNorm files are distributed in RRF (Rich Release Format) from the NLM UMLS portal.

# Load RxNorm from RRF directory
fi-fhir terminology load rxnorm /path/to/rrf/ --version 2024-01

# Specify database connection
fi-fhir terminology load rxnorm /data/rxnorm/rrf/ \
  --version 2024-01 \
  --db "$DATABASE_URL"

The loader imports:

  • Concepts (RXNCONSO.RRF)
  • Relationships (RXNREL.RRF)
  • Attributes (RXNSAT.RRF)

LOINC

LOINC is distributed as CSV files from loinc.org.

# Load LOINC from CSV
fi-fhir terminology load loinc /data/loinc/LoincTable.csv --version 2.77

# With full path
fi-fhir terminology load loinc /data/loinc/Loinc_2.77/LoincTable/Loinc.csv \
  --version 2.77 \
  --db "$DATABASE_URL"

UMLS

UMLS Metathesaurus files from the NLM portal.

# Load UMLS META directory
fi-fhir terminology load umls /data/umls/META/ --version 2024AB

# Load specific tables
fi-fhir terminology load umls /data/umls/2024AB/META/ \
  --version 2024AB \
  --tables MRCONSO,MRREL,MRSTY

ICD-10-CM

ICD-10-CM codes from CMS distribution files.

# Load ICD-10-CM
fi-fhir terminology load icd10cm /data/icd10cm/codes.csv --version FY2024

# From CMS table format
fi-fhir terminology load icd10cm /data/icd10cm/icd10cm_tabular_2024.xml \
  --version FY2024 \
  --format xml

Version Management

Manage multiple vocabulary versions and set the active version:

# View loaded vocabularies and versions
fi-fhir terminology status --db "$DATABASE_URL"

# Set active version for a vocabulary
fi-fhir terminology use rxnorm 2024-01 --db "$DATABASE_URL"
fi-fhir terminology use loinc 2.77 --db "$DATABASE_URL"

# Drop all terminology data (use with caution)
fi-fhir terminology drop --force --db "$DATABASE_URL"

Custom Code Mappings

Healthcare systems use local codes that must be mapped to standard terminologies. fi-fhir supports uploading and managing these mappings.

CSV Upload Format

Prepare mappings in CSV format:

source_code,source_display,target_code,target_system,target_display,confidence
GLU001,Glucose Serum,2345-7,http://loinc.org,Glucose [Mass/volume] in Serum or Plasma,1.0
HGB001,Hemoglobin,718-7,http://loinc.org,Hemoglobin [Mass/volume] in Blood,1.0
WBC001,White Blood Count,6690-2,http://loinc.org,Leukocytes [#/volume] in Blood,1.0
ColumnRequiredDescription
source_codeYesLocal code identifier
source_displayNoHuman-readable name for source code
target_codeYesStandard code
target_systemYesTarget code system URI
target_displayNoStandard display name
confidenceNoMapping confidence (0.0-1.0)

Upload Mappings

# Upload mappings from CSV
fi-fhir terminology mapping upload mappings.csv \
  --source-system epic_labs \
  --target-system http://loinc.org

# With description
fi-fhir terminology mapping upload lab_mappings.csv \
  --source-system hospital_lis \
  --target-system http://loinc.org \
  --description "Main hospital LIS to LOINC mappings"

List and Manage Mappings

# List all mapping sets
fi-fhir terminology mapping list

# List mappings for a specific source system
fi-fhir terminology mapping list --source-system epic_labs

# Get details of a specific mapping
fi-fhir terminology mapping get <mapping-id>

# Delete a mapping set
fi-fhir terminology mapping delete <mapping-id> --force

Resolve Mappings

Look up the target code for a source code:

# Basic resolution
fi-fhir terminology mapping resolve GLU001 \
  --source-system epic_labs \
  --target-system http://loinc.org

# Output
{
  "source_code": "GLU001",
  "source_system": "epic_labs",
  "target_code": "2345-7",
  "target_system": "http://loinc.org",
  "target_display": "Glucose [Mass/volume] in Serum or Plasma",
  "confidence": 1.0,
  "method": "exact"
}

LLM-Powered Autoroute

When an exact mapping isn't found, fi-fhir can use LLM embeddings to suggest the most likely target code.

How Autoroute Works

  1. Exact Match: First, attempts direct lookup in custom mappings
  2. Fuzzy Match: Falls back to string similarity if enabled
  3. Semantic Match: Uses embeddings to find semantically similar codes

Usage

# Resolve with autoroute enabled
fi-fhir terminology mapping resolve UNKNOWN_LAB_CODE \
  --source-system hospital_lis \
  --target-system http://loinc.org \
  --autoroute

# Output with autoroute
{
  "source_code": "UNKNOWN_LAB_CODE",
  "source_system": "hospital_lis",
  "target_code": "2345-7",
  "target_system": "http://loinc.org",
  "target_display": "Glucose [Mass/volume] in Serum or Plasma",
  "confidence": 0.87,
  "method": "semantic",
  "alternatives": [
    {
      "code": "2339-0",
      "display": "Glucose [Mass/volume] in Blood",
      "score": 0.82
    }
  ]
}

Configuration

Autoroute requires Qdrant and an embedding service:

terminology:
  autoroute:
    enabled: true
    min_confidence: 0.7        # Minimum score to return a match
    max_alternatives: 3        # Number of alternatives to return
    embedding_model: text-embedding-3-small

Review Notifications

When autoroute cannot commit a mapping on its own it files a pending autoroute for human review. serve can notify an external system when high-confidence suggestions are waiting.

terminology:
  autoroute_notify:
    notification_webhook: https://hooks.example.com/terminology-review
    interval: 15m           # How often to scan the review queue
    min_confidence: 0.90    # Only page reviewers at or above this confidence
    timeout: 5s             # Per-attempt delivery timeout

Notifications are off unless notification_webhook is set. The receiver gets a JSON POST per digest:

{
  "event": "terminology.pending_autoroute.review_required",
  "generated_at": "2026-08-08T14:05:00Z",
  "min_confidence": 0.9,
  "new_count": 1,
  "eligible_count": 4,
  "items": [
    {
      "id": 812,
      "source_system": "epic_labs",
      "source_code": "GLU",
      "target_system": "http://loinc.org",
      "suggested_code": "2345-7",
      "confidence": 0.97,
      "equivalence": "equivalent",
      "created_at": "2026-08-08T14:02:11Z",
      "expires_at": "2026-09-07T14:02:11Z"
    }
  ]
}

Behavior worth knowing before you wire an on-call route to it:

  • Each pending row is announced once. De-duplication is by row ID, so a quiet review queue produces no traffic. eligible_count always reports the full above-threshold backlog, so you can alert on queue depth rather than on individual arrivals.
  • The payload is deliberately code-only. Display names, LLM reasoning, the decision trace, and alternates are never sent, because they can quote source message content. Follow the id into the review UI (or fi-fhir terminology mapping pending) for the full trace.
  • Notifications never affect mapping resolution. Delivery is asynchronous and bounded. A hung or erroring webhook logs a warning and drops the notification; resolution and pending-autoroute creation are unaffected. The next scan restates the backlog, so a drop loses nothing durable.
  • Delivery is one attempt plus one retry. Any non-2xx response is a failure. If you need at-least-once semantics, terminate the webhook at a queue.

Find terminology codes by meaning rather than exact string matching. "Blood sugar" finds glucose codes even though the strings don't match.

CLI Usage

# Search LOINC for glucose-related tests
fi-fhir terminology search --query "blood sugar" --vocabulary loinc --limit 10

# Search SNOMED for heart conditions
fi-fhir terminology search --query "chest pain" --vocabulary snomed --limit 5

# Search across all loaded vocabularies
fi-fhir terminology search --query "diabetes medication" --limit 20

Output

{
  "results": [
    {
      "code": "2345-7",
      "system": "http://loinc.org",
      "display": "Glucose [Mass/volume] in Serum or Plasma",
      "score": 0.94
    },
    {
      "code": "2339-0",
      "system": "http://loinc.org",
      "display": "Glucose [Mass/volume] in Blood",
      "score": 0.91
    },
    {
      "code": "41653-7",
      "system": "http://loinc.org",
      "display": "Glucose [Mass/volume] in Capillary blood by Glucometer",
      "score": 0.87
    }
  ],
  "query": "blood sugar",
  "vocabulary": "loinc",
  "total_results": 3
}

Building the Search Index

Semantic search requires pre-built embedding indexes:

# Build LOINC index
fi-fhir terminology index build --vocabulary loinc --source ./data/LoincTable.csv

# Build SNOMED index
fi-fhir terminology index build --vocabulary snomed --source ./data/sct2_Description.txt

# Build RxNorm index
fi-fhir terminology index build --vocabulary rxnorm --source ./data/rxnorm/rrf/

# Check index status
fi-fhir terminology index status

# Output
{
  "indexes": [
    {
      "vocabulary": "loinc",
      "version": "2.77",
      "document_count": 98543,
      "last_updated": "2024-01-15T10:30:00Z",
      "status": "ready"
    },
    {
      "vocabulary": "snomed",
      "version": "2024-01",
      "document_count": 456789,
      "last_updated": "2024-01-14T15:45:00Z",
      "status": "ready"
    }
  ]
}

Crosswalk Between Vocabularies

Map codes between different terminology systems using UMLS relationships.

# Cross-walk ICD-10-CM to SNOMED CT
fi-fhir terminology crosswalk --from icd10cm --to snomed E11.9

# Output
{
  "source": {
    "code": "E11.9",
    "system": "ICD-10-CM",
    "display": "Type 2 diabetes mellitus without complications"
  },
  "targets": [
    {
      "code": "44054006",
      "system": "SNOMED CT",
      "display": "Type 2 diabetes mellitus",
      "relationship": "exact_match",
      "confidence": 0.95
    }
  ]
}

# Cross-walk LOINC to SNOMED
fi-fhir terminology crosswalk --from loinc --to snomed 2345-7

Workflow Integration

Use terminology operations within workflow transforms:

workflow:
  name: standardize_codes
  version: "1.0"
  routes:
    - name: map_lab_codes
      filter:
        event_type: lab_result
      transform:
        # Map local codes to LOINC
        - map_terminology:
            field: observation.code
            from: hospital_lis
            to: http://loinc.org
      actions:
        - type: fhir
          endpoint: https://fhir.example.com/r4
          token: my-static-bearer-token

Workflow YAML values are literal — ${VAR} references are not expanded by fi-fhir. Render the file with envsubst before loading it if you need environment-specific values.

Transform Options

map_terminology accepts exactly these three keys; any other key is silently ignored when the workflow loads.

OptionDescription
fieldEvent field containing the code to map
fromSource code system identifier
toTarget code system identifier

If no mapping is found, the field is left unchanged and the route continues. When the matched mapping carries a display name, it is written to a parallel <field>_display key (here, observation.code_display).

Autoroute is not a workflow transform option. Enable it in the terminology.autoroute service config (see LLM-Powered Autoroute), or per-invocation with fi-fhir terminology mapping resolve --autoroute.


Troubleshooting

Database Connection Issues

# Test database connectivity
fi-fhir terminology status --db "$DATABASE_URL"

# Reinitialize if tables are missing
fi-fhir terminology init --db "$DATABASE_URL" --force
  • Ensure Qdrant is running and accessible
  • Verify embedding index is built for the vocabulary
  • Check network latency to embedding service
  • Consider local embedding deployment for production

Missing Mappings

# Check if mappings are loaded
fi-fhir terminology mapping list --source-system your_system

# Verify vocabulary is loaded
fi-fhir terminology status

Index Build Failures

# Check index status for errors
fi-fhir terminology index status --verbose

# Rebuild index
fi-fhir terminology index build --vocabulary loinc --force

Best Practices

Vocabulary Management

  1. Version tracking: Always specify versions when loading vocabularies
  2. Regular updates: Schedule quarterly vocabulary updates
  3. Backup before updates: Export mappings before vocabulary changes

Custom Mappings

  1. Start with high-confidence mappings: Focus on frequently used codes first
  2. Review autoroute suggestions: Don't blindly accept LLM suggestions
  3. Document mapping decisions: Add descriptions to mapping uploads
  4. Monitor unmapped codes: Track codes that fail to resolve

Performance

  1. Pre-build indexes: Build embedding indexes during deployment, not runtime
  2. Cache resolutions: Terminology lookups are cached automatically
  3. Batch operations: Use batch upload for large mapping sets
  4. Local deployment: Run embedding service locally for consistent latency

See Also

Terminology Management | fi-fhir docs