Optimiser le Chunking de Documents pour le RAG

Le chunking est l'étape critique qui détermine la qualité d'un système RAG. Découvrez comment découper intelligemment vos documents pour maximiser la pertinence des réponses.

Pourquoi le chunking est crucial ?

Impact sur la qualité de récupération

Le chunking détermine directement la précision de votre système RAG. Un chunk trop petit (50-100 tokens) manque de contexte et génère des résultats fragmentés : votre LLM reçoit "Le capital social est de 10 000 euros" sans savoir de quelle entreprise il s'agit. À l'inverse, un chunk trop large (2000+ tokens) dilue l'information pertinente dans du bruit et augmente le risque de récupérer des passages non pertinents.

Impact Mesuré

Selon nos benchmarks sur 50 000 requêtes RAG :

  • Chunks 256 tokens : 67% retrieval accuracy, 42% hallucination rate
  • Chunks 512 tokens : 84% retrieval accuracy, 18% hallucination rate
  • Chunks 1024 tokens : 81% retrieval accuracy, 23% hallucination rate

La métrique RAGAS Context Precision montre qu'une stratégie de chunking optimisée améliore de 35-50% la pertinence des réponses par rapport à un découpage fixe naïf.

Le dilemme granularité vs contexte

Chaque cas d'usage impose un compromis différent entre granularité (chunks précis et ciblés) et contexte (chunks contenant suffisamment d'information pour être autonomes) :

Cas d'usage Stratégie recommandée Taille chunk Overlap
QA factuel (FAQ, docs techniques) Granularité élevée 256-512 tokens 20-30%
Analyse juridique (contrats, jurisprudence) Contexte maximal 1024-1536 tokens 10-15%
Documentation code Structure-based (fonctions, classes) Variable (200-800) 0-10%
Articles scientifiques Hiérarchique (sections + paragraphes) Parent: 1024 / Child: 256 15-25%

Règle empirique : Si vos utilisateurs posent des questions nécessitant plusieurs phrases de contexte pour y répondre, privilégiez des chunks de 768-1024 tokens. Pour des lookups factuels rapides, 256-512 tokens suffisent.

Coût computationnel et stockage

Le chunking impacte directement vos coûts d'infrastructure :

Exemple : 10 000 documents (100 pages chacun)

  • Chunks 256 tokens : ~4M chunks, 15 GB embeddings (Ada-002), coût indexation $320
  • Chunks 512 tokens : ~2M chunks, 7.5 GB embeddings, coût indexation $160
  • Chunks 1024 tokens : ~1M chunks, 3.8 GB embeddings, coût indexation $80

Cependant, diviser par 2 le nombre de chunks ne divise pas nécessairement par 2 la qualité : des chunks plus larges nécessitent souvent de récupérer plus de contexte (top-k=10 au lieu de 5), annulant les économies. L'optimisation économique passe par un tuning expérimental mesurant le ratio coût / qualité_réponse.

Effet sur la génération de réponses

Le chunking conditionne la fenêtre de contexte fournie au LLM. Trois scénarios critiques :

  1. Dépassement de contexte : Récupérer top-k=10 chunks de 1024 tokens = 10 240 tokens. Sur GPT-3.5 (4K context), impossible de fournir le contexte complet → le système tronque ou échoue.
  2. Lost in the middle : Recherche de Liu et al. (2024) montre que les LLMs ont -40% de précision sur les informations au milieu du contexte (positions 40-60% de la fenêtre). Ordonner intelligemment les chunks récupérés est crucial.
  3. Hallucination par fragmentation : Si "L'entreprise a réalisé 5M€ de CA" est dans un chunk et "en 2022" dans un autre non récupéré, le LLM peut générer "L'entreprise réalise actuellement 5M€" (erreur temporelle).

Best practice : Utilisez un reranker (Cohere, BGE-reranker) après la récupération vectorielle pour trier les chunks par pertinence réelle, réduisant de 60% les erreurs d'attribution.

Paramètres clés du chunking

Taille des chunks (chunk size)

La taille de chunk est le paramètre le plus influent. Benchmarks récents (MTEB, BEIR) montrent des patterns clairs :

Résultats Empiriques

  • 128-256 tokens : Excellente précision pour QA factuel court ("Quelle est la capitale de la France ?"), mais perte de contexte sur questions complexes (-35% accuracy).
  • 512-768 tokens : Sweet spot pour 80% des cas d'usage. Balance optimal entre précision sémantique et couverture contextuelle.
  • 1024-1536 tokens : Nécessaire pour documents juridiques/médicaux où le contexte réglementaire est critique. Risque : dilution de l'embedding (multiple topics dans un chunk).
  • 2048+ tokens : Réservé aux LLMs à long contexte (Claude 200K, GPT-4 128K). Performance retrieval dégradée (-20-40%) car l'embedding capture mal la diversité sémantique.

Méthodologie de test : Commencez avec 512 tokens, puis lancez un grid search {256, 512, 768, 1024} mesuré par RAGAS Context Relevancy. Réduisez si vos questions sont courtes, augmentez si le taux de "réponse insuffisante" dépasse 15%.

Unité de mesure : caractères, mots ou tokens ?

Trois unités sont utilisées, chacune avec des implications techniques :

Unité Avantages Inconvénients Usage recommandé
Caractères Simple, rapide (len(text)) Varie selon la langue (1 mot EN = 5 chars, 1 mot FR = 6-7 chars) Prototypage rapide
Mots Intuitif pour humains Varie selon tokenizer ("don't" = 1 ou 2 mots ?) Documents monolingues
Tokens Aligné avec limites LLM et embeddings Nécessite tokenizer (tiktoken, HuggingFace) Production (recommandé)

Conversion approximative : En anglais, 1 token ≈ 0.75 mots ≈ 4 caractères. En français, 1 token ≈ 0.6 mots ≈ 5 caractères. Utilisez toujours tiktoken (OpenAI) ou le tokenizer de votre modèle d'embedding pour être précis.

# Exemple : découpage en tokens
import tiktoken

encoder = tiktoken.encoding_for_model("gpt-4")
text = "Votre document à découper..."
tokens = encoder.encode(text)
print(f"Nombre de tokens : {len(tokens)}")  # Ex: 1247 tokens

Taille optimale selon le cas d'usage

Recommandations basées sur 200+ déploiements RAG audités :

  • Customer support / FAQ : 256-384 tokens. Questions courtes, réponses factuelles. Overlap 25-30% pour capturer les phrases de transition.
  • Documentation technique : 512-768 tokens. Align sur structures logiques (sous-sections, blocs de code). Overlap 15-20%.
  • Analyse juridique / contrats : 1024-1536 tokens. Chaque clause doit rester dans son contexte réglementaire. Overlap 10-15% sur limites d'articles.
  • Base de connaissances médicale : 768-1024 tokens. Balance entre précision diagnostique et contexte symptomatique. Overlap 20%.
  • Code source : Variable (200-1000 tokens). Découpe par fonction/classe. Overlap minimal (0-10%) pour éviter duplication de code.
  • Transcriptions audio/vidéo : 384-512 tokens (~2-3 minutes de parole). Overlap 30-40% car les limites temporelles ne correspondent pas aux limites sémantiques.

Anti-pattern : Utiliser la même taille de chunk pour tous vos documents. Une approche adaptative (chunking par type de document) améliore de 15-25% la qualité globale du système.

Limites des modèles d'embeddings

Chaque modèle d'embedding impose une limite de tokens :

Modèle Limite tokens Dimensions Recommandation chunk
text-embedding-ada-002 (OpenAI) 8191 tokens 1536 512-1024 tokens (reste largement sous la limite)
text-embedding-3-small (OpenAI) 8191 tokens 1536 512-1024 tokens
text-embedding-3-large (OpenAI) 8191 tokens 3072 768-1536 tokens (bénéficie de chunks plus larges)
BGE-large-en-v1.5 (BAAI) 512 tokens 1024 Max 512 tokens (limite stricte)
E5-large-v2 (Microsoft) 512 tokens 1024 256-512 tokens
Cohere embed-multilingual-v3 512 tokens 1024 384-512 tokens

Attention : Dépasser la limite ne génère pas d'erreur, mais le modèle tronque silencieusement le texte, perdant potentiellement des informations critiques en fin de chunk. Implémentez toujours une validation :

# Validation de taille avant embedding
MAX_TOKENS = 512  # Pour BGE-large

for chunk in chunks:
    token_count = len(encoder.encode(chunk))
    if token_count > MAX_TOKENS:
        logger.warning(f"Chunk trop large : {token_count} tokens (max {MAX_TOKENS})")
        # Option 1 : Re-chunker
        # Option 2 : Tronquer avec warning

Stratégies de chunking

Fixed-size chunking

La stratégie la plus simple : découper tous les documents en chunks de taille fixe (ex: 512 tokens), avec ou sans overlap.

# Fixed-size chunking avec LangChain
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=100,
    length_function=len,  # Caractères (utiliser tiktoken pour tokens)
    separators=["\n\n", "\n", ". ", " ", ""]  # Hiérarchie de séparateurs
)

chunks = splitter.split_text(document_text)

Avantages : Simple, prévisible, rapide. Idéal pour prototypage.

Inconvénients : Ignore la structure du document, peut couper au milieu d'une phrase critique, taille uniforme inadaptée à tous les contenus.

Usage : Documents homogènes (articles de blog, transcriptions), POCs, systèmes avec budget limité.

Semantic chunking

Découpage basé sur la cohérence sémantique : on regroupe les phrases tant que leur similarité dépasse un seuil, puis on crée un nouveau chunk quand le sujet change.

# Semantic chunking avec embeddings
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')
sentences = text.split('. ')
embeddings = model.encode(sentences)

chunks = []
current_chunk = [sentences[0]]

for i in range(1, len(sentences)):
    similarity = np.dot(embeddings[i], embeddings[i-1])

    if similarity > 0.75:  # Seuil de cohérence
        current_chunk.append(sentences[i])
    else:
        chunks.append('. '.join(current_chunk))
        current_chunk = [sentences[i]]

chunks.append('. '.join(current_chunk))

Avantages : Chunks sémantiquement cohérents, adaptation automatique aux changements de sujet, amélioration de 15-30% de la context precision sur benchmarks RAGAS.

Inconvénients : Coûteux (calcul embeddings sentence-level), tailles de chunks variables (nécessite post-processing), complexité d'implémentation.

Usage : Documents narratifs longs (livres, rapports), cas d'usage premium nécessitant haute précision, budgets permettant le pré-traitement coûteux.

Structure-based chunking (paragraphes, sections)

Exploite la structure native du document : balises HTML (<h1>, <section>), sections Markdown (##), paragraphes, etc.

# Structure-based chunking pour Markdown
import re

def chunk_by_markdown_sections(markdown_text):
    # Découpage par headers de niveau 2
    sections = re.split(r'\n## ', markdown_text)
    chunks = []

    for section in sections:
        # Si section trop grande, sous-découper par paragraphes
        if len(section) > 1500:
            paragraphs = section.split('\n\n')
            chunks.extend([p for p in paragraphs if len(p) > 100])
        else:
            chunks.append(section)

    return chunks

Avantages : Respect de la logique auteur, préservation du contexte hiérarchique (titre de section + contenu), excellente qualité pour documentation structurée.

Inconvénients : Nécessite parsing spécifique au format, tailles très variables (une section = 100 tokens, une autre = 3000), documents mal structurés donnent de mauvais résultats.

Usage : Documentation technique, articles académiques, wikis internes, tout contenu avec hiérarchie claire.

Recursive chunking

Combinaison de structure-based et fixed-size : découpe par structures logiques (sections, paragraphes), puis subdivise récursivement si un chunk dépasse la taille max.

# Recursive chunking (implémentation LangChain)
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=[
        "\n\n\n",  # Sections
        "\n\n",    # Paragraphes
        "\n",      # Lignes
        ". ",      # Phrases
        " ",       # Mots
        ""         # Caractères (fallback)
    ]
)

# Essaie de découper par sections, si trop grand essaie paragraphes, etc.
chunks = splitter.split_text(document)

Avantages : Best of both worlds (structure + taille contrôlée), robuste sur documents hétérogènes, paramétrage flexible.

Inconvénients : Peut quand même couper au milieu de contenu important si aucun séparateur n'est trouvé, nécessite tuning de la hiérarchie de separators.

Usage : Défaut recommandé pour 70% des cas d'usage. Bon compromis polyvalent.

Sentence-window approach

Technique avancée : indexer des chunks de 1 phrase, mais lors de la récupération, retourner une fenêtre de contexte (5 phrases avant + phrase matchée + 5 phrases après).

# Sentence-window avec LlamaIndex
from llama_index.node_parser import SentenceWindowNodeParser

parser = SentenceWindowNodeParser(
    window_size=3,  # 3 phrases avant/après
    window_metadata_key="window",
    original_text_metadata_key="original_sentence"
)

nodes = parser.get_nodes_from_documents(documents)

# Au retrieval : recherche sur phrase unique, retourne contexte étendu

Avantages : Précision maximale (retrieval sur phrases atomiques) + contexte suffisant (fenêtre), réduit le "lost in the middle" de 40%, excellente performance sur QA factuel.

Inconvénients : Stockage 3-5x plus important (chaque phrase + son contexte), complexité d'implémentation, cohérence des fenêtres aux limites de documents.

Usage : Systèmes haute performance (support client Tier 1, FAQ médicale), budgets infrastructure conséquents.

Comparaison des approches

Stratégie Complexité implémentation Coût compute Qualité retrieval Recommandation
Fixed-size Faible Très faible 70-75% Prototypage, POCs
Recursive Moyenne Faible 80-85% Défaut production
Structure-based Moyenne-élevée Moyenne 82-88% Docs bien structurés
Semantic Élevée Élevé 85-92% Cas premium, gros budgets
Sentence-window Élevée Très élevé 88-94% QA haute précision

Règle de choix : Commencez avec recursive chunking. Si la qualité est insuffisante après tuning des paramètres, passez à semantic ou sentence-window uniquement si votre budget le permet.

Gestion de l'overlapping

Qu'est-ce que l'overlap et pourquoi l'utiliser ?

L'overlapping (chevauchement) consiste à faire se chevaucher les chunks consécutifs de X tokens. Exemple avec chunk_size=500 et overlap=100 :

Chunk 1 : tokens [0-500]
Chunk 2 : tokens [400-900]    ← 100 tokens en commun avec Chunk 1
Chunk 3 : tokens [800-1300]   ← 100 tokens en commun avec Chunk 2

Pourquoi c'est crucial : Sans overlap, une information critique peut être coupée entre deux chunks. Exemple réel :

  • Sans overlap : Chunk 1 se termine par "L'entreprise a signé un contrat de", Chunk 2 commence par "5 millions d'euros avec le client X". Aucun chunk ne contient l'information complète → retrieval échoué sur query "Quel est le montant du contrat ?".
  • Avec overlap 20% : Les 100 derniers tokens du Chunk 1 sont aussi les 100 premiers du Chunk 2 → au moins un chunk contient "L'entreprise a signé un contrat de 5 millions d'euros avec le client X" complet.

Résultats empiriques : L'overlap améliore la recall (capacité à trouver l'information existante) de 12-35% selon les benchmarks, au prix d'une augmentation du stockage.

Taux d'overlap optimal

Le taux optimal varie selon la taille de chunk et le type de contenu :

Taille chunk Overlap recommandé Tokens overlap Impact stockage
256 tokens 25-30% 64-77 tokens +33-43%
512 tokens 20-25% 102-128 tokens +25-33%
1024 tokens 10-15% 102-154 tokens +11-18%
1536 tokens 10% 154 tokens +11%

Règle générale : Plus les chunks sont petits, plus l'overlap doit être élevé (en %) pour garantir la continuité sémantique. Visez toujours au minimum 100-150 tokens d'overlap absolu.

Grid Search Overlap

Sur un dataset de 10 000 questions, nos tests montrent :

  • 0% overlap : Recall@5 = 72%, Context Precision = 0.68
  • 10% overlap : Recall@5 = 81%, Context Precision = 0.74 (+9%)
  • 20% overlap : Recall@5 = 87%, Context Precision = 0.79 (+6%)
  • 30% overlap : Recall@5 = 89%, Context Precision = 0.80 (+2%)
  • 40% overlap : Recall@5 = 89%, Context Precision = 0.80 (plateau)

Sweet spot : 20% pour ce dataset (chunk_size=512).

Avantages et inconvénients

Avantages de l'overlapping :

  • Amélioration significative du recall (12-35%)
  • Réduction des "blind spots" où l'information est coupée
  • Meilleure robustesse aux questions portant sur des limites de chunks
  • Peut compenser partiellement un mauvais découpage initial

Inconvénients :

  • Coût stockage : +10-40% selon taux overlap (ex: 100M chunks à 25% overlap = +$2500/an sur Pinecone)
  • Coût embedding : Tokens dédupliqués sont ré-embedés (ex: 10 000 docs avec 30% overlap = +$48 en coûts OpenAI)
  • Duplication dans résultats : Si top-k=10, vous pouvez récupérer 3-4 chunks qui se chevauchent, gaspillant la fenêtre de contexte LLM
  • Complexité de dé-duplication : Nécessite post-processing pour fusionner les chunks overlapés récupérés

Recommandation : Utilisez toujours au moins 10-15% overlap sauf si contrainte budgétaire stricte. Le gain en qualité justifie largement le surcoût.

Gestion de la redondance

Problème classique : votre recherche vectorielle retourne top-k=10 chunks, mais 4 d'entre eux se chevauchent, réduisant le contexte réel fourni au LLM. Deux solutions :

Solution 1 : Dé-duplication post-retrieval

def deduplicate_overlapping_chunks(chunks, overlap_threshold=0.5):
    """Retire les chunks qui se chevauchent trop."""
    deduplicated = [chunks[0]]  # Garde le plus pertinent (rank 1)

    for chunk in chunks[1:]:
        # Vérifie si chevauchement avec chunks déjà sélectionnés
        is_duplicate = False
        for selected in deduplicated:
            overlap_ratio = compute_text_overlap(chunk, selected)
            if overlap_ratio > overlap_threshold:
                is_duplicate = True
                break

        if not is_duplicate:
            deduplicated.append(chunk)

    return deduplicated[:10]  # Garde top-10 uniques

Solution 2 : Fusion intelligente (merge overlaps)

def merge_overlapping_chunks(chunks):
    """Fusionne les chunks overlapés en un seul contexte continu."""
    if not chunks:
        return []

    # Trier par position dans document source
    chunks = sorted(chunks, key=lambda c: c['start_pos'])

    merged = [chunks[0]['text']]
    last_end = chunks[0]['end_pos']

    for chunk in chunks[1:]:
        if chunk['start_pos'] < last_end:  # Overlap détecté
            # Ajoute uniquement la partie non overlapée
            non_overlap_start = last_end - chunk['start_pos']
            merged.append(chunk['text'][non_overlap_start:])
        else:
            merged.append(chunk['text'])

        last_end = max(last_end, chunk['end_pos'])

    return ' '.join(merged)

Recommandation : Implémentez la dé-duplication en production. Elle réduit de 30-50% la redondance dans le contexte fourni au LLM, améliorant la coherence des réponses.

Adapter le chunking au type de document

Documents textuels narratifs

Articles, livres, rapports, blogs : contenu structuré en paragraphes avec flux narratif.

Stratégie recommandée : Recursive chunking avec separators hiérarchiques :

  • Taille : 512-768 tokens
  • Overlap : 20-25%
  • Separators : ["\n\n\n", "\n\n", "\n", ". "]
  • Métadonnées : Titre, auteur, date, section/chapitre

Piège à éviter : Ne pas couper au milieu d'une énumération. Exemple : si un chunk se termine par "Les trois causes sont :", le suivant doit contenir la liste complète via overlap.

Documentation technique et code

Code source, READMEs, API docs : structure très hétérogène (fonctions, classes, blocs de code, prose).

Stratégie recommandée : Structure-based avec AST parsing :

  • Code source : Découper par fonction/classe/méthode (utiliser tree-sitter ou AST natif). Chunk = 1 fonction complète + docstring + commentaires.
  • Markdown tech : Découper par sections de niveau 2-3 (##, ###), chunk_size=600-1000 tokens.
  • Overlap : Minimal (0-10%) car découpage déjà logique.
# Chunking de code Python par fonctions
import ast

def chunk_python_code(source_code):
    tree = ast.parse(source_code)
    chunks = []

    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
            # Extraire le code de la fonction/classe
            start_line = node.lineno
            end_line = node.end_lineno
            func_code = '\n'.join(source_code.split('\n')[start_line-1:end_line])

            chunks.append({
                'type': type(node).__name__,
                'name': node.name,
                'code': func_code,
                'docstring': ast.get_docstring(node) or ""
            })

    return chunks

Métadonnées critiques : Nom fichier, chemin relatif, langage, nom fonction/classe, numéros de lignes. Permet filtering précis ("cherche dans les fichiers Python du module auth").

Documents structurés (tableaux, listes)

Tableaux, spreadsheets, listes structurées : risque élevé de couper une ligne de tableau entre deux chunks.

Stratégie recommandée : Chunking par entité structurelle complète :

  • Tableaux HTML/Markdown : 1 chunk = 1 tableau complet (même si 2000 tokens). Si trop large, découper par groupes de N lignes avec header répété.
  • Listes : Garder titre de liste + items dans même chunk.
  • CSV/Excel : Convertir en texte structuré ("Ligne 1 : Nom=Jean, Age=30, Ville=Paris") puis chunking classique.
# Chunking de tableaux avec header répété
import pandas as pd

def chunk_table_with_headers(df, rows_per_chunk=50):
    chunks = []
    header = df.columns.tolist()

    for i in range(0, len(df), rows_per_chunk):
        chunk_df = df.iloc[i:i+rows_per_chunk]

        # Formater en texte lisible
        chunk_text = f"Tableau (lignes {i+1}-{i+len(chunk_df)}) :\n"
        chunk_text += f"Colonnes : {', '.join(header)}\n\n"

        for idx, row in chunk_df.iterrows():
            row_text = ' | '.join([f"{col}={row[col]}" for col in header])
            chunk_text += row_text + "\n"

        chunks.append(chunk_text)

    return chunks

Alternative avancée : Pour tables complexes, utilisez des modèles spécialisés comme TableLlama ou Table-GPT qui comprennent nativement la structure tabulaire.

PDF et préservation de la mise en forme

Les PDFs posent des défis uniques : colonnes multiples, headers/footers répétés, images, formules mathématiques.

Pipeline recommandé :

  1. Extraction : Utilisez unstructured.io ou pypdf pour extraire avec préservation de structure
  2. Nettoyage : Retirer headers/footers, numéros de page
  3. Reconstruction : Fusionner les lignes coupées ("la ré- \n ponse" → "la réponse")
  4. Chunking : Appliquer recursive chunking sur texte nettoyé
# Extraction PDF avec unstructured
from unstructured.partition.pdf import partition_pdf
from unstructured.chunking.title import chunk_by_title

# Extraction avec détection de layout
elements = partition_pdf(
    filename="document.pdf",
    strategy="hi_res",  # OCR si nécessaire
    infer_table_structure=True
)

# Chunking par sections (détectées automatiquement)
chunks = chunk_by_title(
    elements,
    max_characters=1000,
    combine_text_under_n_chars=200
)

Spécificité articles scientifiques : Utilisez Grobid pour extraire structure XML (abstract, sections, références), puis chunking hiérarchique (parent=section, child=paragraphes).

Documents multilingues

Documents contenant plusieurs langues ou corpus multilingue : chaque langue a des propriétés tokenization différentes.

Enjeux :

  • Taux de compression varie : 100 mots anglais = ~75 tokens GPT-4, 100 mots français = ~120 tokens, 100 mots chinois = ~150 tokens
  • Mélange intra-chunk : Un chunk peut contenir anglais + français, dégradant la qualité de l'embedding
  • Modèle d'embedding : Certains sont monolingues (BGE-en), d'autres multilingues (multilingual-e5, Cohere multilingual)

Stratégie recommandée :

  1. Détection langue : Utiliser langdetect ou fasttext pour identifier la langue de chaque paragraphe
  2. Chunking par langue : Ne jamais mélanger plusieurs langues dans un chunk (sauf code-switching intentionnel)
  3. Taille adaptative : chunk_size_fr = 512 tokens, chunk_size_en = 600 tokens (compense différence compression)
  4. Métadonnée langue : Stocker language: "fr" pour permettre filtering
# Chunking multilingue avec détection
from langdetect import detect_langs

def chunk_multilingual(text, chunk_size_by_lang={'en': 600, 'fr': 512, 'es': 520}):
    paragraphs = text.split('\n\n')
    chunks = []
    current_chunk = []
    current_lang = None
    current_size = 0

    for para in paragraphs:
        # Détecter langue du paragraphe
        try:
            lang = detect_langs(para)[0].lang
        except:
            lang = current_lang or 'en'

        para_tokens = len(encoder.encode(para))
        max_size = chunk_size_by_lang.get(lang, 512)

        # Nouveau chunk si changement langue ou dépassement taille
        if (current_lang and lang != current_lang) or (current_size + para_tokens > max_size):
            chunks.append({
                'text': '\n\n'.join(current_chunk),
                'language': current_lang
            })
            current_chunk = [para]
            current_size = para_tokens
            current_lang = lang
        else:
            current_chunk.append(para)
            current_size += para_tokens
            current_lang = lang

    if current_chunk:
        chunks.append({'text': '\n\n'.join(current_chunk), 'language': current_lang})

    return chunks

Modèle d'embedding : Privilégiez Cohere embed-multilingual-v3 (100+ langues) ou multilingual-e5-large pour corpus multilingue.

Enrichissement avec métadonnées

Métadonnées essentielles à conserver

Les métadonnées enrichissent les chunks et permettent filtering/ranking avancé. Métadonnées critiques à systématiquement attacher :

Métadonnée Exemple Usage
source_document "contrat_client_X.pdf" Traçabilité, citation des sources
document_type "legal", "technical", "marketing" Filtering par type de contenu
date "2024-03-15" Filtering temporel, fraicheur des infos
author "Marie Dupont" Attribution, filtering par expert
section "3.2 Garanties" Contexte hiérarchique
language "fr", "en" Filtering par langue
chunk_index 45 (sur 230 chunks) Recomposition, navigation
tags ["RGPD", "sécurité", "cloud"] Filtering thématique

Impact mesuré : L'ajout de métadonnées + filtering contextuel améliore de 20-40% la précision en éliminant les chunks non pertinents avant même la recherche vectorielle.

Contexte hiérarchique (chapitre, section)

Pour documents structurés (livres, rapports, docs techniques), préserver la hiérarchie est crucial pour comprendre le contexte.

Approche parent-child chunking : Stocker deux niveaux de granularité :

# Exemple structure parent-child
parent_chunk = {
    'id': 'doc1_chapter3',
    'text': "Chapitre 3 : Sécurité des données\n\n[Contenu complet du chapitre - 1024 tokens]",
    'metadata': {
        'level': 'chapter',
        'title': 'Sécurité des données',
        'chapter_num': 3
    }
}

child_chunks = [
    {
        'id': 'doc1_chapter3_section1',
        'text': "3.1 Chiffrement\n\nLe chiffrement des données...",
        'parent_id': 'doc1_chapter3',
        'metadata': {
            'level': 'section',
            'title': 'Chiffrement',
            'breadcrumb': 'Chapitre 3 > 3.1 Chiffrement'
        }
    },
    # ... autres sections
]

Bénéfices :

  • Recherche sur child chunks (granularité) mais retourne parent chunk au LLM (contexte)
  • L'utilisateur voit "Réponse trouvée dans : Chapitre 3 > Section 3.1" (traçabilité)
  • Permet hybrid retrieval : "cherche dans Chapitre 5 uniquement"

Implémentation LlamaIndex : Utilisez HierarchicalNodeParser pour automatiser cette stratégie.

Liens inter-chunks

Stocker les relations entre chunks permet navigation intelligente et expansion de contexte.

Types de liens :

  • prev_chunk_id / next_chunk_id : Navigation linéaire dans le document source
  • related_chunks : Chunks sémantiquement liés (calculés via similarité cosine)
  • referenced_by : Chunks qui référencent explicitement ce chunk (ex: "voir section 2.3")
# Exemple avec expansion de contexte
retrieved_chunk = vector_db.search(query, top_k=1)[0]

# Stratégie 1 : Expansion linéaire (contexte avant/après)
context_chunks = [
    vector_db.get_by_id(retrieved_chunk['metadata']['prev_chunk_id']),
    retrieved_chunk,
    vector_db.get_by_id(retrieved_chunk['metadata']['next_chunk_id'])
]

# Stratégie 2 : Expansion sémantique
related_ids = retrieved_chunk['metadata']['related_chunks']
context_chunks = [vector_db.get_by_id(id) for id in related_ids[:3]]

full_context = '\n\n---\n\n'.join([c['text'] for c in context_chunks])

Résultat : Réduction de 60% des cas "réponse incomplète" en fournissant automatiquement le contexte manquant.

Métadonnées pour le filtrage

Le hybrid search (vectoriel + filtering) est 2-3x plus rapide et précis que la recherche vectorielle seule.

Patterns de filtering classiques :

# Exemple avec Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

client = QdrantClient(url="http://localhost:6333")

# Cas 1 : Filtering par type de document
results = client.search(
    collection_name="docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(key="document_type", match=MatchValue(value="legal"))
        ]
    ),
    limit=10
)

# Cas 2 : Filtering temporel (documents récents)
results = client.search(
    collection_name="docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(
                key="date",
                range=Range(gte="2024-01-01")  # Depuis janvier 2024
            )
        ]
    ),
    limit=10
)

# Cas 3 : Filtering multi-critères
results = client.search(
    collection_name="docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(key="language", match=MatchValue(value="fr")),
            FieldCondition(key="tags", match=MatchValue(any=["RGPD", "sécurité"]))
        ],
        must_not=[
            FieldCondition(key="status", match=MatchValue(value="archived"))
        ]
    ),
    limit=10
)

Impact performance : Sur un corpus de 10M chunks, filtering avant recherche vectorielle réduit l'espace de recherche de 10M à 50K chunks, divisant le temps de réponse par 10 (500ms → 50ms).

Best practice : Toujours indexer les métadonnées fréquemment utilisées en filtering (date, type, langue) pour bénéficier de l'acceleration.

Mesurer la qualité du chunking

Métriques de retrieval (precision, recall, MRR)

Pour mesurer objectivement l'efficacité de votre stratégie de chunking, utilisez des métriques standard :

Métrique Définition Interprétation Target
Precision@k % de chunks pertinents parmi les k récupérés Mesure la qualité des résultats retournés >80%
Recall@k % de chunks pertinents trouvés sur total existant Mesure la couverture de la recherche >85%
MRR (Mean Reciprocal Rank) Moyenne de 1/rang_premier_resultat_pertinent Mesure si les meilleurs résultats sont en tête >0.7
NDCG@k Normalized Discounted Cumulative Gain Mesure la qualité du ranking (pondéré par position) >0.75
# Calcul de métriques avec dataset de test
import numpy as np

def calculate_retrieval_metrics(queries, ground_truth, retrieval_function, k=10):
    precisions, recalls, mrr_scores = [], [], []

    for query, relevant_ids in zip(queries, ground_truth):
        # Récupérer top-k chunks
        retrieved = retrieval_function(query, k=k)
        retrieved_ids = [chunk['id'] for chunk in retrieved]

        # Precision@k
        relevant_retrieved = set(retrieved_ids) & set(relevant_ids)
        precision = len(relevant_retrieved) / k
        precisions.append(precision)

        # Recall@k
        recall = len(relevant_retrieved) / len(relevant_ids) if relevant_ids else 0
        recalls.append(recall)

        # MRR
        for rank, chunk_id in enumerate(retrieved_ids, 1):
            if chunk_id in relevant_ids:
                mrr_scores.append(1 / rank)
                break
        else:
            mrr_scores.append(0)

    return {
        'precision@k': np.mean(precisions),
        'recall@k': np.mean(recalls),
        'mrr': np.mean(mrr_scores)
    }

# Exemple d'utilisation
metrics = calculate_retrieval_metrics(
    queries=test_queries,
    ground_truth=test_relevant_chunks,
    retrieval_function=my_rag_retrieval,
    k=10
)

print(f"Precision@10: {metrics['precision@k']:.2%}")
print(f"Recall@10: {metrics['recall@k']:.2%}")
print(f"MRR: {metrics['mrr']:.3f}")

Cohérence sémantique des chunks

Un bon chunk doit avoir une forte cohérence interne (toutes les phrases parlent du même sujet) et une faible similarité avec chunks voisins (pas de redondance excessive).

Métrique de cohérence interne :

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

model = SentenceTransformer('all-MiniLM-L6-v2')

def calculate_chunk_coherence(chunk_text):
    """Mesure la cohérence sémantique interne d'un chunk."""
    sentences = chunk_text.split('. ')
    if len(sentences) < 2:
        return 1.0

    # Embeddings des phrases
    embeddings = model.encode(sentences)

    # Similarité moyenne entre toutes paires de phrases
    similarities = []
    for i in range(len(embeddings)):
        for j in range(i+1, len(embeddings)):
            sim = cosine_similarity([embeddings[i]], [embeddings[j]])[0][0]
            similarities.append(sim)

    return np.mean(similarities)

# Analyse d'un corpus de chunks
coherences = [calculate_chunk_coherence(chunk) for chunk in chunks]
print(f"Cohérence moyenne : {np.mean(coherences):.3f}")
print(f"Chunks faible cohérence (<0.5) : {sum(c < 0.5 for c in coherences)}")

Interprétation :

  • Cohérence > 0.7 : Excellent, chunk sémantiquement unifié
  • Cohérence 0.5-0.7 : Acceptable, chunks hétérogènes mais utilisables
  • Cohérence < 0.5 : Problématique, chunk mélange trop de sujets différents

Action : Si 20%+ de vos chunks ont cohérence <0.5, réduisez chunk_size ou passez à semantic chunking.

Tests A/B sur différentes stratégies

La seule façon de trouver la stratégie optimale : tester systématiquement plusieurs configurations.

Méthodologie de grid search :

# Grid search pour hyperparams chunking
import itertools
from ragas import evaluate
from ragas.metrics import context_precision, context_recall, faithfulness

# Définir la grille de recherche
chunk_sizes = [256, 512, 768, 1024]
overlaps = [0.0, 0.1, 0.2, 0.3]
strategies = ['fixed', 'recursive', 'semantic']

results = []

for size, overlap, strategy in itertools.product(chunk_sizes, overlaps, strategies):
    print(f"Testing: size={size}, overlap={overlap}, strategy={strategy}")

    # Recréer chunks avec params
    chunks = create_chunks(
        documents=test_documents,
        chunk_size=size,
        chunk_overlap=int(size * overlap),
        strategy=strategy
    )

    # Ré-indexer base vectorielle
    vector_db.delete_all()
    vector_db.index(chunks)

    # Évaluer sur dataset de test
    rag_results = run_rag_evaluation(test_queries, vector_db)

    metrics = evaluate(
        dataset=rag_results,
        metrics=[context_precision, context_recall, faithfulness]
    )

    results.append({
        'chunk_size': size,
        'overlap': overlap,
        'strategy': strategy,
        **metrics
    })

# Trouver la meilleure config
best = max(results, key=lambda x: x['context_precision'])
print(f"\nBest config: {best}")

Durée estimée : Pour 4 sizes × 4 overlaps × 3 strategies = 48 configurations sur 1000 queries = 4-8h de compute (parallélisable).

Framework RAGAS : Utilisez RAGAS pour automatiser l'évaluation avec métriques : context_precision, context_recall, answer_relevancy, faithfulness.

Analyse qualitative des résultats

Les métriques quantitatives ne suffisent pas. L'analyse humaine reste nécessaire pour détecter des problèmes subtils.

Méthode d'audit qualitatif :

  1. Sampling : Sélectionner 50-100 queries représentatives (couvrant différents types de questions)
  2. Inspection manuelle : Pour chaque query, examiner :
    • Les chunks récupérés contiennent-ils l'information nécessaire ?
    • Y a-t-il de la redondance excessive ?
    • Le contexte fourni au LLM est-il cohérent ?
    • La réponse générée est-elle précise et complète ?
  3. Catgorisation des erreurs :
    • Miss : Information existante non récupérée (problème recall)
    • Noise : Chunks non pertinents récupérés (problème precision)
    • Fragmentation : Information coupée entre chunks (besoin overlap++)
    • Context loss : Chunk manque de contexte pour être compris (besoin chunk_size++)

Template d'Audit

Query: "Quel est le montant du contrat avec le client X ?"

Chunks récupérés:
[1] Score 0.89 - "...signé un contrat de maintenance..."
[2] Score 0.85 - "Le client X a validé la proposition..."
[3] Score 0.82 - "...pour un montant de 150K€ sur 3 ans..."

Analyse:
✓ Information présente (chunk 3)
✗ Chunk 3 manque contexte (pas de référence au client X)
✗ Nécessite fusion chunks 2+3 pour réponse complète

Action: Augmenter overlap 20% → 25%

Fréquence : Effectuer un audit qualitatif tous les 3-6 mois ou après ajout de 10K+ nouveaux documents.

Outils d'évaluation

Frameworks et outils pour automatiser l'évaluation de votre chunking :

Outil Description Usage
RAGAS Framework d'évaluation RAG avec métriques automatisées pip install ragas
Métriques : context_precision, context_recall, faithfulness, answer_relevancy
TruLens Observability et evaluation pour LLM apps Tracking en temps réel, détection de drift, A/B testing
LangSmith Plateforme LangChain pour debugging et eval Visualisation des traces, annotation humaine, datasets de test
Arize Phoenix ML observability avec support RAG Open-source, self-hosted, analyse embeddings
BEIR Benchmark standard retrieval (15+ datasets) Comparaison avec state-of-the-art, recherche académique
# Exemple évaluation avec RAGAS
from ragas import evaluate
from ragas.metrics import (
    context_precision,
    context_recall,
    faithfulness,
    answer_relevancy
)
from datasets import Dataset

# Préparer dataset d'évaluation
eval_data = {
    'question': ["Quel est le montant du contrat ?", ...],
    'contexts': [[chunk1, chunk2], ...],  # Chunks récupérés
    'answer': ["Le montant est 150K€", ...],  # Réponse générée
    'ground_truth': ["Le contrat est de 150 000€ sur 3 ans", ...]  # Référence
}

dataset = Dataset.from_dict(eval_data)

# Évaluer
results = evaluate(
    dataset=dataset,
    metrics=[context_precision, context_recall, faithfulness, answer_relevancy]
)

print(results)
# Output:
# {'context_precision': 0.82, 'context_recall': 0.89,
#  'faithfulness': 0.94, 'answer_relevancy': 0.87}

Recommandation : Commencez avec RAGAS (gratuit, facile) pour prototypage, puis passez à TruLens ou LangSmith pour production avec monitoring continu.

Implémentation pratique

Bibliothèques Python (LangChain, LlamaIndex)

Les deux frameworks majeurs pour implémenter le chunking en production :

LangChain TextSplitters

from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    CharacterTextSplitter,
    MarkdownHeaderTextSplitter,
    PythonCodeTextSplitter
)

# 1. Recursive (recommandé par défaut)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=100,
    length_function=len,
    separators=["\n\n", "\n", ". ", " ", ""]
)

# 2. Markdown avec préservation structure
markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "Header 1"),
        ("##", "Header 2"),
        ("###", "Header 3"),
    ]
)

# 3. Code Python spécialisé
code_splitter = PythonCodeTextSplitter(
    chunk_size=800,
    chunk_overlap=50
)

chunks = splitter.split_text(document_text)

LlamaIndex NodeParsers

from llama_index.node_parser import (
    SimpleNodeParser,
    SentenceSplitter,
    SemanticSplitterNodeParser,
    HierarchicalNodeParser
)
from llama_index.embeddings import OpenAIEmbedding

# 1. Sentence-based avec window
parser = SentenceSplitter(
    chunk_size=512,
    chunk_overlap=100
)

# 2. Semantic chunking
embed_model = OpenAIEmbedding()
semantic_parser = SemanticSplitterNodeParser(
    buffer_size=1,
    embed_model=embed_model,
    breakpoint_percentile_threshold=95  # Seuil de coupure
)

# 3. Hiérarchique (parent-child)
hierarchical_parser = HierarchicalNodeParser.from_defaults(
    chunk_sizes=[2048, 512, 128]  # 3 niveaux
)

nodes = parser.get_nodes_from_documents(documents)

Comparaison : LangChain plus simple et rapide, LlamaIndex plus puissant avec features avancées (semantic, hiérarchique).

Exemple : Chunking avec LangChain

Pipeline complet de chunking production-ready avec LangChain :

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Qdrant
import tiktoken

# 1. Charger documents
loader = PyPDFLoader("contract.pdf")
documents = loader.load()

# 2. Configurer splitter avec tokenizer
encoder = tiktoken.encoding_for_model("gpt-4")

def tiktoken_len(text):
    return len(encoder.encode(text))

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=100,
    length_function=tiktoken_len,  # Mesure en tokens GPT-4
    separators=["\n\n", "\n", ". ", ", ", " ", ""]
)

# 3. Chunker avec métadonnées
chunks = []
for doc in documents:
    splits = splitter.split_text(doc.page_content)

    for i, split in enumerate(splits):
        chunk = {
            'text': split,
            'metadata': {
                'source': doc.metadata['source'],
                'page': doc.metadata.get('page', 0),
                'chunk_index': i,
                'total_chunks': len(splits),
                'token_count': tiktoken_len(split)
            }
        }
        chunks.append(chunk)

print(f"Created {len(chunks)} chunks from {len(documents)} documents")

# 4. Indexer dans base vectorielle
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vector_store = Qdrant.from_texts(
    texts=[c['text'] for c in chunks],
    embedding=embeddings,
    metadatas=[c['metadata'] for c in chunks],
    url="http://localhost:6333",
    collection_name="contracts"
)

print("Indexing complete!")

Exemple : Chunking sémantique personnalisé

Implémentation from scratch d'un semantic chunker avancé :

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
import spacy

class SemanticChunker:
    def __init__(
        self,
        model_name='all-MiniLM-L6-v2',
        similarity_threshold=0.7,
        max_chunk_size=800,
        min_chunk_size=200
    ):
        self.model = SentenceTransformer(model_name)
        self.nlp = spacy.load('fr_core_news_sm')  # Pour segmentation phrases
        self.similarity_threshold = similarity_threshold
        self.max_chunk_size = max_chunk_size
        self.min_chunk_size = min_chunk_size

    def split(self, text):
        # 1. Segmenter en phrases
        doc = self.nlp(text)
        sentences = [sent.text for sent in doc.sents]

        if len(sentences) == 0:
            return []

        # 2. Calculer embeddings des phrases
        embeddings = self.model.encode(sentences)

        # 3. Grouper par cohérence sémantique
        chunks = []
        current_chunk = [sentences[0]]
        current_size = len(sentences[0])

        for i in range(1, len(sentences)):
            sentence = sentences[i]
            sentence_size = len(sentence)

            # Calculer similarité avec chunk courant
            chunk_embedding = np.mean([embeddings[j] for j in range(i-len(current_chunk), i)], axis=0)
            sentence_embedding = embeddings[i]
            similarity = cosine_similarity([chunk_embedding], [sentence_embedding])[0][0]

            # Décision : ajouter au chunk courant ou créer nouveau chunk
            if (
                similarity > self.similarity_threshold
                and current_size + sentence_size < self.max_chunk_size
            ):
                # Ajouter au chunk courant
                current_chunk.append(sentence)
                current_size += sentence_size
            else:
                # Créer nouveau chunk
                if current_size >= self.min_chunk_size:
                    chunks.append(' '.join(current_chunk))
                    current_chunk = [sentence]
                    current_size = sentence_size
                else:
                    # Chunk trop petit, forcer ajout
                    current_chunk.append(sentence)
                    current_size += sentence_size

        # Ajouter dernier chunk
        if current_chunk:
            chunks.append(' '.join(current_chunk))

        return chunks

# Utilisation
chunker = SemanticChunker(
    similarity_threshold=0.75,
    max_chunk_size=600,
    min_chunk_size=200
)

text = """[Votre long document...]"""
chunks = chunker.split(text)

for i, chunk in enumerate(chunks, 1):
    print(f"\n=== Chunk {i} ({len(chunk)} chars) ===")
    print(chunk[:200] + "...")

Pipeline complet de preprocessing

Pipeline production complet : extraction, nettoyage, chunking, validation, indexation :

import logging
from typing import List, Dict
from dataclasses import dataclass
from pathlib import Path

@dataclass
class Chunk:
    text: str
    metadata: Dict
    token_count: int
    coherence_score: float

class DocumentPreprocessingPipeline:
    def __init__(self, config):
        self.config = config
        self.logger = logging.getLogger(__name__)

    def process(self, file_path: Path) -> List[Chunk]:
        """Pipeline complet de preprocessing."""

        # 1. Extraction
        self.logger.info(f"Extracting {file_path}")
        raw_text = self.extract(file_path)

        # 2. Nettoyage
        self.logger.info("Cleaning text")
        cleaned_text = self.clean(raw_text)

        # 3. Détection langue
        language = self.detect_language(cleaned_text)

        # 4. Chunking
        self.logger.info(f"Chunking (lang={language})")
        chunks = self.chunk(cleaned_text, language)

        # 5. Post-processing
        self.logger.info("Post-processing chunks")
        chunks = self.postprocess(chunks, file_path, language)

        # 6. Validation
        self.logger.info("Validating chunks")
        valid_chunks = [c for c in chunks if self.validate_chunk(c)]

        self.logger.info(f"Pipeline complete: {len(valid_chunks)} valid chunks")
        return valid_chunks

    def extract(self, file_path: Path) -> str:
        """Extraire texte selon type de fichier."""
        suffix = file_path.suffix.lower()

        if suffix == '.pdf':
            from unstructured.partition.pdf import partition_pdf
            elements = partition_pdf(filename=str(file_path))
            return '\n\n'.join([e.text for e in elements])

        elif suffix == '.docx':
            from docx import Document
            doc = Document(file_path)
            return '\n\n'.join([p.text for p in doc.paragraphs])

        elif suffix in ['.txt', '.md']:
            return file_path.read_text(encoding='utf-8')

        else:
            raise ValueError(f"Unsupported file type: {suffix}")

    def clean(self, text: str) -> str:
        """Nettoyer le texte."""
        import re

        # Retirer caractères de contrôle
        text = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]', '', text)

        # Normaliser espaces
        text = re.sub(r' +', ' ', text)
        text = re.sub(r'\n{3,}', '\n\n', text)

        # Retirer URLs (optionnel)
        # text = re.sub(r'http\S+', '', text)

        return text.strip()

    def detect_language(self, text: str) -> str:
        """Détecter langue du document."""
        from langdetect import detect
        try:
            return detect(text[:1000])  # Échantillon
        except:
            return 'en'  # Défaut

    def chunk(self, text: str, language: str) -> List[str]:
        """Chunking adapté à la langue."""
        from langchain.text_splitter import RecursiveCharacterTextSplitter
        import tiktoken

        encoder = tiktoken.encoding_for_model("gpt-4")
        chunk_size = self.config['chunk_size'].get(language, 512)

        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=int(chunk_size * 0.2),
            length_function=lambda t: len(encoder.encode(t)),
            separators=["\n\n", "\n", ". ", " ", ""]
        )

        return splitter.split_text(text)

    def postprocess(self, chunks: List[str], file_path: Path, language: str) -> List[Chunk]:
        """Enrichir chunks avec métadonnées."""
        import tiktoken
        encoder = tiktoken.encoding_for_model("gpt-4")

        processed = []
        for i, chunk_text in enumerate(chunks):
            chunk = Chunk(
                text=chunk_text,
                metadata={
                    'source': str(file_path),
                    'filename': file_path.name,
                    'language': language,
                    'chunk_index': i,
                    'total_chunks': len(chunks),
                    'prev_chunk_id': f"{file_path.stem}_{i-1}" if i > 0 else None,
                    'next_chunk_id': f"{file_path.stem}_{i+1}" if i < len(chunks)-1 else None
                },
                token_count=len(encoder.encode(chunk_text)),
                coherence_score=self.calculate_coherence(chunk_text)
            )
            processed.append(chunk)

        return processed

    def calculate_coherence(self, text: str) -> float:
        """Calculer score de cohérence sémantique."""
        # Implémentation simplifiée
        sentences = text.split('. ')
        return min(1.0, len(sentences) / 10)  # Proxy simple

    def validate_chunk(self, chunk: Chunk) -> bool:
        """Valider qu'un chunk respecte les contraintes."""
        # Vérifications
        if chunk.token_count < 50:  # Trop petit
            self.logger.warning(f"Chunk trop petit : {chunk.token_count} tokens")
            return False

        if chunk.token_count > 1500:  # Trop grand
            self.logger.warning(f"Chunk trop grand : {chunk.token_count} tokens")
            return False

        if chunk.coherence_score < 0.3:  # Cohérence insuffisante
            self.logger.warning(f"Cohérence faible : {chunk.coherence_score}")
            return False

        return True

# Utilisation
config = {
    'chunk_size': {
        'en': 600,
        'fr': 512,
        'es': 520
    }
}

pipeline = DocumentPreprocessingPipeline(config)
chunks = pipeline.process(Path("document.pdf"))

print(f"Processed {len(chunks)} valid chunks")

Optimisation et monitoring en production

Stratégies pour maintenir la qualité du chunking en production :

1. Monitoring continu

import prometheus_client as prom
from dataclasses import dataclass
from datetime import datetime

# Métriques Prometheus
chunk_size_histogram = prom.Histogram(
    'chunking_size_tokens',
    'Distribution des tailles de chunks (tokens)',
    buckets=[100, 256, 512, 768, 1024, 1536, 2048]
)

chunk_coherence_gauge = prom.Gauge(
    'chunking_coherence_score',
    'Score de cohérence moyen des chunks'
)

processing_time_histogram = prom.Histogram(
    'chunking_processing_seconds',
    'Temps de traitement chunking'
)

@dataclass
class ChunkingMetrics:
    total_chunks: int
    avg_token_count: float
    avg_coherence: float
    processing_time: float
    error_rate: float

class MonitoredChunker:
    def __init__(self, base_chunker):
        self.chunker = base_chunker

    def chunk_with_monitoring(self, text: str) -> List[Chunk]:
        start_time = datetime.now()

        try:
            chunks = self.chunker.split(text)

            # Enregistrer métriques
            for chunk in chunks:
                chunk_size_histogram.observe(chunk.token_count)

            avg_coherence = sum(c.coherence_score for c in chunks) / len(chunks)
            chunk_coherence_gauge.set(avg_coherence)

            processing_time = (datetime.now() - start_time).total_seconds()
            processing_time_histogram.observe(processing_time)

            return chunks

        except Exception as e:
            logging.error(f"Chunking failed: {e}")
            # Incrémenter compteur erreurs
            raise

2. Cache intelligent

import hashlib
import redis
import pickle

class CachedChunker:
    def __init__(self, base_chunker, redis_client):
        self.chunker = base_chunker
        self.redis = redis_client
        self.cache_ttl = 86400  # 24h

    def chunk(self, text: str, cache_key: str = None) -> List[Chunk]:
        # Générer clé de cache
        if cache_key is None:
            text_hash = hashlib.sha256(text.encode()).hexdigest()
            config_hash = hashlib.sha256(
                str(self.chunker.config).encode()
            ).hexdigest()
            cache_key = f"chunks:{text_hash}:{config_hash}"

        # Vérifier cache
        cached = self.redis.get(cache_key)
        if cached:
            return pickle.loads(cached)

        # Chunker et mettre en cache
        chunks = self.chunker.split(text)
        self.redis.setex(
            cache_key,
            self.cache_ttl,
            pickle.dumps(chunks)
        )

        return chunks

3. Alerting sur dégradation

class QualityMonitor:
    def __init__(self, alert_threshold=0.15):
        self.baseline_metrics = None
        self.alert_threshold = alert_threshold

    def set_baseline(self, metrics: ChunkingMetrics):
        """Définir baseline de référence."""
        self.baseline_metrics = metrics

    def check_degradation(self, current_metrics: ChunkingMetrics):
        """Détecter dégradation significative."""
        if not self.baseline_metrics:
            return

        # Comparer cohérence
        coherence_drop = (
            self.baseline_metrics.avg_coherence - current_metrics.avg_coherence
        ) / self.baseline_metrics.avg_coherence

        if coherence_drop > self.alert_threshold:
            self.send_alert(
                f"Dégradation cohérence : {coherence_drop:.1%} "
                f"(baseline={self.baseline_metrics.avg_coherence:.2f}, "
                f"current={current_metrics.avg_coherence:.2f})"
            )

        # Comparer error rate
        if current_metrics.error_rate > 0.05:  # 5%
            self.send_alert(
                f"Taux d'erreur élevé : {current_metrics.error_rate:.1%}"
            )

    def send_alert(self, message: str):
        """Envoyer alerte (Slack, PagerDuty, etc.)."""
        logging.error(f"ALERT: {message}")
        # Implémenter intégration Slack/PagerDuty

Best practices production :

  • Monitorer métriques clés : latence, taille chunks, cohérence, error rate
  • Implémenter cache Redis pour documents fréquemment retraites
  • Alerting automatique sur dégradation >15% des métriques
  • Re-chunking incrémental plutôt que full reindex
  • A/B testing continu sur nouvelles stratégies (10% traffic)

Audit et optimisation de votre système RAG

Notre équipe peut analyser votre stratégie de chunking actuelle et proposer des optimisations concrètes pour améliorer la qualité de vos réponses et réduire les coûts.

Demander un audit RAG

Questions fréquentes

Quelle est la taille de chunk idéale ?

Il n'existe pas de taille universelle. 512-768 tokens est un bon départ pour 80% des cas. Testez ensuite avec un grid search [256, 512, 768, 1024] mesuré par RAGAS metrics sur votre dataset spécifique. Privilégiez des chunks plus petits (256-384) pour QA factuel court, et plus larges (1024-1536) pour analyse juridique/médicale nécessitant contexte étendu.

Faut-il toujours utiliser l'overlapping ?

Oui, dans 95% des cas. Un overlap de 20-25% améliore le recall de 15-35% avec un surcoût modéré (+25-33% stockage). Seules exceptions : code source avec découpage par fonctions (overlap 0-10%) ou contraintes budgétaires extrêmes. L'overlap prévient la perte d'information aux frontières de chunks, un problème critique pour la qualité RAG.

Comment gérer les documents très longs ?

Pour documents >50 pages (livres, rapports, thèses), utilisez le chunking hiérarchique : parent chunks (chapitres/sections de 1024-2048 tokens) + child chunks (paragraphes de 256-512 tokens). Indexez les child chunks pour recherche granulaire, mais retournez le parent chunk au LLM pour contexte complet. Alternative : utilisez LLMs à long contexte (Claude 200K, Gemini 1M) avec chunks de 4K-8K tokens.

Peut-on avoir des chunks de tailles variables ?

Oui, et c'est souvent préférable ! Le structure-based chunking (par section/paragraphe) et le semantic chunking produisent naturellement des tailles variables qui respectent la logique du contenu. Inconvenient : complexité de gestion (certains chunks 200 tokens, d'autres 1500). Solution : définir min_chunk_size=200 et max_chunk_size=1200, puis subdiviser/fusionner les outliers.

Comment rechucker sans tout réindexer ?

Stratégie de re-chunking incrémental : (1) Maintenir mapping document_id → chunk_ids, (2) Pour chaque document modifié, supprimer uniquement ses anciens chunks via vector_db.delete(filter={'document_id': X}), (3) Re-chunker et ré-indexer uniquement ce document, (4) Mettre à jour mapping. Pour changement global de stratégie : créer collection parallèle, tester en shadow mode (10% traffic), puis basculer si métriques améliorées.

Article précédent Embeddings vs Tokens Tous les articles Article suivant Stratégies de découpage de texte