TriVec Labs
All posts
RAGSeptember 1, 2026·5 min read

Automating Medical Coding Part 1

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…

RAG

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 codes

This 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 findings

For 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 condition

This 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 decision

Notice what is deliberately missing:

LLM → arbitrary ICD code

The 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
PROCEDURES

All of these sections matter.

Compare:

DISCHARGE DIAGNOSES
Chronic atrial fibrillation

with:

PAST MEDICAL HISTORY
Previous myocardial infarction

Both 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 modifiers

Now 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 pneumonia

in one section and:

Right Lower lobe pneumonia

later.

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 lobe

This is useful as:

  • it avoids duplicate coding

  • adds more specificity

  • Avoid redundant llm calls

Likewise:

postoperative wound infection
wound infection, postoperative

should 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 hypoxia

results in three retrieval pathways

              Clinical concept
                    │
 
          ┌─────────┼─────────┐
 
          ▼         ▼         ▼
 
       Index      BM25     Embeddings
 
          └─────────┼─────────┘
 
                    ▼
 
                Top N codes

This 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 infection

A semantic model might retrieve:

Other acute postprocedural pain

That is not crazy.

The phrases share:

postoperative
postprocedural
acute
procedure

But clinically:

infection != pain

The 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:

Obesity

and the model selects:

Class 1 obesity

it has added information that never existed

Instead it should produce:

UNSUPPORTED_SPECIFICITY

Quip: 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 B

compare those children against the extracted clinical modifiers.

This transforms:

disease-specific coding logic

into:

generic hierarchy refinement

If 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
Children

Important instruction types may include:

CODE FIRST
USE ADDITIONAL CODE
CODE ALSO
EXCLUDES1
EXCLUDES2

This 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 question

Return 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 model

docker-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