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.
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 Value | Placeholder | Entity 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()
- Imports —
dataclass/fieldfor the result type,typinghelpers, and Presidio'sAnalyzerEngine/RecognizerResultfor detection. RedactionResult— a@dataclasscapturing one redaction pass: theoriginal_text, theredacted_text, the placeholder→originalmappings, theentity_count, and the per-entityentities_detectedrecords.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 aRedactionResult.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:storewrites mappings underpii:mapping:<session_id>with the configured expiry;deletepowers right-to-erasure;extend_ttlkeeps 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
- ✓Do sort detected entities by
endoffset descending before substitution — thesorted(results, key=lambda r: r.end, reverse=True)call inredact()guarantees each replacement leaves earlier character positions untouched; working left-to-right shifts every subsequentstart/endindex, silently garbling every entity after the first. - ✓Do check
_forward_mapbefore 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 ensuresde_redactresolves every occurrence back to a single, unambiguous original rather than producing orphaned placeholders. - ✓Do sort
_reverse_mapkeys by length descending inde_redact— replacing<PERSON_1>before<PERSON_10>triggers a substring match inside<PERSON_10>, producing corrupted output likeJohnSmith_10>; thesorted(..., key=len, reverse=True)ordering inde_redactprevents this partial-match collision entirely.
Don'ts
- ✗Don't collapse every entity to a flat
[REDACTED]token — as the introduction warns, two distinct people (John SmithandJane 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. - ✗Don't share a
PIIRedactorinstance across user sessions without callingreset()—_type_counters,_forward_map, and_reverse_mapare instance-level state; withoutreset(), 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. - ✗Don't pass
score_threshold=0.0to silence Presidio uncertainty — lowering the threshold below the default0.5forwards 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
- Ch 10Detect cost anomalies and spending spikes
- Ch 10Build cost governance dashboard and chargeback
- Ch 12Compare guardrail frameworks: Guardrails AI vs NeMo Guardrails 0.20 vs NemoGuard NIMs vs Google Model Armor
- Ch 13Detect PII with Presidio and Google Sensitive Data Protection
- Ch 13Implement reversible PII redactionYou are here
- Ch 13Build custom PII recognizers for domain data
- Ch 16Validate agent tool calls against permission policies