
The problem
To a novice engineer healthcare coding looks simple from the outside.
A clinician writes a discharge summary. Medical Coder reads it. A set of ICD-10-CM codes get assigned.
So the naive engineering flow looks like:
discharge summary
↓
LLM
↓
ICD codesThis simplistic approach is also exactly how engineers end up with a confident llm model that returns the wrong code for simple medical conditions
The real problem is that a typical discharge summary may contain all of following at once:
Active diagnoses
Chronic comorbidities
Symptoms
Historical conditions
Ruled-out conditions
Postoperative complications
Causal relationships
Procedures
Incidental findingsFor example
DISCHARGE DIAGNOSES
Pneumococcal pneumonia. Acute respiratory failure with hypoxia.
HOSPITAL COURSE
The patient was admitted with fever, cough and shortness of breath.
Acute hypoxic respiratory failure developed secondary to pneumonia and required supplemental oxygen.
There was no evidence of pulmonary embolism.
PAST MEDICAL HISTORY
Previous myocardial infarction.A human coder immediately understands:
Pneumococcal pneumonia
current diagnosis
Acute respiratory failure with hypoxia
current diagnosis
Pulmonary embolism
explicitly ruled out
Previous myocardial infarction
historical conditionThis is domain-specific trade knowledge that is difficult to extract or teach to an LLM through LoRA fine-tuning alone.
The challenge, then, is not making the model clever enough to know ICD codes, but designing a system where the LLM is highly proficient in determining candidate codes without allowing it to become an unreliable authority on actual episode classification.
Instead of asking LLM:
"What ICD code should this diagnosis have?"the system gradually narrows the problem into:
"
Here is the clinical concept.
Here are the candidates retrieved from the ICD classification.
Here is the Index, hierarchy and coding context.
Which of these candidates best matches the evidence?
"That leads to a pipeline more like:
Discharge summary
│
▼
Section extraction
│
▼
Clinical concepts
│
▼
Candidate retrieval
┌─────────────┼─────────────┐
▼ ▼ ▼
Alphabetic Index BM25 Semantic
└─────────────┼─────────────┘
▼
Semantic compatibility
│
▼
Constrained LLM reasoning
│
▼
Deterministic validation
│
▼
Validated ICD decisionNotice what is deliberately missing:
LLM → arbitrary ICD codeThe model does not get that privilege.
If the model can output any code in the universe, eventually it will explore the universe.
Now, Let’s Dive Into How the Pipeline Works
Step 1: Structurally represent the episode first
A discharge summary is semi-structured.
Typical sections include:
DISCHARGE DIAGNOSES
HISTORY OF PRESENT ILLNESS
HOSPITAL COURSE
PAST MEDICAL HISTORY
PROCEDURESAll of these sections matter.
Compare:
DISCHARGE DIAGNOSES
Chronic atrial fibrillationwith:
PAST MEDICAL HISTORY
Previous myocardial infarctionBoth are clinical entities.
Operationally and semantically, they are very different.
So the first layer should be intentionally boring: It has to be deterministic, reliable, and focused on turning messy clinical prose into structured concepts that can be coded later.
Think of it as shrinking the search space from the entire universe down to the inner solar system before the coding machinery even kicks in.
Tip: For LLM pipelines, “boring and deterministic” is often where your weekend gets saved.
A simple model might produce structures like:
ClinicalDocument
sections: list[ClinicalSection]with each section has fields:
Section type
Source text
Character offsets
Document provenance
Clinical modifiersNow every downstream concept carries the full clinical context.
Step 2: Extract clinical concepts, not billing codes
The next layer should answer:
What does the document say?
Not:
What should appear on a claim?
For example:
"Acute hypoxic respiratory failure developed secondary to pneumonia"might become:
{
"name": "respiratory failure",
"assertion": "confirmed",
"acuity": "acute",
"modifiers": ["hypoxia"]
}While:
"No evidence of pulmonary embolism"becomes:
{
"name": "pulmonary embolism",
"assertion": "ruled_out"
}This boundary is critical.
If extraction and coding are merged into one step, System loses the ability to inspect whether the concept was misunderstood or a wrong code was chosen
Step 3: Reconcile repeated concepts before coding
Clinical documentation repeats itself a lot.
You might see:
Pneumococcal pneumoniain one section and:
Right Lower lobe pneumonialater.
These may be the same condition with different attributes.
A reconciliation layer can merge them into something richer:
Pneumococcal pneumonia
organism = pneumococcus
site = right lower lobeThis is useful as:
-
it avoids duplicate coding
-
adds more specificity
-
Avoid redundant llm calls
Likewise:
postoperative wound infection
wound infection, postoperativeshould usually collapse into one concept if the evidence points to the same event.
But reconciliation must be conservative.
Pro tip: Embedding/cosine similarity is good for “these might be related.” It is not good enough for “these are definitely the same diagnosis.”
Step 4: Retrieval should optimize for recall
Now we can start retrieving ICD candidates.
The goal is:
Make sure the correct answer survives.
That means candidate retrieval should be intentionally generous.
It should combine three techniques:
Alphabetic Index
For structured classification terminologies
Lexical retrieval
BM25 or FTS for close wording matches
Semantic retrieval
Bridge language differences via embeddings and cosine similarities.
For example:
Classification:respiratory failure with hypoxiaresults in three retrieval pathways
Clinical concept
│
┌─────────┼─────────┐
▼ ▼ ▼
Index BM25 Embeddings
└─────────┼─────────┘
▼
Top N codesThis is where RAG starts becoming useful.
But retrieval should deliberately remain humble.
Tip: Retrieval says “I found something interesting.” It does not get to narrow down “what constitutes interesting.”
A high semantic score can still be clinically wrong
This is where things get fun.
Suppose the diagnosis is:
Postoperative wound infectionA semantic model might retrieve:
Other acute postprocedural painThat is not crazy.
The phrases share:
postoperative
postprocedural
acute
procedureBut clinically:
infection != painThe candidate should therefore be rejected before the LLM ever sees it.
This is why semantic retrieval needs a second-stage compatibility check.
Quip: Embeddings are excellent at finding relations. Coding needs to know whether they are actually siblings or cousins.
Step 5: Preserve modifiers, but never invent them
The next Step is the opposite : losing specificity.
If the note says:
Obesityand the model selects:
Class 1 obesityit has added information that never existed
Instead it should produce:
UNSUPPORTED_SPECIFICITYQuip: Medical coders call it unsupported specificity. LLM engineers may recognize it as “Hallucinations or unfaithfulness”
A useful rule:
A more specific code is only better when the documentation supports the extra specificity.
Step 6: ICD hierarchy Refinements
If a candidate has children:
parent
├── unspecified
├── with modifier A
└── with modifier Bcompare those children against the extracted clinical modifiers.
This transforms:
disease-specific coding logicinto:
generic hierarchy refinementIf a child is more suited for a condition (because of modifiers) then it should be promoted.
Senior-engineer quip: Use hierarichal database or optimized queries to quickly retrieve , segment and traverse the ICD hierarchies
Step 7: Retrieve Tabular context before reasoning
By now, the candidate set should be reasonably clean.
Next, enrich candidates with authoritative coding context:
Candidate description
Parent hierarchy
Inclusion terms
Direct instructions
Inherited instructions
ChildrenImportant instruction types may include:
CODE FIRST
USE ADDITIONAL CODE
CODE ALSO
EXCLUDES1
EXCLUDES2This information is not just another retrieval signal.
It has higher authority and should be number one in priority order .
This distinction matters a lot in final coding.
Pro tip: A vector score is an opinion. A Tabular instruction is paperwork with consequences.
Step 8: Retrieve only the guideline context you need
The official coding guidelines are large. Do not shove the whole document into the LLM prompt.
Instead retrieve only what matters for the current question:
assertion
candidate family
Tabular instruction
encounter setting
principal/additional diagnosis questionReturn a small number of relevant passages.
This improves:
-
Latency
-
token usage
-
focus
-
traceability
-
reasoning quality.
Quip: Context windows are large now, but that does not mean they need to become digital attics.
Step 9: Now the LLM finally gets a vote
At this point, the model gets a very constrained job.
Instead of:
What ICD code is this?
it receives:
Clinical concept:
Acute respiratory failure with hypoxia
Evidence:
"Acute hypoxic respiratory failure developed..."
Candidate 1:
Acute respiratory failure with hypoxia
Candidate 2:
Acute respiratory failure, unspecified
Candidate 3:
Acute and chronic respiratory failure with hypoxia
Tabular context: ...
Guideline context: ...And it must answer:
{
"decision": "SELECT",
"candidate_id": "CANDIDATE_1"
}or:
{
"decision": "ABSTAIN"
}It should not invent:
a random code
a code from another release
a candidate that was never retrieved
Those failure modes are removed structurally.The safest LLM is sometimes the one with fewer buttons to press.
Tip: It is still better to formulate eval functions that assert the groundness and faithfullness of the llm response here.
Running the pipeline locally
For experimentation, a local setup is practical:
Python service
┌───────────┴───────────┐
│ │
SQLite Ollama
│ │
ICD database reasoning model
FTS / Index embedding modeldocker-compose can quickly spin up the whole infrastructure with low parameter models like qwen-2.7b and nomic-embed-text
In subsequent blogs we will demonstrate working of full pipeline end-to-end