skip to content
rajneeshsoftware developer · coding since 2014
~/writing/

ai · 3 min read

Privacy before inference: strip personal data before the AI sees it

If your AI reads health records or client email, the safest personal data is the data the model never receives. Here is how I remove PII before inference, and put it back after.

When you send text to an AI model, you are sending it to someone else's computer. Even with good contracts and good providers, that text can be logged, cached or kept for a while.

For most text that is fine. For a medical report or a private client email, it is not. Names, phone numbers, addresses and ID numbers do not need to leave your system for the model to do its job.

The model does not need to know that the patient is called Priya Sharma. It only needs to know that the patient's hemoglobin dropped. So I remove the personal parts first. I call this privacy before inference.

The flow

  1. Raw textnames, phones, ids
  2. Detect PIIrules + model
  3. Replacewith placeholders
  4. Modelsees no PII
  5. Restoreplaceholders → real values
  6. Resultfor the user only
personal data is swapped for placeholders before the model, and swapped back after

Step 1: find the personal data

PII means Personally Identifiable Information. I find it in two passes.

The first pass is simple rules. Emails, phone numbers, ID numbers and dates follow patterns, and patterns are easy to catch with code. This pass is fast and very reliable.

The second pass is a small local model that finds names, places and organisations. Names do not follow a pattern, so rules cannot catch them. This model runs on my own server, so the text still has not left the system.

Step 2: swap it for placeholders

Each piece of personal data becomes a placeholder. The same value always gets the same placeholder within one document, so the model can still follow the story.

text
Before:  Priya Sharma (+91 98xxxxxx21) visited Dr. Mehta on 3 March.
After:   [PERSON_1] ([PHONE_1]) visited [PERSON_2] on [DATE_1].

The mapping from placeholder to real value is kept in memory on my server, for that one request only.

typescript
type Found = { value: string; kind: string }; // e.g. { value: "Priya Sharma", kind: "PERSON" }

// one call per request: the map lives only as long as this request does
function createMasker(found: Found[]) {
  const byValue = new Map<string, string>(); // real value → placeholder
  const count: Record<string, number> = {};
  for (const f of found) {
    if (byValue.has(f.value)) continue; // same value, same placeholder
    count[f.kind] = (count[f.kind] ?? 0) + 1;
    byValue.set(f.value, `[${f.kind}_${count[f.kind]}]`);
  }
  // longest first, so "Priya Sharma" is replaced before "Priya"
  const pairs = [...byValue].sort((a, b) => b[0].length - a[0].length);

  return {
    mask: (text: string) => pairs.reduce((t, [value, tag]) => t.split(value).join(tag), text),
    unmask: (text: string) => pairs.reduce((t, [value, tag]) => t.split(tag).join(value), text),
  };
}

const { mask, unmask } = createMasker(found);
const answer = unmask(await model.generate(mask(report)));

Two details in there matter. The map is created per request, not shared, so one user's names can never end up in another user's answer. And longer values are replaced first, otherwise "Priya" would be swapped inside "Priya Sharma" and leave "[PERSON_1] Sharma" behind.

Step 3: let the model work

Now the model gets text with no personal data. It can still summarise, explain, classify or extract, because the meaning is all there. It just does not know who it is about.

Step 4: put the real values back

When the answer comes back, it says things like "[PERSON_1] should discuss this result with [PERSON_2]". My server swaps the placeholders back before showing it to the user. The user sees the real names. The model never did.

One thing to tell the model clearly: keep the placeholders exactly as they are. If it writes "Person 1" instead of "[PERSON_1]", the swap back fails. I check the answer for placeholders that did not come back, and retry if any are missing.

The rest of the trust layer

Removing PII is one part. The full layer I build looks like this:

In transit
TLS 1.3 everywhere
At rest
AES-256 encrypted storage
Before the model
PII detectionplaceholders
Access
rolesleast privilege
Every action
audit logwho saw what and when
the trust layer, outside to inside
  • Encryption in transit: every connection uses TLS 1.3.
  • Encryption at rest: stored files and records are encrypted with AES-256.
  • Least privilege: each part of the system can only read what it needs.
  • Audit logging: every time someone opens a record, it is written down. If something goes wrong, you can see exactly what happened.

When the data should never leave at all

Sometimes even masked data should stay home. For those cases the same code can switch to a local model running with Ollama on the same machine. Nothing goes over the internet. I wrote about that switch in Never depend on one AI provider.

What to remember

  • The safest data is the data you never send.
  • Use rules for patterns, a local model for names.
  • Keep the placeholder map on your server, per request, and then throw it away.
  • Encrypt, limit access, and log everything.

Privacy is not a checkbox you add at the end. It is a layer in the architecture, and it is much easier to build on day one than to bolt on after a scare.