IG Concept Search¶
Maps Chinese or English free text, abbreviations and chart wording to codes of any CodeSystem of an installed
IG — for example TW Core medical-service-payment-tw (Taiwan NHI medical service payment codes), occupation,
department or medication-frequency codes, or the PAS CodeSystems. The code lives in node-server/src/conceptSearch/
and is used by fhir_normalize_code (when the target is an IG CodeSystem) and search_medical_codes(system=…);
SNOMED CT / LOINC / ICD-10 keep their own tables.
What is searched¶
Every IG CodeSystem is indexed by ig_import (automatically after an IG is installed) or ig_concept_index
(backfill / rebuild):
| Source | Notes |
|---|---|
| The CodeSystem JSON | display, designations in every language (e.g. the English names TW Core already ships), nested concept.concept, string properties (e.g. ingredients), short definitions (≤ 120 characters, low weight) |
| CodeSystem supplements | FHIR R4 content = supplement, merged by supplements (optionally |version): designations and properties. Supplements shipped in IG packages, uploaded in the admin console, or bundled with the code (see "Bundled data" below) all apply. A numeric usage property (code or description mentions claim-volume / usage / popularity / 申報量 / 使用量, e.g. a claim-volume built from the NHI open dataset 診療項目醫令申報量(年)) is not a name: it becomes a usage prior for ranking (see "Usage" below) |
| Linked names | Coding properties (e.g. a supplement's loinc) and the targets of IG ConceptMaps whose group.source is the CodeSystem: the shared LOINC COMPONENT, SNOMED descriptions, otherwise the coding display |
| UMLS (optional) | When a concept's English name is a string of a UMLS concept (CUI), that CUI's other synonyms / abbreviations become match-only terms (at most 12 per concept) |
CodeSystems served by the ICD / LOINC / SNOMED tables (icd-10-cm-*-tw …) are not indexed. A signature decides
whether a CodeSystem needs rebuilding; unchanged ones are skipped.
Query processing¶
- Normalisation: NFKC (full-width → half-width), case, Unicode dashes, variant characters (臺/台, 脢/酶 …), Chinese
numerals before measure words (小於五公分 → 小於5公分),
cm→ 公分,<→ 小於, dotted abbreviations (C.R.P → crp),w/o. - Chart noise removed: laterality,
x1,5針, suture specs (4-0 Nylon),r/o,f/u,s/p, dosing / sampling frequencies (q6h,q3m,bid,prn), measured values and doses with a unit (7.8%,0.5mg,25 cmH2O,28Fr,2.4 ATA) and decimal lab values (Hb 9.1). Sizes (公分), durations and ages are kept. - Shorthand: short shorthand joined by
&//(I&D,U/A,B/C) is one word; its parts remain words of their own (Na/K→ na, k). - Tokens: English words (-s / -ing / -ed folded), Chinese character bigrams plus low-weight unigrams; an English word absent from the index matches as a prefix (abd → abdominal, vanco → vancomycin); an English word of 7+ letters that a name lacks also matches, at a discount, another form sharing its first 6 letters (recurrent ↔ recurrence, laparoscopic ↔ laparoscopy).
- Query variants (each scored, the best kept, agreement between variants adds a little): the text, its clinical
core, without brackets, abbreviation expansions, zh ↔ en bridges, UMLS synonyms. Every expansion comes from data:
SNOMED CT "ACR - expansion" descriptions, LOINC shortname ↔ component, abbreviations defined inside IG names
("… lithotripsy (ESWL)", Schwartz–Hearst validated), English ↔ Chinese word pairs mined from ICD-10 / IG bilingual
names (Dice + mutual best match), the abbrev / zhEn tables of
grounding/twLexiconData.ts, and optionally UMLS — stored infhir.term_thesaurus/fhir.umls_terms. - General abbreviation lexicon fills gaps (
fhir.abbreviation_lexicon, the SPECIALIST Lexicon's LRABR; a copy is bundled with the code and loaded while the table is empty, a UMLS file carrying LRABR replaces it): an abbreviation in the query's first clause is looked up there only when the CodeSystem does not use it as a word itself (e.g. "Procalcitonin (PCT)" already contains PCT) and no thesaurus expansion is supported by the CodeSystem (support < 0.45). A general lexicon gives an abbreviation dozens of senses (LP → lipoprotein, lumbar puncture …); each sense is scored by how well the CodeSystem's own names name it — the best lexical score of the sense on its own (capped at 1) + 0.3 × usage of that concept — kept at support ≥ 0.45, one reading per concept, best first (the best one is also embedded). Upper-case acronyms (PLEX, BMA) apply only to words written like abbreviations, lower-case clippings (chole, trach) only to words the CodeSystem does not know; abbreviations of fewer than 3 letters / digits are too ambiguous to expand from it; a one-word expansion must be a clipping (trach → tracheostomy) — ABO → antibody, where the letters merely occur, is dropped. Abbreviations in later clauses (indication, notes) are not expanded, so a wrong sense there adds no noise. - Scoring: BM25-like with query / name coverage; only the text as typed matching a name exactly is
exactand always ranks first (official Chinese names typed verbatim: top-1 ≥ 98%). - Semantic: the same embedding model and identity as ICD (
ig_embedjob; vectors keyed bysha256(name), so a name shared by package versions is embedded once). The core text, the abbreviation-expanded text and the first clause are embedded in one call and scanned exactly against that CodeSystem's vectors (no HNSW). CodeSystems with more than 20,000 concepts are indexed lexically only. Without a usable embedding endpoint, or before the CodeSystem has vectors, responses carrysearch_mode: "keyword_only". - Fusion: 0.55 × lexical + 0.45 × semantic (similarities rescaled within the query's own similarity distribution — best = 1, rank 40 = 0 — and weighted by a confidence of (best − rank-40 value) ÷ 0.12; no absolute 0.7 floor any more: absolute similarity levels are model-specific, and Qwen3-Embedding-4B with a query instruction puts the best match at ~0.6–0.75. The query vector carries the
ig_concepttask instruction). - Usage (only when the CodeSystem carries a usage property; otherwise ranking is unchanged):
- prior: + 0.3 × usage (log-scaled to 0–1 relative to the CodeSystem's most used code);
- sibling ordering: among codes of one
group(same name stem, differing only by a method / setting / size qualifier — 白蛋白 vs 白蛋白(免疫比濁法), 血液透析(一次)-住院 vs -門診), the members the query cannot tell apart — same exact-match status and the same share of the query's words covered (within 0.05), or, when no word of the query matched, a similarity within 0.02 — are ordered by usage instead of by vector noise. The group keeps its place among the candidates; only its representative changes. A query that names the qualifier ("住院 洗腎", "recurrent") covers more words with that sibling, so usage never overrides it. - Multi-item text ("Na/K/Cl", "CBC/DC, Hb …"): the response carries
clauses(each clause's best code, in text order) and the first clause's concept gets a small boost.
Response fields¶
IG candidates of fhir_normalize_code: code, display, source (lexical / semantic / hybrid), score,
matched_term / matched_language (the IG name that matched — always the IG's own display or designation, never a
UMLS string), exact, category (from a categorical property or the hierarchy), group + siblings
(near-duplicate codes with the same name stem, e.g. the same test by another method), linked (codes of other systems
reached through Coding properties, at most 3 + linked_more, e.g. an NHI drug's ATC code). When the resolved IG holds
only an excerpt of a CodeSystem and another installed package holds a full copy of the same canonical (≥ 10× the
concepts and ≥ 1,000 more, e.g. TW Core 1.0.0 vs 0.3.2 medication-nhi-tw), the full copy is searched unless
package_id / version is given, noted in warnings and data.codesystem_copies. system may be a canonical, a
CodeSystem id or alias words (e.g. nhi_payment). fhir_lookup_code adds hint and near_codes when a code is
not found.
Admin console (IG packages → 術語搜尋索引)¶
- Index / vector / thesaurus / abbreviation-lexicon status (the lexicon shows its source: bundled or an uploaded UMLS file); "補建索引" (skips unchanged CodeSystems) and "全部重建" (force). While an index job runs the panel shows its step and "CodeSystems checked / total"; when it ends it shows the result — "索引已是最新(N 個 CodeSystem 未變更)" (index up to date, N unchanged) when nothing was rebuilt.
- "N 個套件尚未建立索引" (N packages not indexed yet) counts only packages that have indexable CodeSystems; packages
with profiles only, or whose CodeSystems are served by the ICD / LOINC / SNOMED tables or are
content = not-present, are listed as a neutral note ("no indexable CodeSystem") and need no action. - Upload a CodeSystem supplement JSON (
application/fhir+json, up to 64 MB); the list shows the package versions it applies to; uploads and deletions rebuild the affected index. Bundled supplements carry a 「內建」 badge; deleting one is remembered (it is not re-applied by the next import) and "還原" / "還原內建 supplement" restores it (queuesig_concept_index, which applies it). API:GET/POST/DELETE /admin/api/igs/supplements,POST /admin/api/igs/supplements/bundled/restore({url?}, all when omitted),GET /admin/api/igs/concept-index,POST /admin/api/igs/concept-index/rebuild.
Bundled data (no manual upload)¶
The code ships openly licensed reference data (node-server/data/, copied into both images; sources, versions,
licences and how to regenerate them: node-server/data/README.md, generators in node-server/scripts/supplements/).
On a fresh install the operator imports an IG (e.g. TW Core) through the normal admin flow — no file uploads, no
direct DB writes:
| File | Supplements | Content |
|---|---|---|
nhi-payment-en-supplement |
TW Core medical-service-payment-tw |
NHI English item names of the payment codes |
nhi-payment-loinc-supplement |
same | LOINC links of the TWPAS ConceptMap nhi-loinc (with LOINC names) |
nhi-payment-usage-supplement |
same | NHI annual claim volume — a usage prior for ranking |
nhi-medication-supplement |
TW Core medication-nhi-tw |
Chinese / English names, ingredients and ATC of NHI drug codes |
lrabr-2024AA.tsv.gz |
— | SPECIALIST Lexicon LRABR abbreviation lexicon (filtered like the loader keeps it, 115,233 rows) |
- When: before every index build —
ig_import(including dependency installs) andig_concept_index("補建索引") — each bundled supplement whosesupplementscanonical names an installed CodeSystem is written tofhir.codesystem_supplements(uploaded_by = 'system:bundled', the same write path as uploads); the same job then builds the index, including the packages a newly applied supplement touches — no second rebuild. A supplement whose target is not installed yet is applied when it is. - Idempotent, never overwrites: a row with the same url + version (whoever stored it) is left alone; when an operator-uploaded different version of the url exists the bundled version is not written; only an older bundled version is replaced by a newer bundled one.
- Deletion is respected: deleting a bundled supplement records an opt-out (
admin.app_settings, groupbundled_supplements, key = url); it does not come back until restored. - Abbreviation lexicon: the bundled LRABR is loaded only while
fhir.abbreviation_lexiconis empty (source = 'bundled:LRABR-2024AA'; parsed fully first, written in one transaction); uploading a UMLS file that carries LRABR (umls_import) still replaces it. The lexicon is not part of the index signature, so loading it triggers no rebuild. - Installs that already hold them (e.g. production, where the same 4 supplements were uploaded by hand with the same url + version and the lexicon came from a UMLS file): nothing is written, nothing re-indexed or re-embedded; the list marks those rows 「內建」.
UMLS (optional, requires a UMLS licence)¶
Without UMLS the engine works as described, just without UMLS synonyms. To enable it:
- Write the compact subset (same parser, ~36 MB):
node dist/loaders/umls.js --subset umls-2024AA-metathesaurus-full.zip umls-subset-2024AA.rrf.gz(the Metathesaurus zip, or a zip holding onlyMETA/MRCONSO.RRF+META/MRSAB.RRF, is accepted as well and is streamed member by member, but uploading the full 4 GB zip through the browser is impractical.) - Upload it in the admin console as source
ig / umls_metathesaurusand run theumls_importjob; the index is rebuilt afterwards.
Source vocabularies (SAB): MRSAB rows with CURVER = Y and SRL = 0, plus SNOMEDCT_US (covered by this
deployment's SNOMED CT licence), minus non-clinical sources: HGNC, OMIM, GO, NCBI, LCH, LCH_NW, SRC, MTHMST, UWDA,
FMA, MTHSPL, USP, USPMG, HL7V2.5, HL7V3.0, CDCREC, MVX, CVX, TKMT, SPN, MCM, QMR, RAM, CCS, CCSR_, DXP, ICPC. Only
LAT = ENG, SUPPRESS = N strings of ≤ 80 characters, and only concepts with at least 2 distinct strings, are kept
(2024AA: ~2.53 M strings / 0.76 M concepts, fhir.umls_terms ≈ 610 MB). The SAB is recorded internally only
(fhir.concept_index_terms.source); no UMLS string is ever returned by a tool.
Abbreviation lexicon (a copy is bundled — see "Bundled data"; this is how to load it from a UMLS file): LRABR of the SPECIALIST Lexicon (NLM Lexical Systems Group; LEX/LRABR inside
LEX.tgz, ~20 MB; open source and freely available — no UMLS licence needed). Only acronym / abbreviation rows with a 2–10 character abbreviation and a 3–80 character ASCII
expansion are kept, spelling variants (plasma exchange / plasma-exchange) collapse (2024 release: ~115 k rows /
34 k abbreviations). Add it as a third argument when writing the subset:
node dist/loaders/umls.js --subset <umls.zip> umls-subset-2024AA-lrabr.rrf.gz LEX/LRABR; an LRABR member of an
uploaded UMLS zip is read as well. umls_import replaces fhir.abbreviation_lexicon only when the file carries it
(apply migration 20261027_abbreviation_lexicon.sql first; without the table it logs a warning and skips). Lexicon
strings likewise only steer matching and never appear in tool output.
Jobs and tables¶
| Job | Description |
|---|---|
ig_import |
Applies the bundled data (supplements / lexicon), indexes the packages it installed (plus those a newly applied supplement touches), then queues ig_embed |
ig_concept_index |
Applies the bundled data, then backfill / rebuild; progress counts CodeSystems (options: packages, force, embed_max_concepts, thesaurus, queue_embed); then rebuilds the thesaurus and queues ig_embed when vectors are missing |
ig_embed |
Embeds the embeddable names (incremental, content-addressed) |
umls_import |
Loads UMLS (optional; also the abbreviation lexicon when the file carries LRABR), then queues ig_concept_index |
Tables: fhir.concept_index_cs / _concepts / _terms, fhir.concept_embeddings, fhir.term_thesaurus,
fhir.codesystem_supplements (migration 20261022_ig_concept_search.sql), fhir.umls_terms /
fhir.umls_release (20261023_umls_terms.sql), fhir.abbreviation_lexicon (20261027_abbreviation_lexicon.sql).
Limitations¶
- Taiwan-specific chart abbreviations (NSD, Foley, OB, "echo" for ultrasound …) are mostly absent from SNOMED / LOINC / UMLS / LRABR and may still not be found; two-letter abbreviations (PD, LP, VF) are not expanded from the general lexicon and rely on the CodeSystem itself, the thesaurus and the embedding.
- CodeSystems with Chinese names only and no English designation (e.g. occupations) rely on the embedding for English queries.
- A very large CodeSystem (tens of thousands of concepts) takes a few seconds to load on its first query; it is cached in memory afterwards.