Skip to content

Privacy-Preserving Data Pipelines

5 min read · updated August 3, 2026

Redaction is usually implemented at the last possible moment, just before text is sent to a model. That is the one place it does the least good, because by then the untreated text is already in five other places.

The placement question

Follow a document through the pipeline and ask, at each stage, what a copy of the text has been written into.

Redact atDescription
Ingest, before storageStrongest. Nothing downstream ever holds the original: not the text artefacts, not the chunks, not the vectors, not the caches, not the logs. Cost: irreversible, and you cannot re-derive what you never stored.
After extraction, before chunkingAlmost as strong, and it keeps the raw bytes as the system of record so a policy change can be re-applied. The usual right answer.
Before embeddingVectors are clean; chunk text in the database is not. An embedding is not reversible in any straightforward way, but it is derived from personal data and is usually treated as personal data itself, so this is where that question stops being theoretical.
At prompt assemblyWeakest. Protects the model provider from seeing it and nothing else. Your own store, your own caches and your own logs all hold the original.

The stage that decides this is the one you would not think of: an embedding is a copy. It is a lossy, high-dimensional copy from which approximate reconstruction of short texts has been demonstrated in the research literature, and treating it as anonymous because it is a list of floats is not a position that survives contact with a data protection review. The redaction techniques themselves are a separate subject; the placement is this one.

The second row is usually the right compromise and it is worth saying why rather than just asserting it. Redacting at ingest is stronger but irreversible: when the detector improves, or when the policy changes, or when it turns out the redaction was over-broad and removed the product codes along with the account numbers, there is nothing to go back to. Redacting after extraction keeps the raw bytes as the system of record — access-controlled and separate — so the treatment is a derivation that can be re-run rather than a destruction that cannot. The cost is that the raw store is now the sensitive thing, and it has to be governed as such.

Detection is the hard half

Structured identifiers are easy and should be validated rather than merely matched, because a pattern match alone produces enough false positives to make people turn the check off:

import re

CARD = re.compile(r"\b(?:\d[ -]*?){13,19}\b")

def luhn_ok(number: str) -> bool:
    digits = [int(c) for c in number if c.isdigit()]
    total, parity = 0, len(digits) % 2
    for i, d in enumerate(digits):
        if i % 2 == parity:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0

def card_spans(text: str):
    for m in CARD.finditer(text):
        if luhn_ok(m.group()):        # a 16-digit order number will not pass
            yield m.span()

The same discipline applies elsewhere: IBANs have a mod-97 check, many national identifiers have checksums, and applying them turns a noisy detector into a usable one.

Names, addresses and free-text health information are the hard part and no regex touches them. Named-entity recognition is the standard tool — Microsoft’s Presidio wraps spaCy NER with a set of pattern recognisers and a separate anonymiser stage, which is a reasonable starting architecture even if you replace the pieces. Whatever you use, two rules hold. Configure per language, because an English NER model on German text underperforms silently. And measure recall on your own labelled sample rather than trusting a published figure — a detector that finds 95% of names in news text may find far fewer in medical shorthand.

Pseudonymise rather than delete

Replacing every entity with [REDACTED] destroys the text for retrieval: a document where four different people become the same token loses the relationships that made it worth retrieving. Consistent pseudonyms preserve structure while removing the identifier.

import hmac, hashlib

# The key lives in a secrets manager and is per-tenant, so tokens cannot
# be correlated across tenants and rotating it invalidates all of them.
def pseudonym(value: str, kind: str, key: bytes) -> str:
    canon = " ".join(value.lower().split())
    tag = hmac.new(key, f"{kind}:{canon}".encode(), hashlib.sha256)
    return f"[{kind.upper()}_{tag.hexdigest()[:8]}]"

# "Dr. Alice Kaur referred Alice Kaur's file to Bob Smith"
#   -> "Dr. [PERSON_3f9a1c04] referred [PERSON_3f9a1c04]'s file
#       to [PERSON_9b27ee15]"

Three properties make this work. It is deterministic, so the same person is the same token in every document and coreference survives. It is keyed, so the mapping cannot be reversed by brute force over a dictionary of names the way a bare hash can. And the token is type-tagged and word-like, so a tokenizer does not shatter it and a model can tell that two different people are involved.

Match the strategy to the field, though. Pseudonymisation is right for identifiers whose identity matters and whose value does not — people, accounts, case numbers. For a field where the value carries meaning the model needs, generalisation is better: a date of birth becomes an age band, a postcode becomes a region, a salary becomes a decile. And for a field that is neither — a bare credential — deletion is the only correct answer, because there is no downstream use for it at all.

Keep the reverse mapping only if you genuinely need re-identification, and if you do, keep it in a separate store with its own access control — because at that point the mapping is the personal data, and the pipeline’s job becomes making sure the two never travel together.

The copies you forgot about

Redacting the main path and leaving these is the most common way a careful design leaks:

  • Application logs. A debug line that logs the prompt logs everything in it. This is the leak that ends up in a third-party log aggregator with a different retention policy and a different access list — logging is its own problem.
  • Caches. A semantic cache stores the query text and the answer. A prompt cache holds a prefix on the provider’s side. Both have retention semantics that are separate from your database’s.
  • Error reports. An exception tracker captures local variables. The variable holding the document text is a local variable.
  • Evaluation sets and fixtures. Test data is copied from production and then lives in a repository that many more people can read, forever.
  • Backups and replicas. Covered in the deletion discussion, and the reason erasure needs a re-application log rather than a single DELETE.

Residency changes the topology

If data must stay in a jurisdiction, that is not a configuration flag; it is a constraint on where each stage runs. The awkward part is usually inference, because the extracted text is what crosses the boundary and it crosses it on every request rather than once.

The design that follows is a split pipeline: extraction, normalisation, chunking and pseudonymisation run inside the boundary; only pseudonymised text leaves it, if anything does. That makes the redaction stage a hard architectural boundary rather than a filter, and it is much easier to argue in a review than a promise that the right function is called on every path. A boundary can be tested: put a canary string in a document inside the boundary and assert that it never appears in any request leaving it. A promise cannot. The residency requirements themselves determine which stages you can place where.

Privacy-Preserving Data Pipelines · Multigrid