LLM LOINC Mapping: Make the Model Pick, Not Remember

Automated LOINC mapping with an LLM: let it pick from real search results, check the axes in code, and ship a FHIR ConceptMap a human approves.

Clint JohnsonPublished 10 min read

Key takeaways

  • Have the LLM pick one code or NONE from real LOINC search results; model-generated names that replaced source labels made search worse, 59% vs 77%.
  • Check property, system, scale and time in code from units and specimen; in one benchmark a rule table alone matched the model, 47 to 46 out of 71.
  • Ship a draft FHIR ConceptMap a human promotes to active, apply it as a lookup keyed on code plus unit, and stamp the map version on every translated result.

Don't ask an LLM to know LOINC. Ask it to choose. Give it real candidates from a LOINC search and make it return one code or NONE. Then check that pick against the OBX-6 units and the specimen in plain code. The output is a versioned FHIR ConceptMap. A human approves it, and the interface engine applies it as a lookup. The model never touches a live message and never sees PHI. It gets a test name, a unit and a specimen.

Every HL7v2 lab pipeline hits the same wall. I've written about moving results with Mirth and RabbitMQ, and about landing them in Snowflake and Google Cloud. Moving the messages is the easy part. Downstream systems need to understand the OBX-3 code, and a local code only means something inside the lab that made it up. If OBX segments are new to you, start with the HL7v2 primer. This post is about the map. For converting v2 to FHIR, see the Google Cloud post.

Keep the model out of the live channel

My rule for agents comes from my support bot. It opens the PR and stops. Merging is mine. Terminology mapping gets the same rule. The agent drafts a map at design time. A person approves it. Deterministic code applies it at runtime.

A model mapping codes inside a live channel makes a clinical data decision on every message, and nobody is watching. A lookup table gives the same answer every time. It also keeps PHI out of the prompt by construction. The agent works on the distinct set of local codes, not on messages.

There's also a deadline behind this. USCDI lists LOINC as the vocabulary for lab tests, and ONC's HTI-1 rule made USCDI v3 the baseline for certified health IT starting January 1, 2026 (ONC ISA). That rule covers certified EHR technology, not labs. The systems consuming your feed will still expect LOINC.

Why LLMs get LOINC codes wrong

LOINC has more than 80,000 codes, and local identifiers often don't carry enough information to map cleanly (ML4H 2022). Models are good at choosing among options. They are bad at producing LOINC vocabulary from memory.

The loincmapr benchmark shows the gap. Its README is still a draft, and its counts changed between versions, so read these numbers as directional. Asked for a LOINC formal name, the model called BUN "Benzeneurea." When model-generated names replaced the source labels in search, the right component turned up 59% of the time. The original labels found it 77% of the time. Used alongside the labels, the generated names lifted recall to 80%.

A recent agentic paper had a locally run model query the LOINC Search API and refine its query each round. On 151 German lab tests it beat an embedding-based RAG baseline in every comparison. Top-1 accuracy reached 85.4% and Top-5 reached 98.0%, all on local hardware.

The two results agree. Let the model add search terms, but never let it drop the source label. The Top-5 number matters more to me than Top-1. If the right answer is almost always in five choices, a reviewer can approve a code in seconds.

Keep the selection contract strict. Show the model up to 15 real candidates with their long names. It answers with one code or NONE. Throw away any answer that isn't on the list, and run at temperature 0. That's the loincmapr setup, using qwen2.5:7b in Ollama, and repeated calls gave identical answers. A 7B model runs on your own hardware, which settles the "where does the data go" question fast.

Fetch candidates with their axes attached

The verifier needs each candidate's property, system, scale and time. Don't ask the model for them. Pull them from LOINC. The LOINC Search API is what the agentic paper queried. LOINC's FHIR server at fhir.loinc.org supports CodeSystem/$lookup, which returns a term's parts as properties, and it needs a LOINC login. A local copy of the LOINC table also works.

Filter to active lab codes first. In loincmapr's copy, that cut 101,632 codes to 57,195. Whichever source you use, record the LOINC version in the map.

Check the six LOINC axes in code

Every LOINC term has six parts: component, property, time, system (specimen), scale and method (LOINC Users' Guide). You can check four of them mechanically from data already in the message.

  • Property comes from units. mmol/L means substance concentration, g/L means mass concentration, µkat/L means catalytic concentration. This check catches mass vs. molar mismatches. Treat % as ambiguous, because LOINC uses it for several kinds of quantity.
  • System comes from specimen, through a crosswalk. Serum and plasma are different systems, but many terms use a combined system. loincmapr had to let serum also accept Ser/Plas/Bld.
  • Scale comes from the result type. Numeric results are Qn, except test strips, which LOINC classes as SemiQn.
  • Time is point-in-time unless the order specifies a collection window.

Normalize OBX-6 to UCUM before the lookup, so a literal µkat/L becomes ukat/L and hits the table.

UNIT_PROPERTY = {"mmol/L": {"SCnc"}, "g/L": {"MCnc"}, "mg/dL": {"MCnc"}, "ukat/L": {"CCnc"}}
SPECIMEN_SYSTEM = {"serum": {"Ser", "Ser/Plas", "Ser/Plas/Bld"},
                   "plasma": {"Plas", "Ser/Plas", "Ser/Plas/Bld"}}

def axis_errors(local, cand):  # cand axes come from the LOINC table or $lookup
    errs = {}
    props = UNIT_PROPERTY.get(local.unit)  # unit already UCUM-normalized
    if props and cand.property not in props:
        errs["property"] = f"{local.unit} implies {props}, got {cand.property}"
    systems = SPECIMEN_SYSTEM.get(local.specimen)
    if systems and cand.system not in systems:
        errs["system"] = f"{local.specimen} vs {cand.system}"
    if local.numeric and cand.scale not in {"Qn", "SemiQn"}:
        errs["scale"] = cand.scale
    if not local.timed and cand.time != "Pt":
        errs["time"] = f"point-in-time result, got {cand.time}"
    if cand.is_panel and not local.is_panel:
        errs["panel"] = "panel code proposed for a single result"
    return errs  # empty dict = candidate survives

Often you can't decide the method axis from the data. LOINC has separate ALT codes with and without pyridoxal-5'-phosphate, and the source label usually doesn't say which one the lab ran. Don't force a choice. Send it to the reviewer with the question already written.

In loincmapr, with candidates filtered on specimen alone, the model picked the right code 33 times out of 71. A naive rule got 18. After filters for time, scale and unit-implied property were added, rules alone got 47 and the model got 46. The author put it this way: "a 20-line rule table knows that too." So write the rule table first. The model's job is to choose a component from whatever survives it.

Why LOINC search returns no match

In the same benchmark, the right code was somewhere in the candidate list for 80% of variables. A fifth of the set was unmappable before the model said a word. Seven variables had zero candidates left after filtering. They failed at retrieval and filtering, not selection. The causes were coagulation tests, estimated GFR, percentages that LOINC writes as "/100 leukocytes," and synonyms. LOINC calls lipase "triacylglycerol lipase," so a search on the lab's label never finds it.

The fix is boring and it works. Keep a synonym table and seed it from your own misses. Let the agent re-query in LOINC's wording when the first search comes back thin. Give "zero candidates" its own bucket in the review queue, so nobody blames the picker for an empty list.

Mapping LOINC panel codes vs. single result codes

Daniel Vreeman recommends mapping result codes first and order or panel codes afterward, in separate source files. You can only tell whether a panel has a LOINC equivalent once its elements are mapped. In the agent, OBX-3 results go through the mapper first and OBR-4 order codes get a second pass. The verifier rejects any panel candidate for a single OBX row. That's the is_panel check above.

Build the reviewer's screen

LOINC's own RELMA Lab Auto Mapper already has the right shape. It shows a local term with its units, lab section and current LOINC, plus a grid of candidate codes. A human double-clicks the right one. Keep that shape and fill the grid better:

  • The agent's top five survivors, with the axis values next to each.
  • The candidates the verifier cut, collapsed, with the reason. Rules are wrong sometimes, and the reviewer should be able to override one.
  • Method ambiguity as a written question, not a guess.
  • Zero-candidate codes in their own queue, sorted by message volume.

Storing LOINC mappings in a FHIR ConceptMap

The agent's output is a FHIR R4 ConceptMap. It already has what this workflow needs: version, date, and status (draft, active, retired). Each mapping is a group.element.target with an equivalence, and group.unmapped says what happens to codes the map doesn't cover. R5 replaces equivalence with relationship.

I put the unit in the source code, as BUN|mg/dL. That way a unit change can't quietly reuse an old mapping.

{
  "resourceType": "ConceptMap",
  "version": "2026.07.3",
  "status": "draft",
  "group": [{
    "source": "urn:local:lab:results",
    "target": "http://loinc.org",
    "element": [{
      "code": "BUN|mg/dL",
      "target": [{ "code": "3094-0", "equivalence": "equivalent" }]
    }],
    "unmapped": { "mode": "fixed", "code": "UNMAPPED" }
  }]
}

The target code is illustrative. Confirm it against the LOINC release you load.

The agent only ever writes draft. A reviewer promotes a version to active, and the engine loads only active versions.

Apply the active map in Mirth

A deploy script loads the active ConceptMap into the global channel map. The transformer is a dictionary lookup:

// Deploy script: $gc('loincMap') = { version: '2026.07.3', targets: { 'BUN|mg/dL': { code: '3094-0', display: '...' } } }
var map = $gc('loincMap');
var misses = [];
for each (var obx in msg['OBX']) {
  var key = obx['OBX.3']['OBX.3.1'].toString() + '|' + obx['OBX.6']['OBX.6.1'].toString();
  var t = map.targets[key];
  if (!t) { misses.push(key); continue; }
  obx['OBX.3']['OBX.3.4'] = obx['OBX.3']['OBX.3.1'].toString(); // local code moves to the alternate slot
  obx['OBX.3']['OBX.3.5'] = obx['OBX.3']['OBX.3.2'].toString();
  obx['OBX.3']['OBX.3.6'] = obx['OBX.3']['OBX.3.3'].toString();
  obx['OBX.3']['OBX.3.1'] = t.code;
  obx['OBX.3']['OBX.3.2'] = t.display;
  obx['OBX.3']['OBX.3.3'] = 'LN';
}
channelMap.put('loincMapVersion', map.version);
channelMap.put('unmapped', misses.join(','));

One destination writes loincMapVersion next to every translated result. Another publishes unmapped to the review queue. Stamp the version on every message. Keeping old ConceptMaps isn't enough. Without the stamp, you can't say which map was in force when a given result was translated.

Measuring automated LOINC mapping accuracy

Hand-label a sample from your own feed and report error per axis, not as one accuracy number. Gold sets are wrong too. The SHIP expert mapping behind loincmapr labels 19 urine strip codes Qn where LOINC says SemiQn. A per-axis report shows those as scale disagreements you can audit. A single score buries them. The same goes for any LLM judge you put in the loop. It needs checking against labels, not trust (why).

Report coverage two ways: by unique local code and by result volume. A mapping-coverage study measured ARUP at 0.44 by code and 0.59 by volume. Expert manual mapping raised that to 0.63 and 0.72. Excluding codes that weren't useful for exchange then took it to 0.73 and 0.79. The same paper describes a DoD project at 46% by code against 89.9% by volume. A small set of codes carries most of the traffic, so sort the review queue by message volume.

Don't compare published numbers to each other. The paper's 85.4% and loincmapr's roughly 65% used different models, languages, gold sets and grading rules. More on building evals you'll trust is in my guide to LLM evals.

Maintaining LOINC mappings in production: new codes, units and drift

A map you approve once goes stale. Three signals should send a code back to the review queue:

  • An unmapped code shows up. The transformer catches it and publishes it.
  • A mapped code arrives with a new unit. If a lab switches from mg/dL to mmol/L, the property axis changed and the old mapping is wrong. With the unit in the key, this becomes a miss automatically.
  • A code's value range shifts. A sudden change in the distribution often means a method or unit change nobody announced.

Compute value statistics locally. The agent only ever sees code, label, unit and specimen.

The model is the cheapest part of this system. Put your effort into the rule table, the synonym list and the reviewer's screen.

Questions people ask

Can an LLM map local lab codes to LOINC?

Yes, as a candidate picker. It should choose one code or NONE from real LOINC search results. Code then checks the axes against units and specimen, and a human approves the resulting ConceptMap before the interface engine applies it.

How accurate is automated LOINC mapping?

One agentic study on 151 German lab tests reported Top-1 accuracy up to 85.4% and Top-5 up to 98.0%. A smaller benchmark found a rule table matched the model, 47 to 46 out of 71. The studies used different models, languages, gold sets and grading, so the numbers aren't comparable.

Does the LLM need patient data to map lab codes?

No. The agent only sees the local code, test name, unit and specimen, and works on the distinct set of local codes rather than messages. Value statistics for drift detection are computed locally.

How should I measure LOINC mapping accuracy?

Hand-label a sample from your own feed and report error per axis instead of one accuracy number, because gold sets contain errors too. Report coverage both by unique local code and by message volume, since the two can differ widely.