AI Patient Matching in an EMPI: The LLM Explains Uncertain Pairs, Humans Merge
How to use AI for EMPI patient matching: deterministic scoring, an LLM that only explains uncertain pairs, and reviewed, reversible FHIR links.
Key takeaways
- Keep normalization, blocking and Fellegi-Sunter scoring deterministic, and let the LLM only explain uncertain middle-band pairs, never merge them.
- Twins, Jr/Sr pairs and newborns break Fellegi-Sunter's independence assumption, so code must flag them and send them to individual review.
- Write merges as FHIR Patient.link with Provenance so an unmerge is removing links, and report false merges separately from missed matches.
An LLM should never merge two patient charts. In an EMPI, AI patient matching works when the boring parts stay deterministic. Normalization, blocking and Fellegi-Sunter scoring decide which pairs exist and how likely they are to be the same person. The model only touches the uncertain middle band. Its job there is to explain the pair to a human, not to decide it. Every merge carries a reviewer's name and is written so it can be undone.
Building an EMPI matching pipeline on GCP
I'm designing this for GCP, inside the templated ETL we already use, extended only where needed. The matching stage doesn't get a separate system. It reuses the templates and abstractions we already use to move the data, and it runs over FHIR Patient resources after they land, like any other transform. I covered getting HL7v2 into Google Cloud in an earlier post.
The pipeline has eight steps: normalize, block, score, band, apply hard rules, explain, queue, write links. The deterministic steps, normalize through hard rules, are plain functions. Rerun them on the same input and you get the same pairs, scores and flags. The explain step calls a model, so store its reasons with each run instead of regenerating them.
TEFCA's Individual Access Services SOP proposes demographic matching at responding nodes, with an August 1, 2027 deadline for some approaches. The AHA wrote in April 2026 that the SOPs depend on "untested consent and patient matching components." If you run a responding node, your match decisions could end up answering queries from outside your organization.
Why a false merge is worse than a duplicate record
A matcher can fail in two ways, and the two failures are not the same size.
A missed match leaves a duplicate chart. The history is split and someone pays to clean it up, but each chart still belongs to one person.
A false merge puts one person's allergies, meds and results in someone else's chart. Two people now share one chart. That's a safety event.
This asymmetry is built into the original model. The Fellegi-Sunter decision rule sorts pairs into match, non-match and indeterminate. It controls the false-match rate and the false non-match rate separately and sends the indeterminate pairs to clerical review. Fellegi and Sunter put a human on the middle band in 1969. What's new is that a model can write the reviewer a useful summary.
Don't benchmark against industry duplicate rates. The FHIR spec says around 2% of registrations are in error, mostly duplicates. A 2018 Black Book survey found an average of 18%. Organizations don't count duplicates the same way, so the two numbers can't be compared. Measure your own.
Normalize patient demographics before you score
This is the cheapest win in the pipeline. Pew reports a study where standardizing addresses to USPS format and standardizing last names raised match rates from 81.3% to 91.6% and halved the number of unlinked records. No model was involved.
To normalize:
- Upper-case names and strip punctuation.
- Pull generational suffixes out of the surname field into their own column.
- Map given names through a nickname table so Bob and Robert compare as related.
- Reduce phone numbers to digits.
- Store DOB as a real date.
- Keep the raw value next to every normalized one. The reviewer needs to see what the registrar typed.
Blocking and Fellegi-Sunter scoring
You can't compare every record to every other record, so blocking generates candidate pairs from a few cheap keys and takes the union. Use exact DOB, normalized surname plus birth year, and phone. The surname-plus-year key catches transposed dates: a 03/04 versus 04/03 swap still shares a year, so the pair gets scored at all. This is the silent failure to watch for. A pair that never lands in the same block can never match, and nothing downstream will tell you it was missed.
Scoring is classic Fellegi-Sunter. Each field has comparison levels: exact, nickname, fuzzy, day-month swapped, disagree, missing. Each level has an m probability (how often you see that level among true matches) and a u probability (how often among non-matches). In Splink's formulation, the match weight is the log2 prior odds plus the sum of log2(m/u), and the probability is 2^M/(1+2^M).
import math
def match_probability(prior, levels):
"""levels: (m, u) for the comparison level each field landed on."""
w = math.log2(prior / (1 - prior))
for m, u in levels:
w += math.log2(m / u)
return 2**w / (1 + 2**w)
LOWER, UPPER = 0.05, 0.995 # placeholders: set from your labeled pairs
def band(p):
if p < LOWER:
return "non_match"
return "fast_confirm" if p >= UPPER else "middle"
The thresholds are yours. FHIR's $match returns a 0-1 score and a match grade, but the spec admits that "probable" and "possible" are similar and defines them loosely. Don't build safety on a vendor's grade. Set the thresholds this way:
- Upper threshold: the score where false merges in your labeled set drop to zero, plus margin.
- Lower threshold: the score below which missed matches stop being worth a reviewer's time.
- Band width: everything between the two is your review queue, and the queue size is your staffing cost.
The queue is worth it. Pew notes that within a facility, organizations reach 90% or better when they manually check and fix uncertain matches. Without that, CHIME's estimate is as low as 80%.
How to handle twins, Jr/Sr and newborns in patient matching
Fellegi-Sunter assumes the comparison fields are independent. That's why the Bayes factors multiply. Twins break the assumption. Same surname, same address, same DOB and same phone get counted as four independent pieces of evidence when they're really one fact: these people live together. The score climbs exactly where it shouldn't. A Jr and Sr share name and address. A newborn shares surname, address and phone with a parent, and the registration may carry a temporary name.
Don't try to fix this by tuning weights. Fix it in code that runs after scoring and can only make the decision stricter.
GENERATIONAL = {"JR", "SR", "II", "III", "IV"}
def hard_rule_flags(a, b): # a, b are normalized records
flags = []
if a.dob == b.dob and a.given != b.given and (
a.multiple_birth or b.multiple_birth or a.address == b.address):
flags.append("possible_twin")
if {a.suffix, b.suffix} & GENERATIONAL and a.suffix != b.suffix:
flags.append("generational_suffix")
if min(a.age_days, b.age_days) < 28: # pick your own newborn cutoff
flags.append("newborn")
return flags
def route(a, b, p):
lane, flags = band(p), hard_rule_flags(a, b)
if flags and lane != "non_match":
lane = "individual_review" # never batch-confirmed
return lane, flags
Even a flagged pair scoring 0.999 lands in individual review. The reviewer has to record which field tells the two records apart, or which field proves they're the same person.
What the LLM does in patient matching
The model sees middle-band pairs and flagged pairs. Nothing else.
Its input is the two normalized demographic records, the comparison level for each field, the score and any hard-rule flags. It gets no clinical content: no encounters, diagnoses, meds or notes. Demographics are enough to judge identity. They're also still PHI, so the model endpoint gets the same minimum-necessary treatment as any tool that touches patient data. Keeping clinical data out has a second benefit. "Same meds, so same person" is exactly the reasoning that produces a confident false merge, and it rests on evidence a registrar can't check.
The output is validated against a schema:
- assessment:
likely_same,likely_differentorcannot_tell. - field_reasons: one plain-language observation per field, such as "Bob and Robert are a nickname pair" or "DOB differs only by a day-month swap."
- conflicts: fields that argue against the match.
- ask_registrar: one question that would settle the pair, such as "Confirm the suffix on both registrations."
Three rules govern what happens to that output:
- If the output fails the schema, the pair still goes to the queue without reasons. It is never dropped.
- The assessment sorts the queue but never closes a pair. If the queue is too big, fix your thresholds and your normalization. Don't let the model start closing items.
- For flagged pairs, the reviewer sees the reasons but not the assessment as a recommendation.
Evaluate the reasons, not just the verdict. Sample reviewed pairs and check whether the fields the model cited differ. The binary-scoring approach to evals works fine here.
FHIR $merge vs Patient.link for reversible merges
$merge is Maturity Level 0 and explicitly not idempotent. It rewrites every reference from the source patient to the target. Provenance for the merge is a SHOULD. Provenance that would support an unmerge is only a MAY, and R5 defines no unmerge operation. The spec also warns that updating AuditEvent and Provenance during a merge may break their digital signatures. Once the references are rewritten, undoing the merge is your problem.
Linking leaves both records intact. The duplicate gets a replaced-by link to the survivor, and the survivor may carry a replaces link back. Patient.link is a modifier element, so consumers can't safely ignore it. An unmerge means removing the replaced-by link, and the replaces link if you wrote one. Nothing else moved.
// on the duplicate
{ "resourceType": "Patient", "id": "dup-123",
"link": [{ "other": { "reference": "Patient/keep-456" }, "type": "replaced-by" }] }
// written in the same transaction
{ "resourceType": "Provenance",
"target": [{ "reference": "Patient/dup-123" }, { "reference": "Patient/keep-456" }],
"recorded": "2026-07-14T15:02:00Z",
"activity": { "text": "patient-link replaced-by" },
"agent": [{ "who": { "reference": "Practitioner/reviewer-9" } }],
"entity": [{ "role": "source", "what": { "display": "match-run 881: p=0.97, flags=[], reasons v3" } }] }
An unmerge writes a second Provenance that points at the first.
Two catches:
- Every consumer has to follow
replaced-by, and has to handle chains, because the survivor may itself be forwarded later. - Write the Provenance with the link. Don't reconstruct it afterward.
If you model FHIR as a graph, these links are another edge type, and "all records for this person" is a traversal. That shape is the subject of FHIR Meets Graph Databases.
If a downstream system forces you onto $merge, take three precautions:
- Call it with
preview=truefirst. You get an OperationOutcome that can include the scale of the merge. - Respect the "Recommend reverse merge" warning, which fires when the source is referenced far more than the target.
- Wire your hard rules into the 422 response the spec reserves for business rules that prevent a merge.
Measuring false merges vs duplicate records
Report two numbers and never blend them. A single F1 score lets a handful of safety events hide behind thousands of easy matches. Track these:
- False merges per reviewed merge. The target is zero, and each one gets a root cause.
- Missed matches discovered later as duplicates.
- Middle-band size per day, which is your review cost.
- Reviewer agreement with the model's assessment, which tells you whether the reasons are worth reading.
- Unmerges. Every unmerge is a false merge that got past a reviewer. It's the most honest label you'll ever get, so feed each one back into your labeled set and re-check your upper threshold.
Questions people ask
Can an LLM decide whether to merge duplicate patient records?
No. In this design the LLM only explains middle-band and flagged pairs with structured reasons, and its assessment sorts the review queue but never closes a pair. Every merge needs a human reviewer.
Should I use FHIR $merge or Patient.link to resolve duplicate patients?
Prefer Patient.link. $merge is Maturity Level 0, not idempotent, rewrites every reference, and R5 defines no unmerge. Linking leaves both records intact, so undoing it means removing the replaced-by link, plus the replaces link if you wrote one.
What data should an LLM see for patient matching?
Only normalized demographics, the field comparison levels, the match score and any hard-rule flags. No encounters, diagnoses, meds or notes, because demographics are enough to judge identity and clinical data invites confident false merges.
How do you measure EMPI matching accuracy?
Report false merges and missed matches as separate numbers instead of a blended score like F1. Also track middle-band size, reviewer agreement with the model, and unmerges, which are false merges that got past a reviewer.