Free lesson · GenAI Safety & Evaluation Engineering

Implement reversible PII redaction

You will build a redaction pipeline that masks PII before sending to hosted LLMs and de-redacts afterwards. Create a PIIRedactor that: (1) detects PII in user input, (2) replaces each PII entity with a placeholder: 'John Smith' → '<PERSON_1>', 'john@email.com' → '<EMAIL_1>', (3) stores the mapping in a Redis cache (session-scoped, TTL=1 hour), (4) sends the redacted input to the hosted LLM (OpenAI/Gemini), (5) receives the response, (6) re-inserts original PII values into the response using the stored mapping. Test: send 'Email john@email.com about his order' → LLM sees 'Email <EMAIL_1> about his order' → response 'I've contacted <EMAIL_1>' → user sees 'I've contacted john@email.com'. Benchmark: redaction + de-redaction latency overhead.

Course: GenAI Evaluation, Safety & Governance · Chapter 13 · PII Detection & Redaction

Free to read — no subscription required.

Introduction

When you redact PII before sending user text to an LLM, the easy move — collapsing every name, email, and phone number into a generic [REDACTED] token — destroys the entity identities the model needs to reason about. Two different people become the same opaque marker, and there's no way to restore the originals when the model's response refers back to them. Teams that ship LLM products under privacy review hit this fast: outputs become ambiguous, and the redaction layer turns into a one-way door that breaks every downstream feature relying on the model's text. By the end of this lesson you'll be able to build a reversible redaction pipeline that swaps PII for typed, numbered placeholders, persists the forward-and-reverse mapping in a session-scoped store, and restores originals on the trusted side of the LLM boundary.

Key Terminology

  • Reversible Redaction: Replacing PII with typed, numbered placeholders that can later be substituted back with the original values, preserving semantic context for the LLM while protecting sensitive data in transit.
  • Typed Placeholder: A token of the form <ENTITY_TYPE_N> (e.g. <EMAIL_1>) that encodes both the PII category and a per-type counter, so the LLM can distinguish multiple entities of the same kind within a single prompt.
  • Bidirectional Mapping: The pair of lookup tables — original→placeholder for redaction and placeholder→original for de-redaction — that lets the pipeline restore the exact source values after the LLM responds.

Concepts

The Problem with Irreversible Redaction

Replacing PII with generic markers like [REDACTED] before sending to an LLM destroys semantic context. If a user writes "Email john@email.com and jane@corp.com about their conflicting schedules," and the system redacts both email addresses to [REDACTED], the LLM receives "Email [REDACTED] and [REDACTED] about their conflicting schedules." The LLM cannot differentiate between the two entities and may produce a response that confuses them. Worse, when the response references [REDACTED], the system cannot determine which original value to restore.

Reversible redaction solves this by using typed, numbered placeholders that preserve entity semantics and enable bidirectional mapping.

Loading diagram...

Code Walkthrough

Placeholder Mapping Architecture

The PIIRedactor creates a unique placeholder for each detected PII entity using the pattern <ENTITY_TYPE_N> where N is a monotonically increasing counter per type:

Original ValuePlaceholderEntity Type
John Smith<PERSON_1>PERSON
Jane Doe<PERSON_2>PERSON
john@email.com<EMAIL_1>EMAIL_ADDRESS
jane@corp.com<EMAIL_2>EMAIL_ADDRESS
555-123-4567<PHONE_1>PHONE_NUMBER

The mapping is stored bidirectionally: forward (original to placeholder) for redaction, and reverse (placeholder to original) for de-redaction.

Building the PIIRedactor

The PIIRedactor below mints a typed placeholder for each detected entity via _get_placeholder, swaps PII for placeholders in redact (returning a RedactionResult that carries the redacted text and the placeholder→original mapping), and restores the originals in de_redact. init holds the bidirectional maps and per-type counters that reset clears between sessions. Because the forward and reverse maps live on the instance, the same value seen twice reuses one placeholder, and de-redaction can resolve every occurrence back to a single original.

Code snippetpython
1from dataclasses import dataclass, field 2from typing import Dict, List, Optional, Tuple 3from presidio_analyzer import AnalyzerEngine, RecognizerResult 4 5@dataclass 6class RedactionResult: 7 """Result of a redaction operation.""" 8 original_text: str 9 redacted_text: str 10 mappings: Dict[str, str] # placeholder -> original value 11 entity_count: int 12 entities_detected: List[Dict] 13 14class PIIRedactor: 15 """Reversible PII redactor with typed placeholder mapping.""" 16 17 def __init__(self, analyzer: Optional[AnalyzerEngine] = None): 18 self.analyzer = analyzer or AnalyzerEngine() 19 self._type_counters: Dict[str, int] = {} 20 self._forward_map: Dict[str, str] = {} # original -> placeholder 21 self._reverse_map: Dict[str, str] = {} # placeholder -> original 22 23 def _get_placeholder(self, entity_type: str) -> str: 24 """Generate next numbered placeholder for entity type.""" 25 count = self._type_counters.get(entity_type, 0) + 1 26 self._type_counters[entity_type] = count 27 return f"<{entity_type}_{count}>" 28 29 def redact(self, text: str, entities: Optional[List[str]] = None, 30 score_threshold: float = 0.5) -> RedactionResult: 31 """ 32 Detect PII and replace with typed placeholders. 33 34 Processing order: entities are sorted by position (end offset descending) 35 to ensure replacements don't shift character positions for subsequent 36 entities. This is critical for correct redaction. 37 """ 38 default_entities = [ 39 "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", 40 "CREDIT_CARD", "US_SSN", "IP_ADDRESS" 41 ] 42 results = self.analyzer.analyze( 43 text=text, 44 entities=entities or default_entities, 45 language="en", 46 score_threshold=score_threshold 47 ) 48 49 # Sort by end position descending for safe replacement 50 sorted_results = sorted(results, key=lambda r: r.end, reverse=True) 51 52 redacted = text 53 entities_info = [] 54 55 for result in sorted_results: 56 original_value = text[result.start:result.end] 57 58 # Reuse placeholder if same value was seen before 59 if original_value in self._forward_map: 60 placeholder = self._forward_map[original_value] 61 else: 62 placeholder = self._get_placeholder(result.entity_type) 63 self._forward_map[original_value] = placeholder 64 self._reverse_map[placeholder] = original_value 65 66 redacted = redacted[:result.start] + placeholder + redacted[result.end:] 67 68 entities_info.append({ 69 "entity_type": result.entity_type, 70 "original": original_value, 71 "placeholder": placeholder, 72 "score": result.score, 73 }) 74 75 return RedactionResult( 76 original_text=text, 77 redacted_text=redacted, 78 mappings=dict(self._reverse_map), 79 entity_count=len(entities_info), 80 entities_detected=entities_info, 81 ) 82 83 def de_redact(self, text: str) -> str: 84 """Restore original PII values from placeholders.""" 85 result = text 86 # Sort placeholders by length descending to avoid partial matches 87 for placeholder in sorted(self._reverse_map.keys(), 88 key=len, reverse=True): 89 result = result.replace(placeholder, 90 self._reverse_map[placeholder]) 91 return result 92 93 def reset(self): 94 """Clear all mappings and counters.""" 95 self._type_counters.clear() 96 self._forward_map.clear() 97 self._reverse_map.clear()
  • Importsdataclass/field for the result type, typing helpers, and Presidio's AnalyzerEngine/RecognizerResult for detection.
  • RedactionResult — a @dataclass capturing one redaction pass: the original_text, the redacted_text, the placeholder→original mappings, the entity_count, and the per-entity entities_detected records.
  • PIIRedactor.__init__ — stores the analyzer and initializes the per-type counters plus the forward (original→placeholder) and reverse (placeholder→original) maps.
  • _get_placeholder — increments the counter for an entity type and returns the next numbered token, e.g. <EMAIL_1>.
  • redact — runs the analyzer, sorts detections by end offset descending so substitutions don't shift earlier positions, reuses an existing placeholder when the same value recurs, and returns a RedactionResult.
  • de_redact — replaces placeholders with their originals, longest token first to avoid <PERSON_1> matching inside <PERSON_10>.
  • reset — clears the counters and both maps so the instance can be reused across sessions.

Persisting Mappings and Wiring the Middleware

In a production environment, mappings must persist across microservice boundaries and survive individual request cycles. Redis provides session-scoped TTL storage, and a thin PIIMiddleware then loads the existing mapping at each turn, runs redaction on the input, persists the updated map, and reverses placeholders in the LLM's output. The one-hour TTL satisfies GDPR's storage limitation principle automatically, bounds Redis memory by active-session count, and limits the blast radius of a leaked mapping to a short window — extend_ttl keeps active conversations alive without disturbing the upper bound.

Code snippetpython
1import json 2from typing import Optional 3import redis 4 5class RedisMapping: 6 """Session-scoped PII mapping storage in Redis.""" 7 8 def __init__(self, redis_client: redis.Redis, ttl_seconds: int = 3600): 9 self.redis = redis_client 10 self.ttl = ttl_seconds 11 12 def _key(self, session_id: str) -> str: 13 return f"pii:mapping:{session_id}" 14 15 def store(self, session_id: str, mappings: dict) -> None: 16 self.redis.setex(self._key(session_id), self.ttl, json.dumps(mappings)) 17 18 def retrieve(self, session_id: str) -> Optional[dict]: 19 data = self.redis.get(self._key(session_id)) 20 return json.loads(data) if data is not None else None 21 22 def delete(self, session_id: str) -> bool: 23 """Right-to-erasure hook: drop the mapping for a session.""" 24 return bool(self.redis.delete(self._key(session_id))) 25 26 def extend_ttl(self, session_id: str) -> bool: 27 return bool(self.redis.expire(self._key(session_id), self.ttl)) 28 29class PIIMiddleware: 30 """FastAPI middleware integrating redaction with Redis storage.""" 31 32 def __init__(self, redactor: PIIRedactor, mapping_store: RedisMapping): 33 self.redactor = redactor 34 self.mapping_store = mapping_store 35 36 async def process_input(self, session_id: str, user_input: str) -> str: 37 # Seed redactor state from prior turns so the same entity reuses the same placeholder 38 existing = self.mapping_store.retrieve(session_id) 39 if existing: 40 self.redactor._reverse_map.update(existing) 41 self.redactor._forward_map.update( 42 {v: k for k, v in existing.items()} 43 ) 44 45 result = self.redactor.redact(user_input) 46 self.mapping_store.store(session_id, result.mappings) 47 return result.redacted_text 48 49 async def process_output(self, session_id: str, llm_output: str) -> str: 50 mappings = self.mapping_store.retrieve(session_id) 51 if not mappings: 52 return llm_output 53 54 restored = llm_output 55 for placeholder, original in sorted( 56 mappings.items(), key=lambda x: len(x[0]), reverse=True 57 ): 58 restored = restored.replace(placeholder, original) 59 60 self.mapping_store.extend_ttl(session_id) 61 return restored
  • RedisMapping — session-keyed store with TTL: store writes mappings under pii:mapping:<session_id> with the configured expiry; delete powers right-to-erasure; extend_ttl keeps active conversations alive.
  • PIIMiddleware.process_input — hydrates the redactor's bidirectional maps from Redis so a name that appeared in turn 1 still maps to <PERSON_1> in turn 5, then redacts and re-persists.
  • PIIMiddleware.process_output — replaces placeholders longest-first to avoid <PERSON_1> matching as a prefix of <PERSON_10>, then nudges the TTL forward.

You'll know it works when a request like "Email john@email.com about his order" reaches the LLM as "Email <EMAIL_1> about his order", and the LLM's reply "I've contacted <EMAIL_1>" is restored to "I've contacted john@email.com" before the user sees it — with the same <EMAIL_1> placeholder reused on follow-up turns within the TTL.


Do's and Don'ts

Do's

  1. Do sort detected entities by end offset descending before substitution — the sorted(results, key=lambda r: r.end, reverse=True) call in redact() guarantees each replacement leaves earlier character positions untouched; working left-to-right shifts every subsequent start/end index, silently garbling every entity after the first.
  2. Do check _forward_map before minting a new placeholder — when the same original value (e.g., john@email.com) appears more than once in a request, reusing the existing <EMAIL_1> keeps coreference consistent for the LLM and ensures de_redact resolves every occurrence back to a single, unambiguous original rather than producing orphaned placeholders.
  3. Do sort _reverse_map keys by length descending in de_redact — replacing <PERSON_1> before <PERSON_10> triggers a substring match inside <PERSON_10>, producing corrupted output like JohnSmith_10>; the sorted(..., key=len, reverse=True) ordering in de_redact prevents this partial-match collision entirely.

Don'ts

  1. Don't collapse every entity to a flat [REDACTED] token — as the introduction warns, two distinct people (John Smith and Jane Doe) become the same opaque marker, the LLM loses coreference, and there is no reverse mapping to restore; the typed, numbered pattern <PERSON_1> / <PERSON_2> is what makes de-redaction unambiguous.
  2. Don't share a PIIRedactor instance across user sessions without calling reset()_type_counters, _forward_map, and _reverse_map are instance-level state; without reset(), session B's first detected person is assigned <PERSON_3> because session A's counter already reached 2, and de-redaction in session B silently restores the wrong original value.
  3. Don't pass score_threshold=0.0 to silence Presidio uncertainty — lowering the threshold below the default 0.5 forwards low-confidence spans as redacted placeholders whose entity type may be wrong, producing <CREDIT_CARD_1> for a ZIP code or <PERSON_1> for a product name, which corrupts the LLM's reasoning and breaks de-redaction fidelity.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Safety & Evaluation Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in GenAI Evaluation, Safety & Governance

All free lessons in GenAI Safety & Evaluation Engineering