Nettoyage des données pour RAG de production : encodage, OCR, suppression des doublons et traitement des PII
Comment transformer des documents d’entreprise analysés en texte prêt à être recherché : réparation de l’encodage, suppression des modèles standard et des doublons, identification des drapeaux OCR à risque, gestion des langues et masquage des données personnelles.
Sources: Data Cleaning and Preprocessing for Production RAG: Building Retrieval-Ready Documents
Imaginez une banque qui a mis en place un assistant à la connaissance capable de traiter 200 000 documents internes et réglementaires. L’ingestion se déroule sans problème : les PDF sont analysés, les circulaires de lutte contre le blanchiment d’argent scannées sont traitées par OCR, les fichiers SharePoint arrivent à l’heure prévue, et les tableaux sont extraits séparément. Rien ne semble manquer dans ce corpus.
Pourtant, les réponses sont inégales. Un responsable de la conformité demande quels documents sont nécessaires pour une diligence renforcée concernant un client corporate à haut risque. L’outil de recherche retourne cinq extraits. Trois contiennent la politique KYC pertinente, tandis que deux présentent un texte presque identique provenant d’une version plus ancienne du document. Chaque extrait commence par le même en-tête :
CONFIDENTIAL - INTERNAL USE ONLY
Group Financial Crime Compliance
Page 47 of 132
Un fragment contient également la phrase « AII high-risk corporate customers must undergo enhanced due diIigence. » Regardez bien : All est devenu AII et diligence est devenu diIigence, car l’OCR a confondu une lettre minuscule l et une majuscule I.
Ailleurs dans le même fichier, un espace non brisant a survécu à l’extraction, une apostrophe Windows-1252 a été décodée incorrectement, et ce pied de page apparaît 132 fois car il se trouvait sur chaque page. La copie archivée de la politique se trouve dans l’index à côté de celle approuvée, puisque SharePoint a conservé les deux.
Techniquement, le pipeline d’ingestion a fonctionné. En termes opérationnels, le corpus est contaminé, et les ingénieurs qui développent leur premier système RAG sous-estiment souvent l’importance de cette différence.
Pourquoi les données contaminées se cachent
Les dommages sont généralement suffisamment subtils pour que rien ne plante. Une séquence de bytes mal formatée ne fera pas tomber une base de données vectorielle. Un paragraphe dupliqué ne provoquera pas l’échec d’une API d’incorporation. Un pied de page répété ne déclenchera aucune alerte, et une erreur d’OCR passant de 1 à l ne stoppera pas le processus d’indexation. Le pipeline incorpore tout sans problème, et les problèmes apparaissent plus tard sous forme d’échecs de récupération.
Les documents dupliqués occupent plusieurs places dans les résultats top-k. Les textes types répétitifs créent une similarité artificielle entre des segments sans rapport. Les erreurs d’OCR modifient des entités cruciales, des pourcentages, des numéros de compte et des limites réglementaires. Les artefacts d’encodage ajoutent des éléments dépourvus de sens pour l’entreprise. Lorsqu’une mauvaise réponse apparaît, chaque composant ultérieur peut avoir accompli sa tâche : le système de récupération a renvoyé ce qui était stocké, le réclassificateur a classé ce qui lui avait été donné, et le LLM a fonctionné à partir du contexte qui lui était parvenu. La panne est survenue avant même ces trois éléments.
Adoptez une politique AML stipulant que les transactions supérieures à 10 000 EUR nécessitent des vérifications supplémentaires sur l’origine des fonds. Lorsque des erreurs d’OCR et une conversion d’encodage défectueuse interviennent, ce qui est indexé devient « EUR l0,000 ». Une personne repère immédiatement l’erreur. En revanche, un modèle d’embedding continue de considérer cette phrase comme étant proche de concepts tels que les seuils de transaction et les vérifications sur l’origine des fonds, ce qui permet une recherche parfaite — et c’est justement cela qui est inquiétant. L’énoncé corrompu parvient au LLM car la recherche sémantique pardonne les imperfections textuelles. Le système trouve alors la section appropriée de la politique, mais contenant une valeur réglementaire erronée.
C’est pourquoi le nettoyage ne peut pas se réduire à text.strip().lower().
Le rôle du nettoyage dans un pipeline RAG
Le nettoyage se situe entre l’ingestion des documents et leur segmentation en blocs, et cette séquence est intentionnelle. Il ne faut pas mesurer la similarité des documents avant d’avoir corrigé les corruption d’encodage évidentes. Il ne faut pas normaliser le texte de manière excessive avant d’avoir détecté sa langue. Il ne faut pas supprimer des clauses juridiques répétées simplement parce qu’elles ressemblent à du texte type. De plus, les corrections OCR peu fiables ne devraient pas modifier silencieusement les obligations de conformité.
Lorsque le texte bruité a été divisé en blocs et intégré, le nettoyage devient beaucoup plus difficile. Les paragraphes dupliqués ont déjà généré des vecteurs identiques, les mots endommagés ont influencé les limites des blocs, les en-têtes répétés ont épuisé l’espace réservé aux tokens, et les erreurs OCR sont intégrées à la représentation sémantique. Le nettoyage doit avoir lieu avant la segmentation en blocs, et non comme une correction appliquée lorsque la qualité commence à décliner.
Normalisation de l’encodage : réparer le texte en premier
Les collections d’entreprise n’ont que rarement la même histoire de codage. Une politique moderne peut être UTF-8, un archive de correspondance client peut utiliser Windows-1252 tandis que les exports anciens utilisent Latin-1. Le texte copié entre SharePoint, des e-mails, Word et des systèmes de documents obsolètes a pu être encodé et décodé à plusieurs reprises avant d’être intégré. Le résultat est le suivant :
Customer’s identity must be verified before account opening.
The customer must provide proof of address.
Enhanced Due Diligence â€" High Risk Customers
Cette corruption est appelée mojibake ; elle se produit lorsque des octets écrits dans un encodage sont lus avec un autre. On voit immédiatement que customer’s est endommagé. Dans un pipeline de récupération, le token corrompu parvient au tokenizer différemment de customer's, et à grande échelle de telles incohérences fragmentent le vocabulaire et ajoutent une variation inutile à l’espace d’embedding.
Faites attention à la portée des corrections apportées. Une fonction imprudente considère que tout caractère hors ASCII est inutile, or un corpus de paiements européen contient légitimement des termes tels que Société Générale, Deutsche Bundesbank, €10,000 et Überweisung. En supprimant les accents et les symboles, les informations réelles disparaissent. L’objectif n’est pas un texte exclusivement en ASCII, mais bien un Unicode cohérent qui conserve son sens.
Réparation du Unicode à l’aide de ftfy
Vous n’avez pas besoin de cataloguer vous-même chaque motif de mojibake, car ftfy corrige déjà les erreurs courantes de Unicode et d’encodage. Une étape de nettoyage en production peut commencer à ce stade :
import re
import unicodedata
from dataclasses import dataclass, field
import ftfy
@dataclass
class TextCleaningResult:
original_text: str
cleaned_text: str
transformations: list[str] = field(default_factory=list)
quality_flags: list[str] = field(default_factory=list)
def normalize_encoding(text: str) -> TextCleaningResult:
"""
Repair common encoding problems while preserving meaningful Unicode.
Suitable for policy, AML/KYC, payment, and regulatory documents where
accented names, currency symbols, and multilingual text must survive.
"""
transformations: list[str] = []
quality_flags: list[str] = []
if not text:
return TextCleaningResult(original_text=text, cleaned_text=text)
cleaned = text
# Repair mojibake and common Unicode encoding mistakes.
repaired = ftfy.fix_text(cleaned)
if repaired != cleaned:
transformations.append("ftfy_encoding_repair")
cleaned = repaired
# NFC preserves characters while producing a consistent Unicode
# representation. Safer than aggressively stripping accents.
normalized = unicodedata.normalize("NFC", cleaned)
if normalized != cleaned:
transformations.append("unicode_nfc_normalization")
cleaned = normalized
# Replace non-breaking spaces with normal spaces.
if "\u00a0" in cleaned:
cleaned = cleaned.replace("\u00a0", " ")
transformations.append("non_breaking_space_normalization")
# Remove zero-width characters that frequently leak from PDFs,
# web pages, and copied Office content.
zero_width_chars = {"\u200b", "\u200c", "\u200d", "\ufeff"}
if any(char in cleaned for char in zero_width_chars):
cleaned = "".join(char for char in cleaned if char not in zero_width_chars)
transformations.append("zero_width_character_removal")
# Remove control characters; preserve newline and tab because
# they may still carry document structure needed downstream.
cleaned_without_controls = "".join(
char for char in cleaned
if char in "\n\t" or unicodedata.category(char) != "Cc"
)
if cleaned_without_controls != cleaned:
transformations.append("control_character_removal")
cleaned = cleaned_without_controls
# Normalize horizontal whitespace without flattening paragraphs.
whitespace_normalized = re.sub(r"[ \t]+", " ", cleaned)
whitespace_normalized = re.sub(r"\n{3,}", "\n\n", whitespace_normalized)
if whitespace_normalized != cleaned:
transformations.append("whitespace_normalization")
cleaned = whitespace_normalized
cleaned = cleaned.strip()
# Keep suspicious replacement characters observable rather than silently deleting them.
if "\ufffd" in cleaned:
quality_flags.append("unicode_replacement_character_detected")
return TextCleaningResult(
original_text=text,
cleaned_text=cleaned,
transformations=transformations,
quality_flags=quality_flags,
)
Remarquez ce que la fonction laisse intact. Elle ne met pas tout en minuscules, n’enlève pas les accents de Société Générale, ne supprime pas le symbole de l’euro et ne transforme pas le document en une seule ligne. Ces omissions sont intentionnelles.
Pourquoi un nettoyage trop agressif est préjudiciable
De nombreux pipelines NLP plus anciens ont été conçus pour des modèles de type « sac de mots » ou TF-IDF, et ils mettaient tout en minuscules, supprimaient la ponctuation, éliminaient les mots de stop et effectuaient du stemming. Appliquer aveuglément cette méthode avant l’utilisation d’embeddings modernes entraîne la perte d’informations utiles. Pensez à ce que cela ferait à cette phrase :
Customer must NOT be classified as low risk.
Le texte financier présente des exemples encore plus délicats. EUR 10 000 et EUR 100 000 se ressemblent par leur écriture, mais diffèrent considérablement sur le plan de leur signification pratique ; de même, Article 6(1)(a) et Article 6(1)(b) ont une structure identique mais une force juridique différente. La règle de travail consiste à normaliser la présentation de manière stricte tout en préservant le sens. Transformer customer’s en customer's revient à modifier la forme afin de restituer l’intention de l’auteur. Omettre le symbole €, supprimer les accents du nom d’un client ou convertir automatiquement l0% en 10% modifie des informations sur lesquelles l’entreprise pourrait s’appuyer.
Suppression des éléments standard : la structure, pas les mots-clés
Les en-têtes et les pieds de page ne relèvent pas du même type de problème que le bruit d’encodage. La répétition de Page 47 sur 132 est clairement un texte générique, mais une exigence de conformité qui apparaît dans plusieurs sections des politiques peut être répétée intentionnellement et avoir une portée juridique. La fréquence d’apparition d’une ligne ne permet pas de déterminer lequel des deux cas vous avez affaire.
Un meilleur indicateur combine la répétition avec la position. Les en-têtes se trouvent en haut des pages et les pieds de page en bas, tandis que les numéros de page changent légèrement tout en conservant le même schéma structurel :
KYC Policy | Version 7.2 | Page 41 of 132
KYC Policy | Version 7.2 | Page 42 of 132
La logique de correspondance exacte considère deux chaînes différentes, tandis que la logique sensible à la structure voit deux instances d’un même modèle. C’est l’une des raisons pour lesquelles la détection du texte générique fonctionne le mieux tant que les limites des pages existent ; il convient donc d’éviter de transformer un document analysé en une seule grande chaîne immédiatement après son ingestion.
import re
from collections import Counter
from dataclasses import dataclass
@dataclass
class BoilerplatePattern:
normalized_text: str
occurrences: int
page_ratio: float
position: str
def normalize_boilerplate_candidate(line: str) -> str:
"""
Normalize variable fields so structurally identical headers and
footers can be compared across pages.
"""
normalized = line.strip()
normalized = re.sub(
r"\bpage\s+\d+\s+of\s+\d+\b", "page <n> of <n>",
normalized, flags=re.IGNORECASE,
)
normalized = re.sub(r"\bpage\s+\d+\b", "page <n>", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s+", " ", normalized)
return normalized.casefold()
def detect_repeated_marginal_text(
pages: list[dict],
margin_lines: int = 3,
min_page_ratio: float = 0.6,
) -> list[BoilerplatePattern]:
"""
Detect repeated text in page-header or page-footer regions.
A candidate is considered boilerplate only when it appears in the same
marginal position across a substantial fraction of the document.
"""
if not pages:
return []
header_counts: Counter = Counter()
footer_counts: Counter = Counter()
for page in pages:
lines = [line.strip() for line in page["text"].splitlines() if line.strip()]
if not lines:
continue
header_counts.update(normalize_boilerplate_candidate(l) for l in lines[:margin_lines])
footer_counts.update(normalize_boilerplate_candidate(l) for l in lines[-margin_lines:])
total_pages = len(pages)
patterns: list[BoilerplatePattern] = []
for position, counts in (("header", header_counts), ("footer", footer_counts)):
for normalized_text, occurrences in counts.items():
page_ratio = occurrences / total_pages
if page_ratio >= min_page_ratio:
patterns.append(BoilerplatePattern(
normalized_text=normalized_text,
occurrences=occurrences,
page_ratio=page_ratio,
position=position,
))
return patterns
Le seuil min_page_ratio est le critère le plus important. La règle n’est pas « supprimer toute ligne qui apparaît deux fois ». Elle consiste plutôt à considérer comme du texte générique une ligne structuralement similaire qui réapparaît fréquemment en marge de la page dans la majeure partie du document. Dans le cas d’une politique de 100 pages où l’indication CONFIDENTIAL - INTERNAL USE ONLY apparaît en tête de 98 pages, les preuves sont convaincantes.
Un point connexe : retirer quelque chose du texte utilisé pour l’incorporation ne signifie pas nécessairement le supprimer du système. Parfois, les informations doivent être transférées de la couche de contenu sémantique à la couche de métadonnées. Des champs tels que la version, le responsable, la classification et la date d’entrée en vigueur sont mieux conservés sous forme de métadonnées que copiés dans chaque fragment.
Déduplication au-delà des copies exactes
Un document peut être parfaitement encodé, sans dommages dus à l’OCR et débarrassé de texte générique, mais il peut néanmoins poser des problèmes de récupération simplement parce que le corpus le contient plusieurs fois. Les entrepôts d’entreprise en sont pleins :
AML_Policy_v8_Final.docx
AML_Policy_v8_Final_Approved.docx
AML_Policy_v8_Final_Copy.docx
AML_Policy_v8_Approved_2026.docx
Tous les quatre fichiers doivent être indexés en tant que documents distincts, ce qui fait que leurs fragments se retrouvent à concurrencer les uns les autres lors de la recherche. Si vous demandez une surveillance renforcée des clients à haut risque, un seul paragraphe de la politique peut occuper trois emplacements, ne laissant que deux pour des éléments de preuve véritablement différents. Les doublons coûtent plus que de l’espace disque : ils faussent la signification réelle de l’indicateur top-k.
Doublons exacts avec hachage du contenu
import hashlib
from dataclasses import dataclass
@dataclass
class DeduplicationRecord:
document_id: str
source: str
content_hash: str
duplicate_of: str | None = None
def canonicalize_for_hashing(text: str) -> str:
"""
Deterministic normalization before hashing.
Does not lowercase or remove punctuation because those transformations
could collapse documents that are not actually identical.
"""
lines = [" ".join(line.split()) for line in text.splitlines()]
return "\n".join(lines).strip()
def calculate_content_hash(text: str) -> str:
canonical_text = canonicalize_for_hashing(text)
return hashlib.sha256(canonical_text.encode("utf-8")).hexdigest()
def find_exact_duplicates(
documents: list[dict],
) -> tuple[list[dict], list[DeduplicationRecord]]:
"""
Keep one canonical copy of each identical document while preserving
duplicate lineage for auditability.
"""
seen_hashes: dict[str, str] = {}
unique_documents: list[dict] = []
records: list[DeduplicationRecord] = []
for document in documents:
content_hash = calculate_content_hash(document["clean_text"])
if content_hash in seen_hashes:
records.append(DeduplicationRecord(
document_id=document["document_id"],
source=document["source"],
content_hash=content_hash,
duplicate_of=seen_hashes[content_hash],
))
continue
seen_hashes[content_hash] = document["document_id"]
unique_documents.append(document)
records.append(DeduplicationRecord(
document_id=document["document_id"],
source=document["source"],
content_hash=content_hash,
))
return unique_documents, records
Le hash est également utile pour l’indexation incrémentale. Si le même document normalisé apparaît lors de la prochaine opération d’ingestion, son hash est déjà connu, ce qui permet au pipeline de sauter les étapes de nettoyage, de segmentation en blocs et d’incorporation.
Dupliques quasi identiques avec MinHash LSH
Le hachage exact couvre les cas simples. Le cas plus difficile concerne deux versions d’une politique KYC, disons la version 7 et la version 8, qui sont 98 % identiques sur le plan textuel mais diffèrent sur une clause réglementaire. Du point de vue de la conformité, ce ne sont absolument pas des dupliques, car cette dernière clause modifie les exigences de contrôle.
Dans un système RAG régulé, la détection des documents presque identiques devrait d’abord répondre à une question différente, à savoir quels documents sont suffisamment similaires pour mériter une investigation, en laissant la suppression comme décision distincte. Pour 200 000 documents, une comparaison par paire nécessite environ 20 milliards de comparaisons. MinHash avec un hachage sensible à la localité évite cela en créant des signatures compactes et en regroupant celles qui sont similaires dans des buckets candidats.
import re
from datasketch import MinHash, MinHashLSH
def create_word_shingles(text: str, shingle_size: int = 5) -> set[str]:
"""
Five-word shingles work well for long policy and regulatory documents
because they capture local textual structure without being overly
sensitive to isolated formatting changes.
"""
tokens = re.findall(r"\b\w+\b", text.casefold())
if len(tokens) < shingle_size:
return {" ".join(tokens)} if tokens else set()
return {
" ".join(tokens[i:i + shingle_size])
for i in range(len(tokens) - shingle_size + 1)
}
def create_minhash(text: str, num_perm: int = 128) -> MinHash:
shingles = create_word_shingles(text)
minhash = MinHash(num_perm=num_perm)
for shingle in shingles:
minhash.update(shingle.encode("utf-8"))
return minhash
def build_duplicate_index(
documents: list[dict],
threshold: float = 0.85,
num_perm: int = 128,
) -> tuple[MinHashLSH, dict[str, MinHash]]:
"""
Build an LSH index for candidate near-duplicate discovery.
The threshold identifies candidates. It does not automatically
determine whether a document is deleted.
"""
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
signatures: dict[str, MinHash] = {}
for document in documents:
document_id = document["document_id"]
signature = create_minhash(document["clean_text"], num_perm=num_perm)
signatures[document_id] = signature
lsh.insert(document_id, signature)
return lsh, signatures
Lorsque les politiques KYC v7 et v8 se retrouvent dans le même groupe, le pipeline a appris qu’elles méritent un examen plus approfondi, et rien de plus. Il n’a pas appris qu’il fallait en supprimer une. Une décision en environnement de production doit combiner la similarité textuelle avec des métadonnées telles que l’ID de la politique, sa version, la date d’entrée en vigueur, son statut d’approbation et la juridiction concernée, car la similarité seule ne permet pas de trancher.
Erreurs d’OCR : texte qui a l’air correct mais est faux
La réparation de l’encodage corrige les caractères qui ont été stockés de manière incorrecte. Les défauts d’OCR relèvent d’une catégorie distincte. Dans ce cas, le moteur a pu lire un caractère Unicode parfaitement valide avec une grande confiance, mais en choisir simplement un autre à la place. Les confusions les plus fréquentes concernent 0 et O, 1, l et I, 5 et S, ainsi que rn et m.
Les taux d’erreur globaux peuvent être faibles, mais la précision globale n’est pas la mesure qui importe pour les systèmes RAG réglementés. Un document circulaire de 40 pages étant à 99,8 % correct peut néanmoins présenter des valeurs cruciales altérées. Le point architectural essentiel est que les erreurs d’OCR diffèrent en termes de risque. Corriger custorner en customer représente un risque faible, car le contexte et un dictionnaire indiquent fortement le mot souhaité. En revanche, transformer EUR l0,000 en EUR 10,000 relève d’une catégorie complètement différente, car une mauvaise interprétation peut modifier une obligation de conformité.
import re
from dataclasses import dataclass
@dataclass
class OCRIssue:
token: str
issue_type: str
risk_level: str
suggested_value: str | None
requires_review: bool
CURRENCY_PATTERN = re.compile(r"\b(EUR|USD|GBP|INR)\s+([A-Za-z0-9.,]+)\b")
PERCENTAGE_PATTERN = re.compile(r"\b([A-Za-z0-9.,]+)\s*%")
def detect_numeric_ocr_issues(text: str) -> list[OCRIssue]:
"""
Detect suspicious OCR substitutions inside financial values.
This function identifies candidates. It does not silently rewrite
high-risk values in the source text.
"""
issues: list[OCRIssue] = []
substitutions = {"O": "0", "o": "0", "I": "1", "l": "1"}
def inspect_numeric_token(token: str, issue_type: str) -> None:
if token.replace(",", "").replace(".", "").isdigit():
return
corrected = "".join(substitutions.get(char, char) for char in token)
numeric_candidate = corrected.replace(",", "").replace(".", "")
if numeric_candidate.isdigit() and corrected != token:
issues.append(OCRIssue(
token=token,
issue_type=issue_type,
risk_level="high",
suggested_value=corrected,
requires_review=True,
))
for match in CURRENCY_PATTERN.finditer(text):
inspect_numeric_token(match.group(2), issue_type="currency_value")
for match in PERCENTAGE_PATTERN.finditer(text):
inspect_numeric_token(match.group(1), issue_type="percentage")
return issues
La fonction ne modifie pas délibérément le document. Une expression régulière peut indiquer que l0,000 semble suspect dans un contexte monétaire, mais elle ne peut pas prouver que la source indiquait réellement 10,000.
Le vocabulaire ordinaire est mieux géré par un dictionnaire connaissant le domaine concerné. Le texte bancaire nécessite une liste de mots générale ainsi que des termes tels que beneficial ownership, counterparty, SWIFT, IBAN, SEPA et sanctioned. Sinon, un correcteur orthographique standard corrigera volontiers des termes financiers légitimes en versions encore pires.
Dans un environnement de production, l’évaluation de la fiabilité du OCR pèse le niveau de confiance du moteur par rapport à l’importance potentielle d’une erreur. Les lectures fiables de textes peu critiques peuvent généralement être corrigées sans intervention humaine. Les contenus à haut risque, tels que les valeurs monétaires, les pourcentages, les numéros d’articles réglementaires et les dates, doivent faire l’objet d’une révision quel que soit le niveau de confiance du OCR. Un système financier doit viser une correction contrôlée plutôt qu’une automatisation maximale.
Prétraitement multilingue
La détection de langue semble secondaire tant qu’un corpus reste confiné à un seul pays. Un répertoire dédié aux opérations de paiement peut contenir des documents en anglais, des lettres clients en allemand, des avis réglementaires en français et des contrats en espagnol ; de plus, un dossier d’enquête AML peut intégrer plusieurs langues au sein d’un même enregistrement de cas.
La langue influence tout ce qui suit. La correction orthographique en dépend, les schémas d’erreurs de reconnaissance optique de caractères varient selon l’écriture, les entités nommées comportent des accents qui doivent être conservés lors de la normalisation, et le modèle d’embedding lui-même peut se comporter différemment en fonction de la langue indexée.
from dataclasses import dataclass
from langdetect import DetectorFactory, LangDetectException, detect_langs
DetectorFactory.seed = 0 # Makes pipeline output reproducible.
@dataclass
class LanguagePrediction:
language: str
confidence: float
alternatives: list[tuple[str, float]]
needs_review: bool
def detect_document_language(
text: str,
confidence_threshold: float = 0.85,
) -> LanguagePrediction:
sample = text.strip()
if not sample:
return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
try:
predictions = detect_langs(sample)
except LangDetectException:
return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
best = predictions[0]
alternatives = [(p.lang, round(p.prob, 4)) for p in predictions]
return LanguagePrediction(
language=best.lang,
confidence=best.prob,
alternatives=alternatives,
needs_review=best.prob < confidence_threshold,
)
Lorsqu’un document mélange plusieurs langues, détecter au niveau des paragraphes permet d’éviter une interprétation unique pour l’ensemble du fichier. Cela fournit également au module de segmentation un indice sur la structure : si une séparation se fait à travers une frontière linguistique inconnue du module de segmentation, un même morceau peut contenir deux contextes linguistiques non liés.
Certaines équipes traduisent d’abord tout en anglais. Cela simplifie l’index, mais cela ajoute une transformation supplémentaire entre le texte de référence et ce qui est récupéré. De plus, une clause de conformité en allemand peut utiliser des termes ayant une signification juridique précise que la traduction modifie subtilement. Une conception plus solide considère le texte dans sa langue originale comme source de vérité et stocke toute traduction en tant que représentation supplémentaire, avec l’origine enregistrée dans les métadonnées. Les métadonnées linguistiques doivent également être précises :
metadata = {
"primary_language": "en",
"languages": ["en", "de", "fr"],
"language_distribution": {"en": 0.72, "de": 0.21, "fr": 0.07},
"is_multilingual": True,
}
La détection de langue peut également servir de vérification de bon fonctionnement. Supposons qu’une politique AML du Royaume-Uni, prévue en anglais, indique soudainement primary_language = "unknown" avec un confidence = 0.31. Le fichier n’est peut-être pas multilingue ; l’OCR a pu altérer le texte à tel point que le détecteur ne le reconnaît plus.
Données personnelles et données sensibles : détecter avant l’incorporation
Le prétraitement constitue l’une des dernières occasions de contrôler ce qui entre dans l’infrastructure de recherche. Un fichier KYC peut contenir des noms de clients, des numéros de passeport, des IBAN, des numéros de téléphone et des adresses e-mail. Une fois ces données traitées par un service d’incorporation et stockées dans une base de données vectorielle, le problème devient bien plus difficile à résoudre que de sécuriser le document original, car des copies existent désormais dans les requêtes d’incorporation, les vecteurs, les métadonnées, les caches, les index, les journaux et les sauvegardes.
La question n’est pas de savoir si le modèle d’incorporation peut comprendre les PII, car il en est capable. Il s’agit plutôt de savoir si ces informations doivent absolument être incorporées.
Supprimer toutes les entités détectées rend effectivement le corpus plus sûr, mais cela gâte la recherche par entité. Si chaque nom d’entreprise est remplacé par <ORGANIZATION>, un enquêteur en lutte contre le blanchiment d’argent qui cherche Northbridge Trading GmbH ne trouvera rien. Par conséquent, la solution pratique consiste à définir une politique de traitement pour chaque type d’entité détectée avant d’intervenir. Les noms publics et opérationnels sont généralement conservés, les PII des clients sont masquées, tokenisées ou restreintes, les références internes à l’organisation suivent des règles d’accès, et les identifiants ne touchent jamais l’index.
from dataclasses import dataclass, field
from presidio_analyzer import AnalyzerEngine
@dataclass
class SensitiveEntity:
entity_type: str
start: int
end: int
score: float
original_value: str
@dataclass
class PIIScanResult:
entities: list[SensitiveEntity] = field(default_factory=list)
needs_review: bool = False
class BankingPIIDetector:
def __init__(self) -> None:
self.analyzer = AnalyzerEngine()
def detect(
self,
text: str,
language: str = "en",
score_threshold: float = 0.70,
) -> PIIScanResult:
"""
Detect sensitive entities without modifying the source text.
Detection is deliberately separated from anonymization because
different entity types require different handling policies.
"""
results = self.analyzer.analyze(
text=text, language=language, score_threshold=score_threshold
)
entities = [
SensitiveEntity(
entity_type=r.entity_type,
start=r.start,
end=r.end,
score=r.score,
original_value=text[r.start:r.end],
)
for r in results
]
high_risk_types = {"CREDIT_CARD", "IBAN_CODE", "US_SSN", "IP_ADDRESS"}
return PIIScanResult(
entities=entities,
needs_review=any(e.entity_type in high_risk_types for e in entities),
)
Remplacer les valeurs par des placeholders tapés fonctionne généralement mieux pour RAG que de les effacer :
Customer IBAN: <IBAN>
Customer Email: <EMAIL_ADDRESS>
La phrase conserve sa structure porteuse de sens, de sorte qu’une question sur l’endroit où l’IBAN d’un client est vérifié peut toujours correspondre à du texte contenant <IBAN>. Lorsque les relations entre les mentions doivent être préservées, les tokens déterministes sont préférables aux placeholders génériques. CUSTOMER_7F29A1 qui apparaît dans 40 enregistrements de cas permet de maintenir une trace d’enquête intacte, tandis que remplacer chaque nom par <PERSON> la détruirait.
Les métadonnées constituent une fuite facilement oubliée. Le texte principal peut être censuré avec soin tout en conservant dans ses champs des informations telles que customer_name: "Lena Hoffmann", ou un chemin source comme /customers/Lena_Hoffmann/KYC/passport_review.pdf. Par conséquent, les contrôles des données sensibles doivent couvrir tout ce qui est indexé, et non seulement le texte.
Un pipeline de niveau production
Individual cleaning functions are enough while you're experimenting. In production a stricter standard applies: a document must not move on to chunking just because a chain of steps ran without an exception. The pipeline has to record what it altered, what still looks suspicious, whether sensitive data turned up, whether the file duplicates another, and whether the end result deserves to be indexed.
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class QualityStatus(str, Enum):
PASS = "pass"
REVIEW = "review"
REJECT = "reject"
@dataclass
class Transformation:
stage: str
operation: str
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class QualityFlag:
code: str
severity: str
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class RetrievalReadyDocument:
document_id: str
source: str
raw_text: str
clean_text: str
language: str | None = None
language_confidence: float | None = None
content_hash: str | None = None
duplicate_of: str | None = None
transformations: list[Transformation] = field(default_factory=list)
quality_flags: list[QualityFlag] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
status: QualityStatus = QualityStatus.PASS
La différence entre raw_text et clean_text est fondamentale. Le premier indique ce que l’ingestion a réellement extrait, tandis que le second précise quelle représentation nous sommes prêts à fragmenter et à intégrer. Un journal de transformation explique comment l’un est devenu l’autre, et cette traçabilité est inestimable lorsqu’une erreur de récupération est signalée six mois plus tard.
import logging
logger = logging.getLogger(__name__)
class ProductionDocumentCleaner:
def __init__(
self,
pii_detector: BankingPIIDetector,
pii_anonymizer,
redact_pii_for_embeddings: bool = True,
) -> None:
self.pii_detector = pii_detector
self.pii_anonymizer = pii_anonymizer
self.redact_pii_for_embeddings = redact_pii_for_embeddings
def clean(self, document: dict) -> RetrievalReadyDocument:
result = RetrievalReadyDocument(
document_id=document["document_id"],
source=document["source"],
raw_text=document["text"],
clean_text=document["text"],
metadata=document.get("metadata", {}).copy(),
)
try:
self._normalize_text(result)
self._detect_language(result)
self._validate_ocr(result)
self._handle_sensitive_data(result)
self._calculate_hash(result)
self._apply_quality_gate(result)
except Exception as exc:
logger.exception("Preprocessing failed for document %s", result.document_id)
result.quality_flags.append(QualityFlag(
code="PREPROCESSING_EXCEPTION",
severity="critical",
details={"error": str(exc)},
))
result.status = QualityStatus.REJECT
return result
def _apply_quality_gate(self, document: RetrievalReadyDocument) -> None:
severities = {flag.severity for flag in document.quality_flags}
if "critical" in severities:
document.status = QualityStatus.REJECT
elif "high" in severities:
document.status = QualityStatus.REVIEW
else:
document.status = QualityStatus.PASS
La porte de sortie indique l’un des trois états explicites. PASS permet au document de continuer son traitement en blocs, sans anomalies à haut risque non résolues. REVIEW signifie que du contenu utilisable a été obtenu, mais certaines transformations nécessitent une validation humaine avant indexation. REJECT indique que le résultat n’est pas suffisamment fiable pour être indexé. Un document numérisé présentant un seuil l0% douteux est classé en REVIEW ; un PDF ne contenant que 17 caractères illisibles sur 80 pages est probablement classé en REJECT ; et une politique dont les seules corrections concernaient des apostrophes et des espaces est considérée comme PASS.
Les métriques relatives à l’étape de nettoyage sont également utiles à conserver. Si text_reduction_ratio supprime normalement 4-8 % d’un document pour atteindre 63 % après une mise en production, cela signifie qu’il y a un problème. Si unknown_language_rate passe de 0,3 % à 18 % suite à une mise à jour d’une bibliothèque OCR, c’est que le détecteur révèle une dégradation plus en amont du processus. De tels chiffres rendent le nettoyage visible et gérable, au lieu de rester une opération invisible effectuée par un script.
Compromis en environnement de production
Aucune configuration de nettoyage ne convient à tout le monde, et les choix pris ont des conséquences réelles.
- Réparation de l’encodage.
ftfypermet une première correction rapide et efficace pour les problèmes courants de mojibake ou de dommages Unicode. Considérez-le comme un outil complémentaire à la normalisation Unicode déterministe, et non comme une autorisation de réécrire le texte à volonté. - Déduplication. Le hachage SHA-256 est presque gratuit et permet de gérer les doublons exacts avant d’employer des techniques de similarité plus coûteuses. MinHash LSH est utile pour trouver des doublons quasi identiques à grande échelle, mais son seuil doit produire des candidats et non des décisions de suppression.
- Correction OCR. La recherche dans un dictionnaire coûte peu et fonctionne de manière prévisible avec les mots courants. Un LLM capable de comprendre le contexte peut gérer des cas plus difficiles, mais un modèle génératif utilisé en prétraitement entraîne des coûts supplémentaires ainsi qu’un risque accru de réécritures plausibles. Pour les documents soumis à des réglementations, il convient de le laisser proposer des corrections dans un format structuré à valider par un réviseur, plutôt que de lui permettre d’éditer des documents sans les avoir vus.
- Gestion multilingue. La détection du document dans son intégralité est peu coûteuse et suffisante pour l’acheminement. La détection au niveau des paragraphes convient mieux aux fichiers mixtes de langues, mais à un coût plus élevé ; le choix dépend de la répartition réelle des langues et des critères d’évaluation de la récupération. Le terme « multilingue » indiqué sur une fiche de modèle ne dit pas grand-chose quant à la capacité à récupérer des termes AML en allemand à partir d’une question de conformité en anglais.
- PII. Une suppression stricte des données sensibles réduit les risques, mais empêche les recherches par entité. Souvent, une meilleure conception consiste à séparer la recherche vectorielle sémantique d’une recherche par correspondance exacte sécurisée pour les identifiants, afin qu’un seul index ne doive pas résoudre les deux problèmes en même temps.
Le fil conducteur de tout cela : laissez les exigences de récupération et le risque de déformation du sens guider les décisions de prétraitement, sans vous soucier de l’aspect « propre » du texte nettoyé.
Retour à la politique initiale
Revenons à cette politique KYC initiale. Le processus d’ingestion l’a extraite avec succès, et le pipeline de nettoyage effectue désormais bien plus d’opérations avant de la permettre d’entrer dans l’index. Il répare les encodages défectueux sans supprimer les caractères multilingues légitimes, élimine les en-têtes de page répétés tout en conservant les métadonnées du document, détecte les copies exactes stockées dans différents répertoires, et considère les versions anciennes de la politique comme étant liées plutôt que comme des éléments à supprimer aveuglément. Il marque EUR l0,000 comme une anomalie OCR grave et refuse de deviner en silence, reconnaît les sections multilingues, détecte les identifiants clients sensibles avant qu’ils n’atteignent la couche d’incorporation, enregistre chaque transformation, et décide si le résultat doit être accepté, soumis à révision ou rejeté.
C’est seulement alors que le document entre dans l’étape de segmentation. L’ordre est important, car la segmentation ne peut pas récupérer des informations déjà détruites lors du prétraitement, et les embeddings ne peuvent pas distinguer des connaissances fiables de corruption introduite dans le corpus sans contrôle.
Après le nettoyage, un autre problème se pose : où couper une politique AML de 80 pages propre ? Il est très simple de créer une fenêtre de 500 tokens, mais celle-ci peut séparer une règle de son exception, enlever le titre d’une table, ou rassembler des clauses non liées simplement parce qu’elles partagent le même budget de tokens. Les approches de segmentation vont des méthodes à taille fixe et récursives aux méthodes sémantiques, tenant compte de la structure, ainsi qu’aux approches au niveau des propositions ou des relations parent-enfant ; chacune mérite un traitement distinct.
Une liste de vérification avant la segmentation
Avant qu’un fichier ne passe à l’étape de segmentation et d’incorporation, la phase de prétraitement doit être en mesure de fournir une réponse claire à chacune des questions suivantes :
- L’encodage et la normalisation Unicode ont-ils été effectués sans supprimer de caractères ayant une signification ?
- Les caractères de contrôle, les caractères sans largeur, les en-têtes et pieds de page répétés, ainsi que d’autres éléments perturbateurs pour la recherche ont-ils été gérés ?
- Des doublons exacts ou quasi-exacts ont-ils été détectés, tout en laissant intactes les véritables versions du document ?
- Les lectures OCR douteuses concernant des montants, des pourcentages, des dates, des identifiants et des références juridiques sont-elles signalées à quelqu’un, plutôt que corrigées en silence ?
- La langue a-t-elle été détectée avec une granularité adéquate, y compris pour les contenus multilingues ?
- Les informations sensibles ont-elles été classées conformément à la politique de sécurité de l’application avant incorporation ?
- Chaque modification significative peut-elle être retracée jusqu’au texte initiallement analysé ?
- Y a-t-il un verdict explicite de VALIDÉ, REVU ou REJETÉ avant le début du découpage ?
Si une réponse est ambiguë, le document n’est pas prêt pour la récupération.
Si une politique endommagée a déjà été divisée en 80 fragments, intégrée, copiée dans un index vectoriel, mémorisée en cache et servie par une application en ligne, réparer le fichier source ne suffit plus. Chaque fragment dérivé doit être localisé, ses vecteurs supprimés, les caches invalidés, le document retraité, et l’élimination des représentations obsolètes vérifiée. Rejeter les connaissances erronées est le moyen le moins coûteux avant qu’elles ne deviennent des embeddings.
Le code présenté vise à être clair et correct. Pour un environnement de production, il convient d’ajouter un enregistrement structuré des événements, une gestion de la configuration et des secrets, des traces d’audit persistantes pour les transformations, des métriques de prétraitement, des mécanismes de récupération en cas d’échec, ainsi qu’une révision humaine pour les corrections à haut risque. Dans les environnements réglementés, toute transformation destructrice doit être traçable jusqu’à la source originale.