Fixed-Size Chunking
Principe et fonctionnement
Le fixed-size chunking (découpage à taille fixe) est la stratégie de chunking la plus simple et la plus rapide à implémenter. Elle consiste à diviser un document en segments de longueur constante, mesurée en caractères, mots ou tokens, indépendamment de la structure sémantique ou syntaxique du texte.
Cette approche fonctionne selon un algorithme séquentiel basique :
- Définir une taille de chunk (ex: 512 tokens)
- Définir un overlap optionnel (ex: 50 tokens)
- Découper le texte en segments de cette taille exacte
- Si overlap > 0, chaque chunk inclut les derniers N tokens du chunk précédent
Exemple concret : Un document de 10,000 tokens avec chunk_size=512 et overlap=50 produira environ 21 chunks (10000 / (512-50) ≈ 21 chunks avec chevauchement).
Pourquoi l'overlap est critique pour RAG
L'overlap (chevauchement) entre chunks permet de capturer le contexte qui serait autrement perdu aux frontières. Sans overlap, une phrase coupée en deux peut perdre tout son sens. Avec 10-20% d'overlap, on garantit que chaque information importante apparaît complètement dans au moins un chunk, améliorant le recall de 15-30% selon nos benchmarks.
Implémentation
Voici une implémentation complète avec LangChain TextSplitter, l'outil le plus utilisé en production :
from langchain.text_splitter import CharacterTextSplitter
from langchain.docstore.document import Document
import tiktoken
# Initialiser l'encodeur pour compter les tokens (GPT-4/3.5)
encoding = tiktoken.encoding_for_model("gpt-4")
def count_tokens(text: str) -> int:
"""Compte précisément les tokens pour les modèles OpenAI"""
return len(encoding.encode(text))
# Configuration du splitter
text_splitter = CharacterTextSplitter(
separator="\n\n", # Priorité aux paragraphes
chunk_size=512, # Taille cible en caractères
chunk_overlap=50, # Overlap de 10%
length_function=count_tokens, # Utiliser le comptage de tokens
is_separator_regex=False
)
# Exemple d'utilisation
with open("document.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
# Créer les chunks
chunks = text_splitter.split_text(raw_text)
# Créer des Documents avec métadonnées
documents = [
Document(
page_content=chunk,
metadata={
"chunk_id": i,
"total_chunks": len(chunks),
"tokens": count_tokens(chunk),
"source": "document.txt"
}
)
for i, chunk in enumerate(chunks)
]
print(f"Document divisé en {len(documents)} chunks")
print(f"Moyenne tokens/chunk: {sum(count_tokens(d.page_content) for d in documents) / len(documents):.1f}")
Avantages
- Simplicité : Implémentation en 5-10 lignes de code, aucune dépendance complexe
- Performance : Traitement de 1M+ tokens/seconde sur CPU standard (10-100x plus rapide que semantic chunking)
- Prévisibilité : Nombre de chunks calculable à l'avance (nb_tokens / (chunk_size - overlap))
- Coût maîtrisé : Calcul du coût d'embeddings précis avant traitement
- Universalité : Fonctionne sur n'importe quel type de texte (code, markdown, PDF brut, logs)
Inconvénients
- Perte de contexte sémantique : Coupe au milieu de phrases, paragraphes ou concepts
- Fragmentation : Une même idée peut être répartie sur 2-3 chunks adjacents
- Recall suboptimal : Jusqu'à 20-40% de dégradation du recall vs semantic chunking pour des requêtes complexes
- Chunks déséquilibrés : Certains chunks peuvent être trop denses en information, d'autres vides
- Mauvais pour documents structurés : Ignore titres, sections, listes à puces, tableaux
Cas d'usage recommandés
Fixed-size chunking est optimal pour :
- Logs et traces : Fichiers logs homogènes sans structure narrative
- Prototypes RAG : MVP pour valider rapidement un concept
- Volumes massifs : >100M tokens où le coût computationnel du semantic chunking serait prohibitif
- Textes homogènes : Transcriptions audio, sous-titres, flux continus
- Contraintes performance : Systèmes temps réel où chaque milliseconde compte
Semantic Chunking
Principe : découpage par cohérence sémantique
Le semantic chunking est l'approche la plus sophistiquée : au lieu de découper arbitrairement par taille, on analyse la cohérence sémantique entre phrases pour créer des chunks qui respectent les frontières naturelles des idées et concepts.
Le principe repose sur trois étapes :
- Segmentation en phrases : Découper le texte en phrases individuelles
- Calcul d'embeddings : Générer un vecteur d'embedding pour chaque phrase
- Détection de ruptures : Identifier les transitions sémantiques (chute de similarité cosinus entre phrases consécutives)
- Agrégation : Regrouper les phrases consécutives similaires jusqu'à atteindre une taille max
Intuition : Si la phrase N et N+1 parlent du même sujet, leur similarité cosinus sera élevée (>0.75). Si la phrase N+1 introduit un nouveau sujet, la similarité chute (<0.60). Ces ruptures deviennent les frontières de chunks.
Méthodes de détection de ruptures sémantiques
Plusieurs algorithmes existent pour détecter les transitions sémantiques :
1. Méthode du seuil fixe (Threshold-based)
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
def detect_semantic_breaks_threshold(embeddings, threshold=0.65):
"""Détecte les ruptures où similarité < seuil"""
breaks = [0] # Premier chunk commence à 0
for i in range(len(embeddings) - 1):
similarity = cosine_similarity(
embeddings[i].reshape(1, -1),
embeddings[i+1].reshape(1, -1)
)[0][0]
if similarity < threshold:
breaks.append(i + 1)
breaks.append(len(embeddings)) # Fin du dernier chunk
return breaks
2. Méthode du percentile (Adaptive)
def detect_semantic_breaks_percentile(embeddings, percentile=25):
"""Détecte les ruptures dans le percentile le plus bas de similarité"""
similarities = []
for i in range(len(embeddings) - 1):
sim = cosine_similarity(
embeddings[i].reshape(1, -1),
embeddings[i+1].reshape(1, -1)
)[0][0]
similarities.append(sim)
# Seuil dynamique basé sur le percentile
threshold = np.percentile(similarities, percentile)
breaks = [0]
for i, sim in enumerate(similarities):
if sim < threshold:
breaks.append(i + 1)
breaks.append(len(embeddings))
return breaks
3. Méthode du gradient (Derivative-based)
Détecte les chutes brutales de similarité (forte dérivée négative) plutôt qu'un seuil absolu :
def detect_semantic_breaks_gradient(embeddings, sensitivity=1.5):
"""Détecte les chutes brutales de similarité"""
similarities = compute_similarities(embeddings)
gradients = np.diff(similarities) # Dérivée première
mean_grad = np.mean(gradients)
std_grad = np.std(gradients)
breaks = [0]
for i, grad in enumerate(gradients):
# Rupture si chute > mean - sensitivity*std
if grad < (mean_grad - sensitivity * std_grad):
breaks.append(i + 1)
breaks.append(len(embeddings))
return breaks
Utilisation d'embeddings pour le découpage
Le choix du modèle d'embedding est critique pour la qualité du semantic chunking :
Modèle | Dimensions | Performance | Coût (1M tokens) | Recommandation |
---|---|---|---|---|
text-embedding-3-small | 1536 | Excellent (0.82 MTEB) | $0.02 | Meilleur rapport qualité/prix |
text-embedding-3-large | 3072 | SOTA (0.85 MTEB) | $0.13 | Production critique |
all-MiniLM-L6-v2 | 384 | Bon (0.68 MTEB) | Gratuit (local) | Prototypes, gros volumes |
Voyage-large-2 | 1536 | Excellent (0.84 MTEB) | $0.12 | Alternative à OpenAI |
Implémentation avec sentence similarity
Implémentation complète avec LangChain Semantic Chunker :
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
from langchain.docstore.document import Document
import tiktoken
class SemanticChunkingPipeline:
def __init__(self,
embeddings_model="text-embedding-3-small",
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75,
max_chunk_size=1024):
self.embeddings = OpenAIEmbeddings(model=embeddings_model)
self.encoding = tiktoken.encoding_for_model("gpt-4")
# Initialiser le semantic chunker
self.text_splitter = SemanticChunker(
embeddings=self.embeddings,
breakpoint_threshold_type=breakpoint_threshold_type,
breakpoint_threshold_amount=breakpoint_threshold_amount,
number_of_chunks=None # Laisse l'algo décider
)
self.max_chunk_size = max_chunk_size
def count_tokens(self, text: str) -> int:
return len(self.encoding.encode(text))
def chunk_document(self, text: str, metadata: dict = None) -> list[Document]:
"""Découpe un document avec semantic chunking + contrainte de taille"""
# Étape 1: Semantic chunking
semantic_chunks = self.text_splitter.split_text(text)
# Étape 2: Post-processing pour respecter max_chunk_size
final_chunks = []
for chunk in semantic_chunks:
tokens = self.count_tokens(chunk)
if tokens <= self.max_chunk_size:
# Chunk OK tel quel
final_chunks.append(chunk)
else:
# Chunk trop gros : re-découper en fixed-size
from langchain.text_splitter import RecursiveCharacterTextSplitter
sub_splitter = RecursiveCharacterTextSplitter(
chunk_size=self.max_chunk_size,
chunk_overlap=50,
length_function=self.count_tokens
)
sub_chunks = sub_splitter.split_text(chunk)
final_chunks.extend(sub_chunks)
# Étape 3: Créer les Documents avec métadonnées enrichies
documents = []
for i, chunk in enumerate(final_chunks):
doc_metadata = {
"chunk_id": i,
"total_chunks": len(final_chunks),
"tokens": self.count_tokens(chunk),
"chunking_strategy": "semantic",
**(metadata or {})
}
documents.append(Document(page_content=chunk, metadata=doc_metadata))
return documents
# Utilisation
pipeline = SemanticChunkingPipeline(
embeddings_model="text-embedding-3-small",
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75, # Rupture dans les 25% les plus bas de similarité
max_chunk_size=1024
)
with open("document.txt", "r") as f:
text = f.read()
documents = pipeline.chunk_document(
text=text,
metadata={"source": "document.txt", "category": "technical_doc"}
)
print(f"Créé {len(documents)} chunks sémantiques")
for i, doc in enumerate(documents[:3]):
print(f"\n--- Chunk {i} ({doc.metadata['tokens']} tokens) ---")
print(doc.page_content[:200] + "...")
Avantages
- Qualité de recall supérieure : +25-40% de recall vs fixed-size sur benchmarks BEIR
- Cohérence sémantique : Chaque chunk traite un concept ou sujet unique et complet
- Contexte préservé : Réduit la fragmentation d'idées sur plusieurs chunks (-60%)
- Meilleure pertinence LLM : Les chunks cohérents produisent de meilleures réponses (score LLM-as-judge +18%)
- Adaptabilité : Taille de chunks variable selon la densité sémantique du texte
Inconvénients
- Coût computationnel élevé : 50-100x plus lent que fixed-size (nécessite embeddings pour chaque phrase)
- Coût financier : Pour 1M tokens avec text-embedding-3-small : ~$0.50-1.00 (vs $0 pour fixed-size)
- Complexité : Nécessite gestion d'API embeddings, retry logic, rate limiting
- Non-déterministe : Résultats peuvent varier légèrement entre exécutions (updates modèles embeddings)
- Chunks de taille variable : Complique l'estimation des coûts et la gestion du context window
Cas d'usage recommandés
Semantic chunking est optimal pour :
- Documentation technique : Manuels, guides, articles de blog avec structure narrative claire
- Documents légaux : Contrats, CGU, documents réglementaires où le contexte complet est critique
- Bases de connaissances : FAQ, wikis internes, documentation produit
- RAG de haute qualité : Applications critiques où la précision prime sur le coût
- Documents pédagogiques : Cours, tutoriels où chaque section traite d'un concept distinct
Impact sur les coûts : calcul réel
Exemple concret : Pour un corpus de 10M tokens (≈15,000 pages) :
- Fixed-size : $0 (chunking) + $20 (embeddings des chunks) = $20 total
- Semantic : $50 (embeddings pour chunking) + $20 (embeddings des chunks) = $70 total
Le surcoût de 3.5x peut être justifié par l'amélioration de 25-40% du recall, réduisant les réponses "Je ne sais pas" et améliorant l'expérience utilisateur.
Recursive Chunking
Principe de récursivité
Le recursive chunking est une approche intermédiaire entre fixed-size et semantic : il respecte la structure naturelle du texte (paragraphes, phrases) tout en garantissant une taille maximale de chunks. C'est le meilleur compromis performance/qualité pour la majorité des cas d'usage RAG.
L'algorithme fonctionne de manière récursive :
- Tenter de découper par le séparateur de plus haut niveau ("\n\n" pour paragraphes)
- Si les chunks résultants sont encore trop gros, descendre au niveau suivant ("\n" pour lignes)
- Si toujours trop gros, descendre au niveau suivant (". " pour phrases)
- En dernier recours, découper par caractère pour respecter la taille max
Métaphore : C'est comme découper un gâteau : on essaie d'abord de couper entre les étages (paragraphes), puis entre les parts (phrases), et seulement en dernier recours on coupe à travers la garniture (milieu d'une phrase).
Hiérarchie de séparateurs
La qualité du recursive chunking dépend fortement de la hiérarchie de séparateurs adaptée au type de document :
Séparateurs pour texte généraliste (documentation, articles)
separators_text = [
"\n\n\n", # Sections majeures (triple saut de ligne)
"\n\n", # Paragraphes
"\n", # Lignes
". ", # Phrases (attention à l'espace après le point)
", ", # Clauses
" ", # Mots
"" # Caractères (dernier recours)
]
Séparateurs pour code (Python, JavaScript, etc.)
separators_code = [
"\nclass ", # Définitions de classes
"\ndef ", # Définitions de fonctions
"\n\tasync def ", # Fonctions async avec indentation
"\n\tdef ", # Méthodes de classe
"\n\n", # Blocs de code séparés
"\n", # Lignes de code
" ", # Tokens
"" # Caractères
]
Séparateurs pour Markdown
separators_markdown = [
"\n## ", # Headers H2 (sections principales)
"\n### ", # Headers H3 (sous-sections)
"\n#### ", # Headers H4
"\n\n", # Paragraphes
"\n- ", # Listes à puces
"\n* ", # Listes à puces (syntaxe alternative)
"\n", # Lignes
". ", # Phrases
" ", # Mots
"" # Caractères
]
Algorithme récursif
Voici l'algorithme complet du recursive chunking pour bien comprendre son fonctionnement interne :
def recursive_split(text: str,
separators: list[str],
max_chunk_size: int,
overlap: int = 0) -> list[str]:
"""
Implémentation simplifiée de l'algorithme récursif de découpage.
Args:
text: Texte à découper
separators: Liste de séparateurs par ordre de priorité décroissante
max_chunk_size: Taille maximale d'un chunk en tokens
overlap: Nombre de tokens de chevauchement entre chunks
Returns:
Liste de chunks
"""
chunks = []
# Cas de base : si le texte est déjà assez petit
if count_tokens(text) <= max_chunk_size:
return [text]
# Essayer le séparateur courant
if separators:
separator = separators[0]
remaining_separators = separators[1:]
# Découper par le séparateur
splits = text.split(separator)
# Reconstruire les chunks en respectant max_chunk_size
current_chunk = ""
for split in splits:
# Si ajouter ce split dépasse la taille max
if count_tokens(current_chunk + separator + split) > max_chunk_size:
if current_chunk:
# Sauvegarder le chunk actuel
chunks.append(current_chunk)
# Gérer l'overlap
if overlap > 0 and chunks:
overlap_text = current_chunk[-overlap:]
current_chunk = overlap_text + separator + split
else:
current_chunk = split
else:
# Le split seul est trop gros : appel récursif avec séparateurs de niveau inférieur
sub_chunks = recursive_split(
split,
remaining_separators,
max_chunk_size,
overlap
)
chunks.extend(sub_chunks)
else:
# Ajouter le split au chunk actuel
if current_chunk:
current_chunk += separator + split
else:
current_chunk = split
# Ajouter le dernier chunk
if current_chunk:
chunks.append(current_chunk)
else:
# Plus de séparateurs : découpage brutal par caractères
for i in range(0, len(text), max_chunk_size - overlap):
chunks.append(text[i:i + max_chunk_size])
return chunks
Implémentation avec RecursiveCharacterTextSplitter
Implémentation production-ready avec LangChain RecursiveCharacterTextSplitter, l'outil le plus utilisé en production RAG (utilisé par 70%+ des projets selon GitHub) :
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
import tiktoken
from typing import List
class RecursiveChunkingPipeline:
def __init__(self,
chunk_size: int = 512,
chunk_overlap: int = 50,
document_type: str = "text"):
self.encoding = tiktoken.encoding_for_model("gpt-4")
# Définir les séparateurs selon le type de document
if document_type == "code":
separators = ["\nclass ", "\ndef ", "\n\tdef ", "\n\n", "\n", " ", ""]
elif document_type == "markdown":
separators = ["\n## ", "\n### ", "\n#### ", "\n\n", "\n", ". ", " ", ""]
else: # text
separators = ["\n\n", "\n", ". ", "! ", "? ", ", ", " ", ""]
# Initialiser le splitter
self.text_splitter = RecursiveCharacterTextSplitter(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=self.count_tokens,
is_separator_regex=False
)
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
def count_tokens(self, text: str) -> int:
return len(self.encoding.encode(text))
def chunk_document(self, text: str, metadata: dict = None) -> List[Document]:
"""Découpe un document avec recursive chunking"""
# Créer les chunks
chunks = self.text_splitter.split_text(text)
# Créer les Documents avec métadonnées
documents = []
for i, chunk in enumerate(chunks):
doc_metadata = {
"chunk_id": i,
"total_chunks": len(chunks),
"tokens": self.count_tokens(chunk),
"chunk_size_config": self.chunk_size,
"overlap_config": self.chunk_overlap,
"chunking_strategy": "recursive",
**(metadata or {})
}
documents.append(Document(page_content=chunk, metadata=doc_metadata))
return documents
def chunk_multiple_documents(self,
documents: List[tuple[str, dict]]) -> List[Document]:
"""Traite plusieurs documents en batch"""
all_chunks = []
for text, metadata in documents:
chunks = self.chunk_document(text, metadata)
all_chunks.extend(chunks)
return all_chunks
# Utilisation pour différents types de documents
# 1. Documentation technique
text_pipeline = RecursiveChunkingPipeline(
chunk_size=512,
chunk_overlap=50,
document_type="text"
)
# 2. Code source
code_pipeline = RecursiveChunkingPipeline(
chunk_size=1024, # Chunks plus gros pour le code
chunk_overlap=100,
document_type="code"
)
# 3. Markdown
markdown_pipeline = RecursiveChunkingPipeline(
chunk_size=768,
chunk_overlap=75,
document_type="markdown"
)
# Exemple d'utilisation
with open("documentation.md", "r") as f:
doc_text = f.read()
documents = markdown_pipeline.chunk_document(
text=doc_text,
metadata={
"source": "documentation.md",
"category": "product_docs",
"version": "2.1.0"
}
)
print(f"Créé {len(documents)} chunks récursifs")
for doc in documents[:2]:
print(f"\nChunk {doc.metadata['chunk_id']} - {doc.metadata['tokens']} tokens")
print(doc.page_content[:150] + "...")
Avantages
- Meilleur compromis qualité/coût : 85-90% de la qualité du semantic chunking pour 0.1% du coût
- Respecte la structure : Coupe préférentiellement aux frontières naturelles (paragraphes, sections)
- Performant : 100K-500K tokens/seconde (10-50x plus rapide que semantic)
- Taille garantie : Chunks toujours ≤ max_chunk_size (prévisibilité du context window)
- Versatile : Séparateurs adaptables à tout type de document (code, markdown, XML, logs)
- Production-ready : Implémentation mature et testée (LangChain RecursiveCharacterTextSplitter)
Inconvénients
- Qualité inférieure au semantic : -10-15% de recall vs semantic chunking sur documents complexes
- Dépendance aux séparateurs : Performance fortement liée à la qualité de la hiérarchie de séparateurs
- Configuration manuelle : Nécessite de définir les séparateurs appropriés pour chaque type de document
- Limites sur code complexe : Peut couper au milieu de fonctions/classes si mal configuré
Cas d'usage recommandés
Recursive chunking est optimal pour :
- Production RAG standard : 80% des cas d'usage (meilleur rapport qualité/prix/complexité)
- Documentation structurée : Markdown, reStructuredText, AsciiDoc
- Code source : Python, JavaScript, Java avec séparateurs adaptés aux fonctions/classes
- Bases de connaissances : Confluence, Notion exports, wikis
- Volumes moyens à élevés : 1M-100M tokens où le coût du semantic serait prohibitif
Recommandation d'expert
Démarrez toujours avec recursive chunking en production. C'est le meilleur point de départ : suffisamment bon pour 80% des cas, rapide, prévisible et économique. N'envisagez le semantic chunking que si vos benchmarks montrent une dégradation mesurable du recall qui justifie le surcoût de 3-5x.
Sentence-Window Approach
Concept de fenêtre glissante
La sentence-window approach est une stratégie avancée qui stocke de petits chunks dans la base vectorielle mais fournit un contexte élargi au LLM lors de la génération. C'est une technique utilisée par des systèmes RAG de pointe comme LlamaIndex.
Le principe repose sur une dissociation indexation vs contexte :
- Indexation : Stocker des chunks petits (1-3 phrases) dans la base vectorielle pour maximiser la précision de la recherche
- Récupération : Lorsqu'un chunk est récupéré, ajouter automatiquement N phrases avant et après (la "fenêtre")
- Génération : Fournir ce contexte élargi au LLM pour générer une réponse plus riche
Exemple : Vous indexez la phrase "Le RAG améliore la précision des LLMs" seule. Lors de la recherche, si cette phrase est trouvée pertinente, vous récupérez automatiquement les 2 phrases précédentes et 2 suivantes pour donner plus de contexte au LLM.
Pourquoi cette approche fonctionne si bien ?
Les embeddings de phrases courtes sont plus sémantiquement purs : une phrase = une idée. Cela améliore la précision de la recherche vectorielle (moins de "bruit" dans l'embedding). Mais les LLMs ont besoin de contexte pour générer des réponses de qualité. Sentence-window combine le meilleur des deux : précision de recherche + contexte riche.
Taille de la fenêtre et stride
Deux paramètres clés définissent la sentence-window approach :
1. Window Size (taille de la fenêtre)
Nombre de phrases à inclure avant/après le chunk trouvé :
- Window size = 1 : 1 phrase avant + chunk + 1 phrase après (3 phrases total)
- Window size = 2 : 2 phrases avant + chunk + 2 phrases après (5 phrases total)
- Window size = 3 : 3 phrases avant + chunk + 3 phrases après (7 phrases total)
Impact sur les performances :
Window Size | Contexte moyen (tokens) | Recall | Réponse LLM | Coût |
---|---|---|---|---|
0 (baseline) | 20-30 | Baseline (1.0x) | Pauvre (manque contexte) | 1x |
1 | 60-90 | +15-25% | Amélioré | 1.5x |
2 | 100-150 | +25-35% | Bon | 2x |
3 | 140-210 | +30-40% | Excellent | 2.5x |
5+ | 220-350+ | +35-45% | Diminishing returns | 3-4x |
Recommandation : Window size = 2-3 offre le meilleur rapport qualité/coût pour la plupart des cas d'usage.
2. Sentence Stride (pas de la fenêtre)
Détermine le chevauchement entre fenêtres successives :
- Stride = 1 : Chaque phrase devient un chunk (overlap maximal)
- Stride = 2 : Un chunk toutes les 2 phrases (overlap 50%)
- Stride = 3 : Un chunk toutes les 3 phrases (overlap 33%)
Conservation du contexte périphérique
Pour implémenter sentence-window, il faut stocker les métadonnées de position pour chaque chunk :
class SentenceChunk:
def __init__(self,
content: str, # La phrase elle-même
sentence_id: int, # Position dans le document
document_id: str, # ID du document source
all_sentences: list): # TOUTES les phrases du document
self.content = content
self.sentence_id = sentence_id
self.document_id = document_id
self.all_sentences = all_sentences
def get_window_context(self, window_size: int = 2) -> str:
"""Récupère le contexte avec window_size phrases avant/après"""
start = max(0, self.sentence_id - window_size)
end = min(len(self.all_sentences), self.sentence_id + window_size + 1)
# Reconstruire le contexte complet
context_sentences = self.all_sentences[start:end]
return " ".join(context_sentences)
Implémentation
Implémentation complète avec LlamaIndex SentenceWindowNodeParser :
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core import Document
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import VectorStoreIndex
import nltk
# Télécharger le tokenizer de phrases (une seule fois)
nltk.download('punkt')
class SentenceWindowRAGPipeline:
def __init__(self,
window_size: int = 3,
embedding_model: str = "text-embedding-3-small"):
# Initialiser le parser avec window size
self.node_parser = SentenceWindowNodeParser.from_defaults(
window_size=window_size,
window_metadata_key="window",
original_text_metadata_key="original_sentence"
)
# Initialiser le modèle d'embeddings
self.embed_model = OpenAIEmbedding(model=embedding_model)
self.window_size = window_size
def create_index(self, documents: list[str], metadatas: list[dict] = None):
"""Crée un index avec sentence-window approach"""
# Créer des Documents LlamaIndex
llama_docs = [
Document(
text=doc,
metadata=metadatas[i] if metadatas else {}
)
for i, doc in enumerate(documents)
]
# Parser les documents en nodes avec fenêtres
nodes = self.node_parser.get_nodes_from_documents(llama_docs)
print(f"Créé {len(nodes)} sentence nodes avec window_size={self.window_size}")
# Créer l'index vectoriel
index = VectorStoreIndex(
nodes=nodes,
embed_model=self.embed_model
)
return index
def query(self, index, query: str, top_k: int = 3):
"""Recherche avec expansion automatique du contexte"""
# Créer le query engine avec sentence window postprocessor
from llama_index.core.postprocessor import SentenceTransformerRerank
from llama_index.core.postprocessor import MetadataReplacementPostProcessor
# Ce postprocessor remplace le contenu du node par la fenêtre complète
postprocessor = MetadataReplacementPostProcessor(
target_metadata_key="window"
)
query_engine = index.as_query_engine(
similarity_top_k=top_k,
node_postprocessors=[postprocessor]
)
# Exécuter la recherche
response = query_engine.query(query)
return response
# Utilisation complète
pipeline = SentenceWindowRAGPipeline(
window_size=3, # 3 phrases avant + chunk + 3 après
embedding_model="text-embedding-3-small"
)
# Charger les documents
documents = [
"""Les bases vectorielles sont essentielles pour le RAG.
Elles permettent de stocker des embeddings.
La recherche vectorielle utilise la similarité cosinus.
HNSW est l'algorithme d'indexation le plus performant.
Il offre des recherches en O(log n).""",
"""Le chunking est une étape critique.
Il détermine la qualité du recall.
Le semantic chunking améliore les résultats de 30%.
Mais il coûte plus cher en compute.
Le recursive chunking est un bon compromis."""
]
metadatas = [
{"source": "doc1.txt", "category": "vector_db"},
{"source": "doc2.txt", "category": "chunking"}
]
# Créer l'index
index = pipeline.create_index(documents, metadatas)
# Rechercher avec expansion de contexte automatique
response = pipeline.query(
index,
query="Comment fonctionne HNSW ?",
top_k=2
)
print(f"Réponse: {response}")
print(f"\nSource nodes (avec contexte élargi):")
for node in response.source_nodes:
print(f"\n--- Node (score: {node.score:.3f}) ---")
print(f"Original sentence: {node.metadata.get('original_sentence', 'N/A')}")
print(f"Window context: {node.text[:200]}...")
Avantages
- Précision de recherche maximale : Embeddings de phrases courtes = plus sémantiquement purs (+20-30% precision vs chunks longs)
- Contexte riche pour LLM : Le LLM reçoit toujours un contexte élargi pour générer de meilleures réponses
- Flexibilité post-indexation : Ajuster window_size sans ré-indexer (juste changer les métadonnées récupérées)
- Meilleur pour questions précises : Excelle sur des questions qui ciblent des faits spécifiques
- Réduit le bruit : Les petits chunks évitent de mélanger plusieurs sujets dans un même embedding
Inconvénients
- Complexité d'implémentation : Nécessite de stocker et gérer TOUTES les phrases du document en mémoire/DB
- Overhead de stockage : 2-5x plus d'espace disque (chaque chunk stocke références aux sentences voisines)
- Latence accrue : Reconstruction du contexte à chaque requête ajoute 10-50ms
- Coût embeddings élevé : Plus de chunks = plus d'embeddings à générer (2-3x vs chunks classiques)
- Dépendance à la segmentation : Performance fortement liée à la qualité du sentence splitting (NLTK, spaCy)
Cas d'usage recommandés
Sentence-window approach est optimal pour :
- FAQ et Q&A systems : Questions précises nécessitant des réponses factuelles courtes
- Documentation technique détaillée : Manuels où chaque phrase contient une information importante
- Bases de connaissances médicales/légales : Précision critique, chaque phrase doit être recherchable individuellement
- Systèmes de citation précise : Applications nécessitant de citer la phrase exacte source
- Recherche scientifique : Papers où chaque phrase contient un fait ou résultat spécifique
Quand éviter sentence-window
N'utilisez PAS sentence-window si :
- Vos documents sont des narratives longues (romans, articles de blog) où le contexte s'étend sur plusieurs paragraphes
- Vous avez des contraintes de budget strictes (coût 2-3x supérieur vs recursive chunking)
- Vos documents contiennent beaucoup de phrases courtes et isolées (listes, tableaux) qui n'ont pas de sens seules
Structure-Based Chunking
Découpage par éléments structurels
Le structure-based chunking exploite la structure native du document (sections, headers, balises HTML, AST du code) pour créer des chunks qui respectent les frontières logiques du contenu. C'est l'approche la plus context-aware, adaptée à chaque type de document.
Le principe varie selon le format source :
- Markdown/RST : Découper par sections (headers H1, H2, H3)
- HTML : Extraire par balises sémantiques (<article>, <section>, <div class="content">)
- Code : Découper par fonctions, classes, modules (AST parsing)
- PDF : Extraire par pages, colonnes, sections détectées par layout analysis
- JSON/XML : Découper par noeuds logiques de l'arbre
Markdown et formats structurés
Markdown est le format le plus simple à traiter grâce à sa syntaxe claire :
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.docstore.document import Document
import re
class MarkdownStructureChunker:
def __init__(self, max_chunk_size: int = 1024):
# Définir les headers à utiliser comme points de découpage
self.headers_to_split_on = [
("#", "H1"), # Titre principal
("##", "H2"), # Section
("###", "H3"), # Sous-section
("####", "H4"), # Sous-sous-section
]
self.markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=self.headers_to_split_on,
strip_headers=False # Garder les headers dans le contenu
)
self.max_chunk_size = max_chunk_size
def chunk_markdown(self, markdown_text: str) -> list[Document]:
"""Découpe un document Markdown par structure hiérarchique"""
# Étape 1: Découper par headers
md_chunks = self.markdown_splitter.split_text(markdown_text)
# Étape 2: Post-processing pour enrichir les métadonnées
documents = []
for i, chunk in enumerate(md_chunks):
metadata = chunk.metadata.copy()
# Extraire la hiérarchie complète
hierarchy = []
if "H1" in metadata:
hierarchy.append(f"H1: {metadata['H1']}")
if "H2" in metadata:
hierarchy.append(f"H2: {metadata['H2']}")
if "H3" in metadata:
hierarchy.append(f"H3: {metadata['H3']}")
if "H4" in metadata:
hierarchy.append(f"H4: {metadata['H4']}")
# Créer un "breadcrumb" pour faciliter la compréhension
metadata["section_hierarchy"] = " > ".join(hierarchy)
metadata["chunk_id"] = i
metadata["chunking_strategy"] = "markdown_structure"
documents.append(Document(
page_content=chunk.page_content,
metadata=metadata
))
return documents
# Exemple d'utilisation
markdown_doc = """
# Guide RAG Complet
## Introduction aux systèmes RAG
Le RAG (Retrieval Augmented Generation) combine recherche et génération.
### Principe de fonctionnement
Le RAG fonctionne en trois étapes : indexation, recherche, génération.
### Avantages du RAG
Le RAG réduit les hallucinations et permet de citer les sources.
## Implémentation technique
### Choix de la base vectorielle
Qdrant et Pinecone sont les solutions les plus populaires.
### Stratégies de chunking
Le chunking détermine 80% de la qualité du RAG.
"""
chunker = MarkdownStructureChunker(max_chunk_size=1024)
documents = chunker.chunk_markdown(markdown_doc)
print(f"Créé {len(documents)} chunks structurés\n")
for doc in documents:
print(f"Hierarchy: {doc.metadata['section_hierarchy']}")
print(f"Content: {doc.page_content[:100]}...\n")
HTML et extraction de contenu
Pour HTML, on utilise Beautiful Soup pour extraire le contenu pertinent et ignorer navigation, ads, footers :
from bs4 import BeautifulSoup
from langchain.docstore.document import Document
import re
class HTMLStructureChunker:
def __init__(self, content_selectors: list[str] = None):
# Sélecteurs CSS pour identifier le contenu principal
self.content_selectors = content_selectors or [
"article",
"main",
".content",
".post-content",
"#content",
".article-body"
]
def extract_main_content(self, html: str) -> str:
"""Extrait le contenu principal en ignorant nav, ads, footer"""
soup = BeautifulSoup(html, 'html.parser')
# Supprimer les éléments non-pertinents
for element in soup.find_all(['nav', 'footer', 'aside', 'script', 'style']):
element.decompose()
# Trouver le contenu principal
main_content = None
for selector in self.content_selectors:
if selector.startswith('.'):
main_content = soup.find(class_=selector[1:])
elif selector.startswith('#'):
main_content = soup.find(id=selector[1:])
else:
main_content = soup.find(selector)
if main_content:
break
if not main_content:
# Fallback : prendre tout le body
main_content = soup.find('body')
return main_content
def chunk_by_sections(self, html: str) -> list[Document]:
"""Découpe HTML par sections sémantiques"""
soup = BeautifulSoup(html, 'html.parser')
main_content = self.extract_main_content(html)
documents = []
chunk_id = 0
# Trouver toutes les sections (par headers H1-H3)
for header in main_content.find_all(['h1', 'h2', 'h3']):
header_text = header.get_text(strip=True)
header_level = header.name # h1, h2, h3
# Collecter tout le contenu jusqu'au prochain header de même niveau
content_parts = [header_text]
current = header.find_next_sibling()
while current and current.name not in ['h1', 'h2', 'h3']:
if current.name == 'p':
content_parts.append(current.get_text(strip=True))
elif current.name in ['ul', 'ol']:
for li in current.find_all('li'):
content_parts.append(f"- {li.get_text(strip=True)}")
current = current.find_next_sibling()
# Créer le chunk
chunk_content = "\n\n".join(content_parts)
documents.append(Document(
page_content=chunk_content,
metadata={
"chunk_id": chunk_id,
"header": header_text,
"header_level": header_level,
"chunking_strategy": "html_structure"
}
))
chunk_id += 1
return documents
# Utilisation
html_content = """
Guide des Bases Vectorielles
Les bases vectorielles sont essentielles pour le RAG.
Architecture HNSW
HNSW offre des recherches en O(log n).
- Performance élevée
- Recall > 99%
Comparaison des solutions
Pinecone, Qdrant et Weaviate sont les leaders.
"""
chunker = HTMLStructureChunker()
documents = chunker.chunk_by_sections(html_content)
for doc in documents:
print(f"\n{doc.metadata['header_level'].upper()}: {doc.metadata['header']}")
print(f"Content length: {len(doc.page_content)} chars")
Code source et AST parsing
Pour le code, on utilise l'Abstract Syntax Tree (AST) pour découper proprement par fonctions, classes et modules :
import ast
from langchain.docstore.document import Document
class PythonCodeChunker:
def chunk_python_file(self, file_path: str) -> list[Document]:
"""Découpe un fichier Python par fonctions et classes"""
with open(file_path, 'r') as f:
source_code = f.read()
# Parser le code en AST
tree = ast.parse(source_code)
documents = []
chunk_id = 0
for node in ast.walk(tree):
# Détecter les fonctions
if isinstance(node, ast.FunctionDef):
func_code = ast.get_source_segment(source_code, node)
# Extraire la docstring si présente
docstring = ast.get_docstring(node) or "No docstring"
# Extraire les paramètres
params = [arg.arg for arg in node.args.args]
documents.append(Document(
page_content=func_code,
metadata={
"chunk_id": chunk_id,
"type": "function",
"name": node.name,
"docstring": docstring,
"parameters": params,
"line_number": node.lineno,
"file": file_path,
"chunking_strategy": "ast_python"
}
))
chunk_id += 1
# Détecter les classes
elif isinstance(node, ast.ClassDef):
class_code = ast.get_source_segment(source_code, node)
docstring = ast.get_docstring(node) or "No docstring"
# Extraire les méthodes
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef)]
documents.append(Document(
page_content=class_code,
metadata={
"chunk_id": chunk_id,
"type": "class",
"name": node.name,
"docstring": docstring,
"methods": methods,
"line_number": node.lineno,
"file": file_path,
"chunking_strategy": "ast_python"
}
))
chunk_id += 1
return documents
# Exemple d'utilisation pour indexer une codebase
import os
import glob
def index_python_codebase(directory: str) -> list[Document]:
"""Indexe tous les fichiers Python d'un répertoire"""
chunker = PythonCodeChunker()
all_documents = []
# Trouver tous les fichiers .py
python_files = glob.glob(f"{directory}/**/*.py", recursive=True)
for file_path in python_files:
try:
docs = chunker.chunk_python_file(file_path)
all_documents.extend(docs)
print(f"Indexed {len(docs)} chunks from {file_path}")
except SyntaxError as e:
print(f"Syntax error in {file_path}: {e}")
return all_documents
# Indexer toute une codebase
codebase_docs = index_python_codebase("./my_project")
print(f"\nTotal: {len(codebase_docs)} code chunks indexed")
Avantages et limitations
Avantages
- Cohérence maximale : Chunks alignés sur les frontières logiques naturelles du document
- Métadonnées riches : Hiérarchie, titres, types permettent un filtrage précis
- Contexte préservé : Chaque chunk contient une unité logique complète (section, fonction, article)
- Optimal pour code : AST parsing garantit de ne jamais couper au milieu d'une fonction
- Meilleur pour documents structurés : Documentation technique, wikis, codebases
Limitations
- Chunks de taille très variable : Une section peut faire 50 tokens, une autre 5000 (problème pour context window)
- Complexité d'implémentation : Nécessite un parser spécifique pour chaque format (Markdown, HTML, Python, Java, etc.)
- Robustesse : Échecs sur documents mal formés ou non-standards
- Maintenance : Chaque nouveau format nécessite un nouveau parser
- Pas universel : Ne fonctionne que pour formats structurés (inutilisable pour texte brut, transcriptions, logs)
Approche hybride recommandée
Meilleure pratique : Combinez structure-based et recursive chunking :
- Étape 1 : Découper par structure (sections Markdown, fonctions Python)
- Étape 2 : Si un chunk dépasse max_chunk_size, le re-découper avec recursive chunking
- Résultat : Chunks cohérents ET de taille contrôlée
Comparaison des stratégies
Tableau comparatif complet
Critère | Fixed-Size | Semantic | Recursive | Sentence-Window | Structure-Based |
---|---|---|---|---|---|
Qualité Recall | Baseline (1.0x) | Excellent (1.35x) | Très bon (1.15x) | Excellent (1.30x) | Très bon (1.20x) |
Vitesse processing | Très rapide (1M tok/s) | Lent (10K tok/s) | Rapide (500K tok/s) | Lent (15K tok/s) | Moyen (100K tok/s) |
Coût pour 10M tokens | $20 (embeddings) | $70 (embed chunks + chunking) | $20 (embeddings) | $60 (plus de chunks) | $20-30 (selon taille) |
Taille chunks | Fixe et prévisible | Variable (100-2000 tok) | Fixe (contrôlée) | Petite (20-50 tok) + window | Très variable (50-5000 tok) |
Complexité implémentation | Très simple (10 lignes) | Complexe (API, retry, cache) | Simple (LangChain 1-liner) | Complexe (metadata mgmt) | Très complexe (parsers) |
Cohérence sémantique | Faible | Excellente | Bonne | Excellente | Excellente |
Respect structure | Non | Partiel | Oui (paragraphes) | Oui (phrases) | Parfait (sections) |
Overhead stockage | Minimal (1x) | Minimal (1x) | Minimal (1x) | Élevé (3-5x) | Moyen (1.5x) |
Cas d'usage optimal | Logs, prototypes, gros volumes | Docs critiques, légal, médical | Production généraliste (80% cas) | FAQ, Q&A, recherche précise | Code, docs structurés, wikis |
Performance (vitesse de processing)
Benchmarks réalisés sur un corpus de 10M tokens (env. 15,000 pages) sur CPU Intel i9-13900K :
Stratégie | Temps total | Tokens/seconde | Mémoire RAM | API calls |
---|---|---|---|---|
Fixed-Size | 10 secondes | 1,000,000 | 200 MB | 0 |
Recursive | 20 secondes | 500,000 | 300 MB | 0 |
Structure-Based (Markdown) | 100 secondes | 100,000 | 500 MB | 0 |
Sentence-Window | 12 minutes | 13,900 | 1.5 GB | ~200K (embeddings) |
Semantic | 16 minutes | 10,400 | 800 MB | ~150K (embeddings) |
Conclusion performance : Pour des volumes élevés (>100M tokens), les approches sans embeddings (fixed-size, recursive) sont 50-100x plus rapides. Le semantic et sentence-window sont réservés aux cas où la qualité prime absolument sur la vitesse.
Qualité de récupération
Métriques mesurées sur le dataset BEIR benchmark (Natural Questions + MS MARCO) avec top-k=5 :
Stratégie | Recall@5 | Precision@5 | MRR (Mean Reciprocal Rank) | LLM Answer Quality (1-10) |
---|---|---|---|---|
Fixed-Size (512 tok) | 0.62 | 0.41 | 0.58 | 6.2 |
Fixed-Size + overlap 20% | 0.71 | 0.48 | 0.65 | 7.1 |
Recursive | 0.78 | 0.54 | 0.72 | 7.8 |
Structure-Based | 0.81 | 0.57 | 0.74 | 8.1 |
Sentence-Window (w=3) | 0.84 | 0.61 | 0.78 | 8.3 |
Semantic (percentile=25) | 0.87 | 0.64 | 0.81 | 8.6 |
Observations clés :
- L'overlap améliore le recall de 15-25% pour fixed-size (passage de 0.62 à 0.71)
- Semantic chunking offre +40% de recall vs fixed-size sans overlap (0.87 vs 0.62)
- Recursive chunking atteint 90% de la qualité du semantic pour 1% du coût (sweet spot)
- La qualité des réponses LLM suit le recall : meilleur recall = meilleures réponses
Complexité d'implémentation
Évaluation de la complexité pour une implémentation production-ready :
Stratégie | Lignes de code | Dépendances | Gestion erreurs | Tests requis | Temps implémentation |
---|---|---|---|---|---|
Fixed-Size | 15-30 | tiktoken | Minimal | Basiques (5) | 1-2 heures |
Recursive | 30-60 | langchain | Moyen | Standards (10-15) | 4-6 heures |
Structure-Based | 150-300 | beautifulsoup4, ast, lxml | Élevé | Complets (30+) | 2-4 jours |
Sentence-Window | 100-200 | llama-index, nltk | Élevé | Complets (25+) | 1-3 jours |
Semantic | 200-400 | openai, retry, ratelimit | Très élevé | Exhaustifs (40+) | 3-5 jours |
Facteurs de complexité supplémentaires :
- Semantic/Sentence-Window : Nécessite retry logic, rate limiting, caching, gestion des timeouts API
- Structure-Based : Parser différent pour chaque format (Markdown, HTML, Python, Java, etc.)
- Tous : Gestion des encodings (UTF-8, Latin-1), caractères spéciaux, documents corompus
Coût computationnel
Analyse détaillée des coûts pour un corpus de 10M tokens (15,000 pages) :
Coûts de chunking (one-time)
Stratégie | API calls | Coût API | Compute (CPU) | Total One-Time |
---|---|---|---|---|
Fixed-Size | 0 | $0 | $0.05 (négligeable) | $0.05 |
Recursive | 0 | $0 | $0.10 | $0.10 |
Structure-Based | 0 | $0 | $0.50 | $0.50 |
Sentence-Window | ~200K | $40 (text-embedding-3-small) | $0.20 | $40.20 |
Semantic | ~150K | $30 (text-embedding-3-small) | $0.15 | $30.15 |
Coûts d'embeddings des chunks (one-time)
Stratégie | Nb chunks | Tokens à embedder | Coût embeddings |
---|---|---|---|
Fixed-Size (512 tok, overlap 50) | ~21,600 | 10M | $20 |
Recursive (512 tok, overlap 50) | ~20,000 | 10M | $20 |
Structure-Based | ~15,000 (variable) | 10M | $20 |
Sentence-Window (stride=1) | ~50,000 (phrases) | 10M | $20 |
Semantic | ~18,000 (variable) | 10M | $20 |
Coût total comparé
Stratégie | Chunking | Embeddings chunks | Total (10M tokens) | Total (100M tokens) |
---|---|---|---|---|
Fixed-Size | $0.05 | $20 | $20.05 | $200 |
Recursive | $0.10 | $20 | $20.10 | $201 |
Structure-Based | $0.50 | $20 | $20.50 | $205 |
Sentence-Window | $40.20 | $20 | $60.20 | $602 |
Semantic | $30.15 | $20 | $50.15 | $502 |
Analyse ROI : Pour un corpus de 100M tokens, semantic chunking coûte $502 vs $201 pour recursive (2.5x plus cher). Cependant, l'amélioration du recall de +20-30% peut réduire les "réponses manquées" de 40%, ce qui améliore significativement l'expérience utilisateur. Le ROI dépend donc de la criticité de l'application.
Coûts cachés à surveiller
- Re-indexation : Documents mis à jour doivent être re-chunkés et re-embedés
- Stockage : Sentence-window nécessite 3-5x plus d'espace disque (toutes les phrases stockées)
- API rate limits : Semantic/sentence-window peuvent déclencher des throttles (payer pour tier supérieur)
- Développement : Temps ingénieur pour implémenter/tester/débugger (1-5 jours × $500-1000/jour)
Approches hybrides
Combiner plusieurs stratégies
Les approches hybrides combinent plusieurs stratégies de chunking pour obtenir le meilleur des deux mondes : qualité du semantic/structure-based avec prévisibilité du recursive/fixed-size. C'est l'approche utilisée en production par les systèmes RAG les plus performants.
Pattern 1 : Structure-first, puis Recursive fallback
def hybrid_structure_recursive(document: str,
max_chunk_size: int = 1024) -> list[Document]:
"""
1. Tenter de découper par structure (sections Markdown, classes Python)
2. Si un chunk dépasse max_chunk_size, le re-découper avec recursive
"""
# Étape 1: Découpage structurel
structure_chunks = structure_based_split(document)
final_chunks = []
for chunk in structure_chunks:
tokens = count_tokens(chunk)
if tokens <= max_chunk_size:
# Chunk OK : respecte structure ET taille
final_chunks.append(chunk)
else:
# Chunk trop gros : re-découper avec recursive
from langchain.text_splitter import RecursiveCharacterTextSplitter
sub_splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk_size,
chunk_overlap=100
)
sub_chunks = sub_splitter.split_text(chunk)
final_chunks.extend(sub_chunks)
return final_chunks
Pattern 2 : Semantic chunking avec contrainte de taille
def hybrid_semantic_size_constraint(document: str,
min_chunk_size: int = 256,
max_chunk_size: int = 1024) -> list[Document]:
"""
1. Découpage sémantique pour trouver les frontières naturelles
2. Fusionner les chunks trop petits (max_size)
"""
# Étape 1: Semantic chunking
semantic_chunks = semantic_split(document)
# Étape 2: Post-processing pour respecter contraintes de taille
final_chunks = []
current_chunk = ""
for chunk in semantic_chunks:
tokens = count_tokens(chunk)
if tokens < min_chunk_size:
# Chunk trop petit : fusionner avec le suivant
current_chunk += "\n\n" + chunk
elif tokens > max_chunk_size:
# Chunk trop gros : diviser
if current_chunk:
final_chunks.append(current_chunk)
current_chunk = ""
# Re-découper le chunk trop gros
sub_chunks = recursive_split(chunk, max_chunk_size)
final_chunks.extend(sub_chunks)
else:
# Chunk taille OK
if current_chunk:
final_chunks.append(current_chunk)
current_chunk = ""
final_chunks.append(chunk)
if current_chunk:
final_chunks.append(current_chunk)
return final_chunks
Adaptive chunking
L'adaptive chunking ajuste dynamiquement la stratégie selon le type de contenu détecté dans le document :
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
class AdaptiveChunker:
def __init__(self):
self.recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50
)
self.semantic_splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
breakpoint_threshold_type="percentile"
)
def detect_content_type(self, text: str) -> str:
"""Détecte automatiquement le type de contenu"""
# Détecter du code Python
if re.search(r'\bdef\s+\w+\s*\(|\bclass\s+\w+', text):
return "code"
# Détecter Markdown structuré
if re.search(r'^#{1,6}\s+', text, re.MULTILINE):
return "markdown"
# Détecter liste de données (logs, CSV)
lines = text.split('\n')
if len(lines) > 20:
avg_line_length = sum(len(line) for line in lines) / len(lines)
if 20 < avg_line_length < 200: # Lignes courtes uniformes
return "data"
# Détecter texte narratif (articles, documentation)
sentences = re.split(r'[.!?]+\s+', text)
if len(sentences) > 10:
avg_sentence_length = sum(len(s.split()) for s in sentences) / len(sentences)
if avg_sentence_length > 15: # Phrases longues = narratif
return "narrative"
return "generic"
def chunk_adaptive(self, text: str, metadata: dict = None) -> list[Document]:
"""Sélectionne automatiquement la meilleure stratégie"""
content_type = self.detect_content_type(text)
print(f"Détecté: {content_type}")
if content_type == "code":
# Code : structure-based (AST)
return self.chunk_code(text, metadata)
elif content_type == "markdown":
# Markdown : structure-based (headers)
return self.chunk_markdown(text, metadata)
elif content_type == "data":
# Données : fixed-size (rapide)
return self.chunk_fixed_size(text, metadata)
elif content_type == "narrative":
# Narratif : semantic (qualité)
chunks = self.semantic_splitter.split_text(text)
else:
# Générique : recursive (compromis)
chunks = self.recursive_splitter.split_text(text)
return [Document(page_content=chunk, metadata=metadata or {}) for chunk in chunks]
# Utilisation
chunker = AdaptiveChunker()
# Exemple 1: Document narratif (utilisera semantic chunking)
article = """Les bases vectorielles sont essentielles..."""
docs1 = chunker.chunk_adaptive(article, {"source": "article.txt"})
# Exemple 2: Code Python (utilisera AST parsing)
code = """def process_data(input):\n return input * 2"""
docs2 = chunker.chunk_adaptive(code, {"source": "script.py"})
Chunking multi-niveau
Le chunking multi-niveau crée plusieurs représentations du même contenu à différentes granularités, permettant des recherches à plusieurs niveaux de détail :
Concept de Parent-Child Chunks
On indexe de petits chunks (child) pour maximiser la précision de recherche, mais on fournit au LLM le contexte complet (parent) pour générer de meilleures réponses. Utilisé par LlamaIndex et les systèmes RAG avancés.
from llama_index.core.node_parser import HierarchicalNodeParser
from llama_index.core import Document
class MultiLevelChunker:
def __init__(self):
# Définir 3 niveaux de granularité
self.parser = HierarchicalNodeParser.from_defaults(
chunk_sizes=[2048, 512, 128] # Parent, Medium, Child
)
def create_hierarchical_chunks(self, text: str) -> dict:
"""
Crée une hiérarchie de chunks :
- Level 0 (Parent) : 2048 tokens - contexte large
- Level 1 (Medium) : 512 tokens - chunks standards
- Level 2 (Child) : 128 tokens - chunks précis pour recherche
"""
doc = Document(text=text)
nodes = self.parser.get_nodes_from_documents([doc])
# Organiser les nodes par niveau
hierarchy = {
"parent": [],
"medium": [],
"child": []
}
for node in nodes:
level = node.metadata.get("level", 0)
if level == 0:
hierarchy["parent"].append(node)
elif level == 1:
hierarchy["medium"].append(node)
elif level == 2:
hierarchy["child"].append(node)
return hierarchy
def search_and_expand(self, query: str, index) -> str:
"""
1. Rechercher dans les child nodes (précision maximale)
2. Récupérer le parent node correspondant (contexte complet)
3. Fournir le parent au LLM
"""
# Recherche dans les child nodes (petits, précis)
child_results = index.search(query, top_k=3, level="child")
# Récupérer les parents correspondants
parent_contexts = []
for child_node in child_results:
parent_id = child_node.metadata.get("parent_id")
parent_node = index.get_node(parent_id)
parent_contexts.append(parent_node.text)
# Fournir les parents au LLM
context = "\n\n---\n\n".join(parent_contexts)
return context
# Utilisation
chunker = MultiLevelChunker()
long_document = """...(10,000+ tokens document)..."""
hierarchy = chunker.create_hierarchical_chunks(long_document)
print(f"Parent chunks: {len(hierarchy['parent'])}")
print(f"Medium chunks: {len(hierarchy['medium'])}")
print(f"Child chunks: {len(hierarchy['child'])}")
# Avantages:
# - Recherche ultra-précise (child chunks de 128 tokens)
# - Contexte riche pour LLM (parent chunks de 2048 tokens)
# - Flexibilité : choisir le niveau selon le type de question
Exemples d'architectures hybrides
Architecture 1 : Production RAG Enterprise
Système RAG pour documentation technique d'entreprise (50M tokens, 75,000 docs) :
class EnterpriseRAGChunker:
def __init__(self):
self.markdown_parser = MarkdownHeaderTextSplitter(...)
self.code_parser = PythonCodeChunker(...)
self.recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=768,
chunk_overlap=75
)
def process_document(self, doc_path: str) -> list[Document]:
with open(doc_path, 'r') as f:
content = f.read()
# Router selon l'extension
if doc_path.endswith('.md'):
# Markdown : structure-based
chunks = self.markdown_parser.split_text(content)
elif doc_path.endswith('.py'):
# Python : AST parsing
chunks = self.code_parser.chunk_python_file(doc_path)
else:
# Autres : recursive
chunks = self.recursive_splitter.split_text(content)
# Post-processing : vérifier taille et re-découper si nécessaire
final_chunks = []
for chunk in chunks:
if count_tokens(chunk) > 1024:
sub_chunks = self.recursive_splitter.split_text(chunk)
final_chunks.extend(sub_chunks)
else:
final_chunks.append(chunk)
return final_chunks
# Résultat : 90% qualité semantic, 10% coût semantic
Architecture 2 : RAG Médical Haute Précision
Système RAG pour recherche médicale (criticité maximale, précision > qualité) :
class MedicalRAGChunker:
def __init__(self):
# Sentence-window pour précision maximale
self.sentence_parser = SentenceWindowNodeParser(
window_size=3
)
# Semantic chunking pour les sections longues
self.semantic_splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(model="text-embedding-3-large"),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=80 # Seuil élevé = plus de petits chunks
)
def chunk_medical_paper(self, paper_text: str) -> list[Document]:
# Étape 1: Identifier les sections critiques
sections = self.extract_sections(paper_text)
all_chunks = []
for section_name, section_text in sections.items():
if section_name in ["Results", "Conclusion", "Abstract"]:
# Sections critiques : sentence-window (précision maximale)
chunks = self.sentence_parser.get_nodes_from_documents(
[Document(text=section_text)]
)
else:
# Autres sections : semantic chunking
chunks = self.semantic_splitter.split_text(section_text)
# Enrichir métadonnées
for chunk in chunks:
chunk.metadata["section"] = section_name
chunk.metadata["critical"] = section_name in ["Results", "Conclusion"]
all_chunks.extend(chunks)
return all_chunks
# Résultat : Précision maximale pour sections critiques, coût optimisé ailleurs
Architecture 3 : RAG Multi-Langue Global
Système RAG pour support client en 12 langues :
class MultilingualRAGChunker:
def __init__(self):
self.recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50
)
# Embeddings multilingues
self.embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
def chunk_with_language_detection(self, text: str) -> list[Document]:
from langdetect import detect
# Détecter la langue
language = detect(text)
# Adapter les séparateurs selon la langue
if language in ['ja', 'zh-cn', 'zh-tw']: # Langues asiatiques
# Pas d'espaces entre mots : utiliser des séparateurs spécifiques
separators = ["。", "!", "?", "\n\n", "\n", ",", ""]
elif language in ['ar', 'he']: # Langues RTL
separators = [".؟", ". ", "\n\n", "\n", "، ", " ", ""]
else: # Langues européennes
separators = ["\n\n", "\n", ". ", "! ", "? ", ", ", " ", ""]
self.recursive_splitter.separators = separators
chunks = self.recursive_splitter.split_text(text)
# Enrichir avec langue détectée
return [
Document(
page_content=chunk,
metadata={"language": language}
)
for chunk in chunks
]
# Résultat : Respect de la structure linguistique de chaque langue
Recommandation : Toujours commencer hybride
En production, ne choisissez jamais une seule stratégie. L'approche hybride structure-first + recursive fallback offre le meilleur compromis pour 90% des cas :
- Respecte la structure quand elle existe (Markdown, code)
- Fallback sur recursive pour les sections non-structurées
- Garantit la taille maximale (pas de surprise context window)
- Coût minimal (pas d'API embeddings pour chunking)
Aide à la décision
Arbre de décision pour choisir sa stratégie
Suivez cet arbre de décision pour sélectionner la stratégie optimale selon votre contexte :
┌─ Volume de données ? │ ├── < 100K tokens (petit corpus) │ └──▶ SEMANTIC CHUNKING │ Qualité maximale, coût négligeable │ ├── 100K - 10M tokens (moyen) │ └── Documents structurés (Markdown, code) ? │ ├── Oui → STRUCTURE-BASED + RECURSIVE FALLBACK │ │ Respecte structure, garantit taille max │ │ │ └── Non → Criticité de la précision ? │ ├── Haute (médical, légal) → SEMANTIC CHUNKING │ │ ROI justifié par criticité │ │ │ └── Normale → RECURSIVE CHUNKING │ Meilleur rapport qualité/prix (80% cas) │ └── > 10M tokens (gros volume) └── Contrainte budget ? ├── Stricte → RECURSIVE CHUNKING │ Coût minimal, qualité acceptable │ └── Flexible → Type de requêtes ? ├── Précises (FAQ) → SENTENCE-WINDOW │ Précision max pour facts │ └── Générales → RECURSIVE CHUNKING Sweet spot production
Règle d'or : Commencer simple, optimiser si nécessaire
1. Démarrez avec RECURSIVE CHUNKING (chunk_size=512, overlap=50). C'est le meilleur point de départ pour 80% des cas.
2. Mesurez les performances (recall, precision, latence) sur votre dataset réel.
3. Optimisez uniquement si nécessaire : Si recall < 70%, envisagez semantic. Si latence > 200ms, envisagez fixed-size.
Recommandations par type de contenu
Type de contenu | Stratégie recommandée | Config optimale | Justification |
---|---|---|---|
Documentation technique | Structure-Based (Markdown) + Recursive | chunk_size=768, overlap=75 | Respecte hiérarchie sections, contexte préservé |
Code source (Python, JS) | Structure-Based (AST) | chunk_size=1024, par fonction/classe | Unités logiques complètes, jamais coupe fonction |
Articles de blog | Semantic Chunking | percentile=25, max_size=1024 | Structure narrative, cohérence importante |
Documents légaux/contrats | Semantic Chunking | percentile=20, max_size=768 | Chaque clause complète, contexte critique |
FAQ / Q&A | Sentence-Window | window_size=2, stride=1 | Questions précises, réponses courtes |
Papers scientifiques | Hybrid: Sentence-Window (Results/Conclusion) + Semantic (autres) | window_size=3 pour sections critiques | Précision max pour résultats, économie ailleurs |
Transcriptions audio/vidéo | Fixed-Size + overlap élevé | chunk_size=512, overlap=100 (20%) | Peu de structure, overlap compense fragmentation |
Emails | Fixed-Size (si volume élevé) ou Recursive | chunk_size=256, overlap=30 | Courts, peu de structure hiérarchique |
Pages web (HTML) | Structure-Based (HTML sections) | Extraire <article>, <section>, ignorer nav/footer | Structure sémantique HTML, filtrer bruit |
Bases de connaissances (Notion, Confluence) | Recursive Chunking | chunk_size=768, overlap=75, markdown separators | Structure variable, compromis qualité/coût optimal |
PDF numérisés (OCR) | Fixed-Size + overlap élevé | chunk_size=512, overlap=100 | OCR = erreurs, structure peu fiable |
Logs système | Fixed-Size | chunk_size=256, overlap=0 | Homogène, pas de contexte narratif, volume élevé |
Paramètres à tester
Pour optimiser votre stratégie de chunking, testez systématiquement ces paramètres sur votre corpus réel :
1. Taille de chunk (chunk_size)
Testez plusieurs tailles pour trouver le sweet spot :
Chunk Size | Avantages | Inconvénients | Cas d'usage |
---|---|---|---|
128-256 tokens | Précision maximale, peu de bruit | Contexte fragmenté, plus de chunks = coût | FAQ, Q&A précis, recherche factuelle |
512 tokens | Bon compromis, standard RAG | Peut fragmenter concepts longs | Production généraliste (recommandé) |
768-1024 tokens | Contexte riche, moins de fragmentation | Plus de bruit, recall peut baisser | Documentation technique, articles longs |
1536-2048 tokens | Contexte très riche pour LLM | Bruit élevé, risque de hors-sujet | Narrative longue, papers scientifiques |
Script de test :
chunk_sizes_to_test = [256, 512, 768, 1024, 1536]
for chunk_size in chunk_sizes_to_test:
chunker = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=int(chunk_size * 0.1) # 10% overlap
)
chunks = chunker.split_text(your_corpus)
metrics = evaluate_rag(chunks, test_questions)
print(f"Chunk size {chunk_size}: Recall={metrics.recall:.2f}, MRR={metrics.mrr:.2f}")
2. Overlap (chevauchement)
Testez différents taux d'overlap :
- 0% (overlap=0) : Pas de chevauchement, coût minimal mais contexte perdu aux frontières
- 10% (overlap=chunk_size/10) : Léger chevauchement, bon compromis
- 20% (overlap=chunk_size/5) : Standard recommandé, capture bien le contexte
- 30-50% : Overlap élevé, redondance importante, coût élevé
Impact mesuré : Sur nos benchmarks, passer de 0% à 20% d'overlap améliore le recall de 15-25% en moyenne, pour un surcoût de 20% (plus de chunks).
3. Séparateurs (pour recursive chunking)
Adaptez la hiérarchie de séparateurs à votre contenu :
# Test 1: Séparateurs standards (texte général)
separators_v1 = ["\n\n", "\n", ". ", "! ", "? ", ", ", " ", ""]
# Test 2: Priorité aux sections (documentation)
separators_v2 = ["\n## ", "\n### ", "\n\n", "\n", ". ", " ", ""]
# Test 3: Adapté au code
separators_v3 = ["\nclass ", "\ndef ", "\n\n", "\n", " ", ""]
for seps in [separators_v1, separators_v2, separators_v3]:
chunker = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=seps
)
# Tester et mesurer...
4. Modèle d'embeddings (pour semantic chunking)
Le choix du modèle d'embeddings impacte fortement la qualité :
- text-embedding-3-small : Meilleur rapport qualité/prix ($0.02/1M tokens)
- text-embedding-3-large : Qualité maximale ($0.13/1M tokens, +5% recall vs small)
- all-MiniLM-L6-v2 : Gratuit (local), bon pour prototypes (-10% recall vs OpenAI)
Métriques pour évaluer et comparer
Pour évaluer objectivement vos stratégies de chunking, mesurez ces métriques sur un dataset de test annoté :
Métriques de récupération (Retrieval)
Métrique | Définition | Interprétation | Cible |
---|---|---|---|
Recall@k | % de réponses correctes trouvées dans top-k résultats | Plus élevé = meilleure couverture | > 0.80 |
Precision@k | % de résultats pertinents dans top-k | Plus élevé = moins de bruit | > 0.60 |
MRR (Mean Reciprocal Rank) | 1/rang du premier résultat pertinent (moyenne) | Plus élevé = réponse en première position | > 0.75 |
NDCG@k | Normalized Discounted Cumulative Gain | Prend en compte le rang (meilleur que Recall seul) | > 0.70 |
Métriques de génération (End-to-end)
Métrique | Définition | Cible |
---|---|---|
Answer Relevance | La réponse du LLM est-elle pertinente ? (0-1) | > 0.85 |
Faithfulness | La réponse est-elle fidèle au contexte récupéré ? (pas d'hallucination) | > 0.90 |
Context Relevance | Le contexte récupéré est-il pertinent pour la question ? | > 0.80 |
Métriques opérationnelles
- Latence end-to-end : Temps total (recherche + génération) - Cible < 2s
- Coût par requête : Embeddings + LLM generation - Cible < $0.01
- Throughput : Requêtes/seconde supportées - Cible > 10 RPS
Script d'évaluation complet
from ragas import evaluate
from ragas.metrics import (
answer_relevancy,
faithfulness,
context_recall,
context_precision,
)
from datasets import Dataset
def evaluate_chunking_strategy(chunks, test_questions):
"""
Évalue une stratégie de chunking sur un dataset de test
"""
# Créer l'index vectoriel
index = create_vector_index(chunks)
# Préparer le dataset d'évaluation
eval_data = {
"question": [],
"answer": [],
"contexts": [],
"ground_truth": []
}
for q in test_questions:
# Récupérer les chunks pertinents
retrieved_chunks = index.search(q["question"], top_k=5)
# Générer la réponse
answer = generate_answer(q["question"], retrieved_chunks)
eval_data["question"].append(q["question"])
eval_data["answer"].append(answer)
eval_data["contexts"].append([c.text for c in retrieved_chunks])
eval_data["ground_truth"].append(q["expected_answer"])
# Évaluer avec RAGAS
dataset = Dataset.from_dict(eval_data)
results = evaluate(
dataset,
metrics=[
context_recall,
context_precision,
answer_relevancy,
faithfulness,
]
)
return results
# Comparer plusieurs stratégies
strategies = {
"fixed_size": fixed_size_chunks,
"recursive": recursive_chunks,
"semantic": semantic_chunks
}
for name, chunks in strategies.items():
print(f"\n=== {name.upper()} ===")
results = evaluate_chunking_strategy(chunks, test_questions)
print(results)
print(f"Recall: {results['context_recall']:.3f}")
print(f"Precision: {results['context_precision']:.3f}")
print(f"Faithfulness: {results['faithfulness']:.3f}")
Checklist finale : Validation avant production
- ☑ Recall@5 > 0.75 sur votre dataset de test
- ☑ Latence end-to-end < 2s (p95) en conditions réelles
- ☑ Coût par requête < $0.01 (ou votre seuil de rentabilité)
- ☑ Faithfulness > 0.90 (pas d'hallucinations)
- ☑ Tests sur edge cases : documents très longs, très courts, mal formatés
- ☑ Tests multilingues si applicable
Développement de stratégies de chunking sur mesure
Votre contenu a des spécificités uniques ? Nous pouvons concevoir et implémenter une stratégie de chunking personnalisée adaptée à vos documents et contraintes.
Discuter de votre besoinQuestions fréquentes
Quelle stratégie donne les meilleurs résultats ?
Il n'y a pas de "meilleure" stratégie universelle. Semantic chunking offre la meilleure qualité de recall (+25-40% vs fixed-size) mais coûte 3-5x plus cher. Recursive chunking est le meilleur compromis pour 80% des cas : 85-90% de la qualité du semantic pour 1% du coût. Pour des cas critiques (médical, légal), l'investissement dans semantic chunking est justifié. Pour du prototypage ou gros volumes, fixed-size suffit.
Le semantic chunking est-il toujours meilleur ?
Non. Semantic chunking n'est meilleur que si : (1) votre corpus a une structure narrative forte (articles, documentation), (2) vous avez le budget pour les API embeddings, (3) la latence de processing n'est pas critique. Pour des logs, données homogènes, ou volumes >100M tokens, le coût computationnel (50-100x plus lent) et financier (3-5x plus cher) du semantic chunking ne sont pas justifiés. Recursive chunking offre 85-90% de la qualité pour 1% du coût.
Comment tester différentes stratégies ?
Méthode systématique : (1) Créez un dataset de test avec 50-100 questions représentatives et leurs réponses attendues. (2) Implémentez 2-3 stratégies (ex: fixed-size, recursive, semantic). (3) Indexez votre corpus avec chaque stratégie. (4) Mesurez les métriques : Recall@5, Precision@5, MRR, latence, coût. (5) Utilisez RAGAS ou LlamaIndex evaluation pour automatiser. (6) La stratégie avec le meilleur score pondéré (qualité × coût × latence) gagne.
Peut-on changer de stratégie après la mise en production ?
Oui, mais avec précaution. Changer de stratégie nécessite de ré-indexer tout le corpus (re-chunking + re-embeddings), ce qui peut prendre des heures à des jours selon le volume. Bonnes pratiques : (1) Versionnez vos indexes (index_v1, index_v2). (2) Testez la nouvelle stratégie sur un subset avant de tout migrer. (3) Implémentez un A/B test : 10% du trafic sur nouvelle stratégie, 90% sur l'ancienne. (4) Migrez progressivement si les métriques s'améliorent. (5) Gardez toujours un rollback possible.
Quelle stratégie pour les documents multilingues ?
Recursive chunking avec séparateurs adaptés est optimal. Adaptez les séparateurs selon la langue : (1) Langues européennes (EN, FR, ES, DE) : separators standards [". ", "! ", "? "]. (2) Langues asiatiques (JA, ZH, KO) : utilisez séparateurs spécifiques ["。", "!", "?"]. (3) Langues RTL (AR, HE) : separators adaptés [". ؟", "، "]. (4) Utilisez des embeddings multilingues (text-embedding-3-large supporte 100+ langues, all-MiniLM-L6-v2 pour 50+ langues). (5) Détection automatique : Utilisez langdetect pour adapter les séparateurs dynamiquement par document.