Wat deze laag doet
Voor verzending naar het AI-model worden namen, bedrijven, adressen en bedragen vervangen door consistente, omkeerbare tokens, overal in de deal dezelfde: ⟦PERSOON_1⟧, ⟦BEDRIJF_2⟧, ⟦BEDRAG_3⟧. Het model ziet dus geen identificerende gegevens. In het antwoord worden de tokens weer teruggezet naar de echte waarden.
De vertaaltabel (token ↔ echte waarde) is het kroonjuweel: die blijft binnen de vertrouwensgrens, versleuteld met de dossiersleutel, en gaat nooit mee naar buiten.
"Kan dit niet met AI?" Jawel, maar alleen lokaal
Als je een externe AI de tekst laat pseudonimiseren, heb je exact hetzelfde lek: de ruwe data verlaat de grens. De oplossing is dat de detectie lokaal draait, op onze eigen infra. Drie smaken, die je combineert:
- Regels en regex, geen ML. Gestructureerde gegevens (IBAN, BSN, e-mail, KvK-nummer, bedragen, datums) vang je met patronen. 100% lokaal, deterministisch.
- Woordenlijsten uit het dossier zelf. Bij een overname ken je de partijen vaak al (uit de intake): die namen vervang je met hoge precisie via een lijst.
- Een lokaal NER-model. Voor vrije-tekst namen gebruik je een lokaal taalmodel (spaCy, Nederlands) dat op onze machine draait. Dat is geen externe AI: de tekst gaat niet het internet op. Het is een klein herkenningsmodel, iets heel anders dan een LLM-API.
Niet "AI ja/nee", maar "verlaat de data de grens ja/nee". Een lokaal herkenningsmodel binnen onze omgeving is prima; een externe LLM-API is precies wat je hier wilt vermijden.
Tokeniseren, verwerken, terugzetten
De mapping bestaat alleen binnen de grens. Alles links en rechts van "AI (Scaleway)" gebeurt op onze eigen infra.
Van scratch: een consistente, omkeerbare tokenizer
De kern zonder frameworks. Belangrijk: dezelfde instantie hergebruiken voor alle documenten in één deal, dan krijgt "Acme Holding B.V." overal hetzelfde token en blijven kruisverwijzingen kloppen.
KNOWN = { "Jan de Vries": "PERSOON", "Acme Holding B.V.": "BEDRIJF" }
import re
PATTERNS = {
"IBAN": re.compile(r"\bNL\d{2}[A-Z]{4}\d{10}\b"),
"EMAIL": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),
"BEDRAG": re.compile(r"€\s?\d[\d.,]*"),
"KVK": re.compile(r"\bKvK[- ]?\d{8}\b"),
}
class Pseudonymizer:
def __init__(self):
self.forward = {} # echte waarde -> token
self.reverse = {} # token -> echte waarde (= de kluis)
self.n = {}
def _token(self, value, kind):
if value in self.forward: # zelfde waarde -> zelfde token
return self.forward[value]
self.n[kind] = self.n.get(kind, 0) + 1
tok = f"⟦{kind}_{self.n[kind]}⟧"
self.forward[value] = tok
self.reverse[tok] = value
return tok
def pseudonymize(self, text):
for value, kind in sorted(KNOWN.items(), key=lambda kv: -len(kv[0])):
text = text.replace(value, self._token(value, kind))
for kind, pat in PATTERNS.items():
text = pat.sub(lambda m: self._token(m.group(0), kind), text)
return text
def restore(self, text): # tokens in het AI-antwoord terugzetten
for tok, value in self.reverse.items():
text = text.replace(tok, value)
return text
De reverse-tabel is wat je per deal versleuteld opslaat. Wat hier nog ontbreekt: vrije-tekst namen die je niet vooraf kent. Daarvoor komt het lokale NER-model erbij.
Met Presidio: regex + lokaal NER + omkeerbare mapping
Microsoft Presidio is de open-source standaard. De analyzer combineert regex, context en een lokaal spaCy/transformer NER-model (ook Nederlands); de anonymizer vervangt via operators; de deanonymizer zet terug zolang de mapping bewaard is. Alles draait lokaal.
pip install presidio-analyzer presidio-anonymizer
python -m spacy download nl_core_news_lg # Nederlands NER-model, lokaal
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.operators import Operator, OperatorType
# 1. Analyzer met een LOKAAL Nederlands NER-model (niets gaat naar buiten).
nlp = NlpEngineProvider(nlp_configuration={
"nlp_engine_name": "spacy",
"models": [{"lang_code": "nl", "model_name": "nl_core_news_lg"}],
}).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp, supported_languages=["nl"])
# 2. Custom operator: geef elke unieke waarde een consistent token
# en hou de mapping bij (dit is de kluis).
class Tokenize(Operator):
def operate(self, text, params):
mapping, n = params["mapping"], params["counter"]
kind = params["entity_type"]
if text in mapping["fwd"]:
return mapping["fwd"][text]
n[kind] = n.get(kind, 0) + 1
tok = f"⟦{kind}_{n[kind]}⟧"
mapping["fwd"][text] = tok
mapping["rev"][tok] = text
return tok
def validate(self, params): pass
def operator_name(self): return "tokenize"
def operator_type(self): return OperatorType.Anonymize
anonymizer = AnonymizerEngine(); anonymizer.add_anonymizer(Tokenize)
def pseudonymize(text, mapping, counter):
results = analyzer.analyze(text=text, language="nl") # PERSON, ORG, IBAN, ...
return anonymizer.anonymize(text=text, analyzer_results=results,
operators={"DEFAULT": OperatorConfig("tokenize",
{"mapping": mapping, "counter": counter})}).text
def restore(text, mapping): # terugzetten met de bewaarde mapping
for tok, value in mapping["rev"].items():
text = text.replace(tok, value)
return text
Presidio levert ook een kant-en-klaar pseudonymization-voorbeeld met mapping. Voeg custom recognizers toe voor NL-specifiek (BSN, KvK) en voor de bekende partijen uit het dossier.
Nauwkeurigheid en veilig falen
Geen enkele detector is 100%. Een NER-model mist soms een naam (recall typisch 85 tot 95%). Wie 100% belooft, wordt betrapt. Zo kom je toch dicht bij 100% op wat telt:
- Bekende partijen via woordenlijst, niet NER. De target, verkoper, koper en sleutelpersonen ken je uit de intake. Exacte matching is deterministisch.
- Gestructureerde data via regex. IBAN, BSN, e-mail, KvK, bedragen: patronen, geen model.
- Instellen op veilig falen. Optimaliseer op recall (liever te veel maskeren dan te weinig); bij lage zekerheid blokkeren of laten reviewen.
- Menselijke check voor de eerste externe verzending per deal, en de recall meetbaar maken op testdossiers.
De basis maakt het al veilig: encryptie (Jan's sleutel) + een dedicated EU-verwerker onder DPA die de data niet mag gebruiken. Pseudonimisering komt daar bovenop en verkleint de schade als er ooit iets doorheen glipt. Zelfs als de detector een naam mist, gaat die naar een contractueel gebonden EU-verwerker, niet het open internet op. Zo is die 85 tot 95% geen zwakte maar een extra slot.
Randgevallen en hoe je ze afdekt
| Risico | Aanpak |
|---|---|
| Gemiste entiteit (NER mist een naam) | Bekende partijen via woordenlijst; conservatieve drempel; bij twijfel blokkeren of laten reviewen. |
| Over-maskeren breekt de betekenis | Type-behoudende tokens (PERSOON/BEDRIJF/BEDRAG) zodat de AI de rol nog snapt. |
| Bedragen nodig voor rekenchecks | Consistente tokens behouden gelijk/ongelijk; voor echt rekenen die stap binnen de grens houden. |
| Gescande of handgeschreven documenten | Eerst lokaal OCR, daarna pas pseudonimiseren. |
| Taal en juridisch jargon | Nederlands spaCy-model + custom recognizers; testen op echte testdossiers. |
Omkeerbare pseudonimisering blijft een persoonsgegeven onder de AVG, het is een waarborg, geen ontsnapping. De verwerkersovereenkomst met Scaleway blijft dus nodig. En een lek van de data en de mapping samen is meldingsplichtig. Bescherm de mapping-kluis als het gevoeligste onderdeel van het hele systeem.
Aanbevolen opzet
- Waar: in de verwerkings-laag (confidential gateway), binnen de vertrouwensgrens, voor Scaleway.
- Detectie: regex + woordenlijst (bekende partijen) + lokaal spaCy NL NER via Presidio, alles lokaal.
- Tokens: consistent en type-behoudend; één mapping per deal.
- Mapping: versleuteld met de dossiersleutel, blijft binnen de grens, verlaat die nooit.
- LLM-agnostisch: werkt met Scaleway Managed Inference, en later met elk ander model.
Hoort bij de technische architectuur. Concept ter bespreking.