Aller au contenu
Développement medium

Nettoyer du texte pour un pipeline RAG

10 min de lecture

logo python

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.

  • 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.
  • Python 3.10+ — aucune autre dépendance, le nettoyage tient en bibliothèque standard.
  • Avoir extrait un corpus à nettoyer.

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.

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.

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.

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)

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.

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 texte

Sur 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 à...'

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.

SymptômeCause probableSolution
UnicodeDecodeError à la lectureFichier dans un autre encodageLire en encoding="utf-8", sinon détecter
Caractères bizarres après nettoyageTexte non normalisé en NFCAppliquer normaliser_unicode en premier
Recherche qui rate des doublonsCaractères invisibles non retirésVérifier le texte avec repr()
Phrases mal coupéesAbréviations, décimalesAffiner la regex ou passer à spaCy
Chunks anormalement longsEspaces et lignes vides non réduitsAppliquer normaliser_espaces
  • 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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn