Anonymiser un texte avant de l'envoyer a un LLM — pourquoi, comment, limites
Quand on travaille dans un domaine sensible (banque, sante, juridique), envoyer du texte brut a un LLM externe n'est pas une option. Voici comment j'ai construit un anonymiseur reversible, ce qui marche, et ce qui ne marche pas.
Je travaille pour une banque cantonale suisse. J'ai aussi quelques clients dans le juridique. Dans les deux cas, le besoin "j'aimerais utiliser un LLM puissant pour resumer ce dossier" se heurte a un mur : on ne peut pas envoyer le contenu en clair a un service externe. Secret bancaire, secret professionnel, RGPD, contrats clients — chaque domaine a ses raisons, le resultat est le meme.
J'ai donc construit un petit outil d'anonymisation reversible. Cet article documente l'approche, ce qui marche en pratique, et les limites qu'il faut connaitre avant de l'utiliser en serieux.
L'idee de base
Le principe est simple sur le papier :
- Avant l'envoi : on remplace toutes les entites sensibles du texte par des placeholders.
- Envoi au LLM : seul le texte anonymise sort vers l'API externe.
- A la reception : on remplace les placeholders de la reponse par les valeurs d'origine.
Concretement :
Texte source :
"Jean Dupont (jean.dupont@example.ch) a vire CHF 12'500 sur le
compte CH93 0076 2011 6238 5295 7 de Marie Martin le 12/04/2026."
Texte anonymise (envoye au LLM) :
"[PERSON_1] ([EMAIL_1]) a vire [MONEY_1] sur le compte [IBAN_1]
de [PERSON_2] le [DATE_1]."
Reponse du LLM (resume) :
"[PERSON_1] a effectue un virement de [MONEY_1] vers [PERSON_2]."
Reponse finale (replacee) :
"Jean Dupont a effectue un virement de CHF 12'500 vers Marie Martin."Tout l'enjeu est dans l'etape 1 et 3 : ne rien laisser passer en clair, et savoir reconstruire la reponse sans ambiguite.
L'architecture
┌─────────────┐ ┌──────────────┐ ┌────────────┐
│ Texte source│ → │ Anonymiseur │ → │ LLM externe│
└─────────────┘ │ │ └─────┬──────┘
│ ┌──────────┐ │ │
│ │ Mapping │ │ ▼
│ │ secret │ │ ┌────────────┐
│ └──────────┘ │ ← │ Reponse │
│ │ │ anonymisee │
│ Desanonymise │ └────────────┘
└──────┬───────┘
▼
┌──────────────┐
│ Reponse en │
│ clair │
└──────────────┘Le mapping secret (placeholder ↔ valeur reelle) ne quitte jamais le client. Il vit en RAM pendant la session, ou est stocke en local chiffre si on veut le persister.
Detection des entites : les trois couches
C'est la partie qui prend le plus de temps a faire correctement.
Couche 1 — Regex pour les formats deterministes
Certaines entites ont un format precis et regulier. On peut les capturer en regex avec une fiabilite tres elevee :
- Emails : RFC 5322 (simplifie en pratique)
- IBAN : prefixe pays + 2 digits + code banque + numero, avec validation par checksum (mod 97)
- Numeros AVS suisses :
756.XXXX.XXXX.XX, avec checksum - Numeros de telephone CH :
+41,0xx, formats divers - NIP / NPA : 4 digits suisses
- Montants :
CHF,EUR,USD, avec formats avec/sans separateurs - Dates : ISO + formats locaux (DD.MM.YYYY, DD/MM/YYYY, etc.)
Cette couche est rapide (regex compilees) et deterministe. On ecrit la regex, on teste sur un corpus, on a confiance.
Couche 2 — NER (Named Entity Recognition) pour les entites contextuelles
Pour les noms de personnes, d'organisations, de lieux : pas de format. Il faut un modele.
J'utilise spaCy en local avec un modele francais (fr_core_news_lg). C'est :
- rapide (le texte ne quitte pas la machine),
- gratuit,
- suffisant pour 80-90% des cas avec un modele recent.
import spacy
nlp = spacy.load("fr_core_news_lg")
doc = nlp("Marie Martin travaille a la BCV depuis 2018.")
for ent in doc.ents:
print(ent.text, ent.label_)
# Marie Martin PER
# BCV ORG
# 2018 DATELimites : spaCy rate parfois les noms peu courants, les noms multi-mots (van der Berg), ou inversement attrape des faux positifs (un mot rare avec majuscule au milieu d'une phrase). Donc on ne s'arrete pas la.
Couche 3 — Dictionnaire client custom
Pour chaque client / projet, j'ajoute un dictionnaire d'entites specifiques :
- noms de produits internes,
- noms d'employes,
- noms de projets confidentiels,
- jargon metier que le NER ne reconnait pas.
Ce dictionnaire est applique avant spaCy avec une recherche par mots entiers (boundary-aware). C'est l'etape qui rattrape les cas que les deux couches precedentes ratent.
Le mecanisme de placeholders
Chaque entite detectee est remplacee par un placeholder dont la forme est :
[<TYPE>_<INDEX>]Avec TYPE ∈ {PERSON, EMAIL, IBAN, AVS, PHONE, MONEY, DATE, ORG, LOC, CUSTOM} et INDEX un compteur par type.
Deux regles importantes :
Consistance : si "Jean Dupont" apparait 5 fois, c'est
[PERSON_1]les 5 fois. Pas[PERSON_1],[PERSON_4],[PERSON_7]. Sans ca, le LLM ne peut pas suivre une chaine de coreference et son resume est inutilisable.Reversibilite : on garde le mapping en memoire client. Le format avec crochets est choisi parce qu'il survit aux transformations courantes (resume, traduction, paraphrase) que le LLM peut appliquer.
Ce qui marche bien
Apres quelques semaines d'utilisation sur des dossiers reels (anonymises avant test, evidemment), les retours sont positifs sur :
- Resumes de dossiers : le LLM produit un resume coherent, et la reponse desanonymisee est exploitable.
- Extraction structuree : "extrais-moi les montants et les dates au format JSON" fonctionne bien parce que les placeholders se comportent comme des tokens "valeur" pour le LLM.
- Generation de courriers types : "redige une reponse formelle a ce client" donne quelque chose d'utilisable.
Ce qui ne marche pas (ou mal)
Soyons honnetes — l'approche a des angles morts serieux.
1. Les indirections semantiques
Un texte peut contenir des informations sensibles sans que ces informations soient des entites nommees. Exemple :
"Le client a perdu son travail le mois dernier et envisage de revendre l'appartement qu'il a achete en 2019."
Aucun nom, aucun email, aucun IBAN. Mais c'est extremement sensible. L'anonymiseur ne voit pas cette information parce qu'elle n'est pas une entite typee.
Mitigation : limiter l'anonymiseur a des cas ou le risque residuel apres anonymisation est acceptable. Le secret bancaire ne se reduit pas au numero de compte.
2. La reidentification par recoupement
Meme avec un texte parfaitement anonymise, un attaquant peut recouper :
- la longueur,
- le style,
- les dates relatives,
- les montants approximatifs,
- les references temporelles
...avec une base externe pour reidentifier. C'est documente dans la litterature sur le k-anonymat depuis 20 ans.
Mitigation : si l'objectif est de proteger contre un adversaire motive et informe, l'anonymisation par substitution ne suffit pas. Il faut soit un LLM auto-heberge, soit du calcul homomorphe, soit ne pas externaliser ce texte.
3. Les variations orthographiques
"Jean Dupont", "J. Dupont", "M. Dupont", "JD" : le NER ne lie pas toujours ces formes a la meme entite. Resultat : on anonymise "Jean Dupont" et "J. Dupont" de facon coherente, mais pas "JD" qui passe en clair.
Mitigation : ajouter une etape de coreference resolution. C'est techniquement faisable mais ca alourdit le pipeline. Pour l'instant je m'en passe et je flag manuellement les acronymes douteux.
4. Les contenus copy-paste tabulaires
Un PDF colle qui contient une table de transactions, avec des colonnes mal alignees, casse a peu pres tout. Les regex montant capturent des bouts de date, le NER prend en otage des libelles bancaires, etc.
Mitigation : pre-process serieux du texte avant anonymisation. C'est un boulot a part entiere et il faut le faire.
Architecture du POC
J'ai code une premiere version en Python (FastAPI + spaCy). Deux endpoints :
POST /anonymize
body: { text: "...", custom_dict: ["..."] }
return: { anonymized: "...", session_id: "abc123" }
POST /deanonymize
body: { text: "...", session_id: "abc123" }
return: { text: "..." }Le mapping est stocke en RAM cote serveur, indexe par session_id, avec TTL court (15 minutes). Aucune persistance disque. En production, le LLM serait appele cote client, qui possede la session et donc le mapping.
Stack :
fastapi==0.115.5
spacy==3.8.2
pydantic==2.9.2
phonenumbers==8.13.50 # validation telephone
python-stdnum==1.20 # validation IBAN, AVS, etc.C'est tres simple et c'est volontaire : un outil de cette nature doit etre lisible de bout en bout pour qu'on puisse l'auditer. Pas de magie, pas de framework cache.
Les questions ouvertes
Quelques choses sur lesquelles je n'ai pas encore tranche :
- Stocker le mapping cote client ou cote serveur ? Client = mieux pour la sechurite, plus dur a faire si le LLM est appele depuis le backend. Aujourd'hui je suis cote serveur avec TTL court ; je ne suis pas satisfait.
- Anonymiser aussi les references temporelles relatives ? ("il y a deux semaines") : utile mais casse la coherence narrative que le LLM voit.
- Hash deterministe vs index ? Aujourd'hui c'est
[PERSON_1],[PERSON_2]. Un hash deterministe[PERSON_a1b2]aurait l'avantage que la meme valeur dans deux sessions differentes ait le meme placeholder — utile pour des analyses inter-documents, dangereux pour la confidentialite.
Conclusion
L'anonymisation par substitution est un outil de premiere ligne, pas une solution complete. Elle reduit drastiquement l'exposition de donnees sensibles vers des LLM externes sur les cas usuels (resume, classification, extraction). Elle ne remplace pas un LLM auto-heberge quand le risque residuel est trop eleve.
Pour les cas vraiment sensibles (donnees client banque, dossiers medicaux), je recommande aujourd'hui :
- anonymisation par substitution pour le 80% des cas usuels,
- LLM auto-heberge (Mistral, Llama) pour le 20% restant ou pour les pipelines automatises,
- revue humaine systematique sur les sorties pour les cas critiques.
Si ce sujet t'interesse et que tu veux discuter d'une implementation pour ton contexte, contacte-moi : julien@tscherrig.com.