
Le texte extrait d'une page web ou d'un PDF n'est pas indexable tel quel. Il traîne des caractères invisibles, des espaces multiples, du HTML résiduel, un encodage Unicode incohérent. Ce bruit dégrade silencieusement un RAG : deux textes identiques au sens mais différents au caractère près produisent des embeddings différents, et la recherche rate des passages pertinents. Ce guide construit un pipeline de nettoyage déterministe — normalisation Unicode, retrait du HTML, suppression des invisibles, normalisation des espaces — puis une segmentation en phrases. Tout en bibliothèque standard, sans modèle ni dépendance. Public visé : développeur qui prépare un corpus entre l'extraction et le chunking.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Pourquoi un texte non nettoyé dégrade les embeddings.
- Normaliser l'encodage Unicode vers une forme canonique.
- Retirer le HTML résiduel et les caractères invisibles.
- Normaliser les espaces et les lignes vides.
- Segmenter un texte en phrases sans modèle NLP lourd.
Prérequis
Section intitulée « Prérequis »- Python 3.10+ — aucune autre dépendance, le nettoyage tient en bibliothèque standard.
- Avoir extrait un corpus à nettoyer.
Pourquoi nettoyer le texte avant l'indexation
Section intitulée « Pourquoi nettoyer le texte avant l'indexation »Un embedding transforme un texte en vecteur : c'est lui qui décide si deux passages « se ressemblent ». Le modèle qui le calcule travaille caractère par caractère — il ne devine pas qu'un espace insécable et un espace normal sont équivalents, ni qu'un <p> traîné est sans importance.
Conséquence : deux textes que vous lisez comme identiques peuvent produire des vecteurs différents s'ils diffèrent par un caractère invisible ou une balise résiduelle. La recherche se retrouve faussée en amont, sans aucun message d'erreur — le RAG marche moins bien, et on ne sait pas pourquoi.
Le nettoyage règle ce problème avant l'indexation. C'est une étape mécanique, sans intelligence artificielle, mais elle conditionne la qualité de tout ce qui suit.
Normaliser l'encodage Unicode
Section intitulée « Normaliser l'encodage Unicode »Un même caractère accentué peut s'écrire de deux façons en Unicode. « é » peut être un seul point de code (forme composée), ou deux — un « e » suivi d'un accent combinant (forme décomposée). À l'écran, c'est identique ; pour le modèle, ce sont deux séquences différentes.
La normalisation NFC ramène tout le texte à la forme composée canonique — la représentation unique et stable.
import unicodedata
def normaliser_unicode(texte: str) -> str: """Ramène le texte à une forme Unicode canonique (NFC).""" return unicodedata.normalize("NFC", texte)Sur un « é » décomposé — deux caractères — la sortie est un « é » composé — un seul. Appliquée à tout le corpus, cette étape garantit que des textes visuellement identiques le sont aussi octet pour octet.
Retirer le HTML résiduel
Section intitulée « Retirer le HTML résiduel »Même après une extraction soignée, des fragments de balises survivent parfois — un <span> oublié, un <br> isolé. Une expression régulière simple les retire.
import re
_BALISES_HTML = re.compile(r"<[^>]+>")
def retirer_html(texte: str) -> str: """Supprime les balises HTML résiduelles.""" return _BALISES_HTML.sub("", texte)Le motif <[^>]+> reconnaît toute séquence ouverte par <, fermée par >, sans > au milieu. C'est suffisant pour des résidus d'extraction — il ne s'agit pas de parser du HTML complet, seulement de nettoyer des miettes.
Supprimer les caractères invisibles
Section intitulée « Supprimer les caractères invisibles »C'est le piège le plus sournois. Certains caractères ne s'affichent pas mais existent dans le texte : l'espace de largeur nulle (zero-width space), la marque d'ordre des octets (BOM), le trait d'union conditionnel (soft hyphen). Ils s'invitent au copier-coller et à l'extraction.
import re
# Zero-width (200b-200f), marques de direction (202a-202e),# BOM (feff), soft hyphen (00ad)._INVISIBLES = re.compile(r"[--]")
def retirer_invisibles(texte: str) -> str: """Supprime les caractères invisibles (zero-width, BOM, soft hyphen).""" return _INVISIBLES.sub("", texte)Normaliser les espaces
Section intitulée « Normaliser les espaces »Le texte extrait abonde en espaces parasites : tabulations, espaces multiples, lignes vides en série. Ils n'ont aucun sens pour la recherche et gonflent inutilement les chunks.
import re
_ESPACES = re.compile(r"[ \t]+")_LIGNES_VIDES = re.compile(r"\n\s*\n+")
def normaliser_espaces(texte: str) -> str: """Réduit les espaces multiples et les lignes vides surnuméraires.""" texte = _ESPACES.sub(" ", texte) texte = _LIGNES_VIDES.sub("\n\n", texte) lignes = [ligne.strip() for ligne in texte.splitlines()] return "\n".join(lignes).strip()Trois passes : les espaces et tabulations consécutifs deviennent un espace unique ; les lignes vides en série se réduisent à une seule séparation ; chaque ligne est rognée de ses espaces de début et de fin. Le résultat est un texte aéré mais sans gras superflu.
Assembler le pipeline de nettoyage
Section intitulée « Assembler le pipeline de nettoyage »Les quatre fonctions s'enchaînent dans un ordre qui compte. On normalise l'Unicode d'abord, on retire le HTML, puis les invisibles, et on termine par les espaces — nettoyer les espaces en dernier rattrape les trous laissés par les étapes précédentes.
def nettoyer(texte: str) -> str: """Pipeline de nettoyage complet — l'ordre des étapes compte.""" texte = normaliser_unicode(texte) texte = retirer_html(texte) texte = retirer_invisibles(texte) texte = normaliser_espaces(texte) return texteSur un texte chargé de balises, d'un zero-width space et d'espaces en trop, le résultat est net :
Avant : ' <p>Les volumes Docker</p> conservent les données. \n\n\n...'Après : 'Les volumes Docker conservent les données.\n\nIls survivent à...'Segmenter en phrases
Section intitulée « Segmenter en phrases »Le chunking par phrases — vu à l'étape suivante — a besoin de découper le texte en phrases. Une segmentation parfaite demande un modèle NLP ; une segmentation suffisante pour préparer un corpus tient en une expression régulière.
import re
# Ponctuation forte, suivie d'un espace et d'une majuscule._FIN_PHRASE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÀ-ÖØ-Þ])")
def segmenter_phrases(texte: str) -> list[str]: """Découpe un texte en phrases (segmentation simple par ponctuation).""" texte = " ".join(texte.split()) if not texte: return [] return [p.strip() for p in _FIN_PHRASE.split(texte) if p.strip()]Le motif coupe sur une ponctuation forte — ., !, ? — suivie d'un espace et d'une majuscule. Cette double condition évite de couper sur une abréviation suivie d'un mot en minuscule. Ce n'est pas infaillible, mais c'est déterministe, sans téléchargement de modèle, et largement assez pour alimenter un chunking par phrases.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
UnicodeDecodeError à la lecture | Fichier dans un autre encodage | Lire en encoding="utf-8", sinon détecter |
| Caractères bizarres après nettoyage | Texte non normalisé en NFC | Appliquer normaliser_unicode en premier |
| Recherche qui rate des doublons | Caractères invisibles non retirés | Vérifier le texte avec repr() |
| Phrases mal coupées | Abréviations, décimales | Affiner la regex ou passer à spaCy |
| Chunks anormalement longs | Espaces et lignes vides non réduits | Appliquer normaliser_espaces |
À retenir
Section intitulée « À retenir »- Un texte non nettoyé dégrade les embeddings sans message d'erreur — le RAG marche moins bien sans qu'on sache pourquoi.
- La normalisation NFC garantit que des textes visuellement identiques le sont aussi pour le modèle.
- Les caractères invisibles sont le piège silencieux : on les retire par principe.
- L'ordre du pipeline compte : Unicode, HTML, invisibles, puis espaces.
- Une segmentation en phrases par regex est déterministe et suffisante pour préparer le chunking.
- Tout ce nettoyage tient en bibliothèque standard — aucune dépendance.