What I learned de-identifying clinical documents for an LLM
Notes from building Arbiter, a clinical-document RAG prototype that runs on synthetic records only.
The rule I started with was simple: nothing reaches a model until the document has been de-identified. Every other decision followed from that.
I want to be upfront about what Arbiter is. It's a prototype, launched in September 2026, tested on 20 synthetic documents and 40 gold questions. No real patients, no customers, no compliance claims. But building it taught me where de-identification goes wrong, and most of it applies to any team putting health data near an LLM.
HIPAA gives you two routes
The Safe Harbor method removes 18 kinds of identifiers: names, most of a ZIP code, all date elements except the year, phone numbers, medical record numbers and so on. The other route, Expert Determination, has a qualified statistician certify that re-identification risk is very small. A prototype like mine works toward Safe Harbor because you can test it mechanically.
Make "de-identified" a state the code checks
In Arbiter, a document moves through a pipeline (FastAPI, Redis Streams, LangGraph, Postgres with pgvector), and the model step refuses to run unless the document is marked de-identified. Presidio scrubs it before indexing. The map back to the original identifiers is encrypted per tenant. Nobody has to remember the rule, because the pipeline enforces it.
Your recall number can lie
My first metric matched whole entities and read 0.967. Then I scored each address component separately. ZIP codes had survived in 14 of 20 documents and street lines in 7. Under Safe Harbor, you may keep only the first three digits of a ZIP, and only where that area has more than 20,000 people. A whole-string metric hid exactly the identifier that matters. With a strict per-component metric and new recognizers, it reached 1.000.
Default recognizers don't know charts
Presidio is a good base, but on clinical text:
- labelled MRNs scored below the threshold and never fired;
- phone numbers with extensions got through;
- the date filter redacted "daily" and "nightly", which caused 11 of 12 misses on medication lists.
That last one is the over-redaction problem nobody warns you about. Strip too much and the model can't answer the clinical question, so people start looking for ways around the filter.
Check the output, too
An answer is rejected if it leaks an identifier, misses a citation, or scores below 0.7 on faithfulness. The tests found a bug the eval didn't: the output leak check used a looser threshold than the input scrub, so a bare SSN redacted on the way in could pass on the way out. Use one standard in both directions.
Isolation has to be real
Row-level security sits on every table holding PHI, and retrieval is hard-filtered by patient. During bring-up I found the services connecting as the Postgres superuser, which bypasses row-level security entirely. The policies were there and none of them applied. Cross-tenant probes now prove it: another tenant's job returns 404, zero rows are visible, and cross-tenant inserts are rejected.
Contracts come before processing
Any vendor that handles PHI for you needs a Business Associate Agreement. Arbiter has a gate for that: a tenant can't process anything until its BAA is marked signed.
Takeaways
- Enforce de-identification as a pipeline state, not a convention.
- Score each identifier component separately. ZIP codes and dates are where whole-string metrics fool you.
- Test recognizers on realistic documents, and watch for over-redaction as closely as for leaks.
- Run the same leak check on the way out as on the way in.
- Prove tenant isolation with probes. Make sure the app connects as a role that row-level security actually binds.
I'm looking for an AI engineering role (remote, US). If your team puts sensitive data near LLMs, I'd love to talk.