Skip to content

Stratégies de chunking — du naïf au contextual retrieval Anthropic

TL;DR Le chunking est le levier #1 de qualité d'un RAG, et celui qu'on bâcle systématiquement. Un mauvais split → embeddings noyés, recall en chute, hallucinations garanties. En 2026, le standard freelance c'est document-structure aware + small-to-big + Anthropic contextual retrieval mesurés via Ragas. Sur un cabinet d'avocats à Lyon, passer de fixed-size 512 → chunking par article de contrat = +34 pts de context precision et 40 % de tickets support en moins sur ton agent juridique. Maîtriser ce fichier vaut 200-400 €/jour de surcharge crédible chez un client sérieux.

🧠 Mental model

Un chunk = une unité atomique de retrieval. Trois forces s'opposent :

        cohérence sémantique          recall (chunks petits, précis)
                  ▲                                ▲
                  │                                │
                  │     CHUNKING                   │
                  └───────┬────────────────────────┘


              contexte (chunks gros, "in-context")

Analogie : tu prêtes un livre à un ami qui ne lit que la page que tu lui tends. Tu veux qu'il comprenne sans lire le reste.

  • Trop petit (une phrase) → il manque le contexte.
  • Trop gros (un chapitre) → il se perd, son cerveau (le LLM) zappe.
  • Pile la bonne taille avec un sous-titre = Anthropic contextual retrieval : chaque page commence par "Dans le chapitre X, on parlait de Y, et maintenant...".

Une autre image pour ton cerveau de dev PHP/TS : le chunk c'est le résultat d'un SELECT ... LIMIT dans Postgres. La query de l'utilisateur est un WHERE. Si tes lignes sont mal découpées (colonnes mélangées), aucun WHERE ne te ramènera la bonne ligne.

Comment un staff engineer choisit une stratégie (l'arbre de décision)

La faute du junior, c'est de partir d'une stratégie (« on fait du semantic chunking, c'est à la mode ») et de la plaquer partout. Le staff part de trois questions sur la donnée, dans cet ordre :

  1. Le doc a-t-il une structure exploitable ? (headers Markdown, articles de loi, sections de CV, tours de parole, schéma JSON). Si oui → document-structure aware. C'est presque toujours le meilleur ROI : tu exploites une frontière sémantique que l'auteur a déjà tracée pour toi, gratuitement, de façon déterministe et testable. Ne génère jamais avec un LLM une frontière que ton document te donne déjà.
  2. Le retrieval a-t-il besoin de précision (petits chunks) ET le LLM de contexte (gros chunks) ? Si oui → small-to-big par-dessus (1). Ce n'est pas une stratégie de découpe, c'est une stratégie de stockage + lookup : tu embeds petit, tu renvoies gros.
  3. Les chunks perdent-ils leur référent une fois isolés ? (« la clause de non-concurrence » sans savoir de quel contrat ; « il a décidé » sans savoir qui) → ajoute Anthropic contextual retrieval ou late chunking pour réinjecter le contexte global dans l'embedding.

Les trois se composent — ce ne sont pas des alternatives. Un pipeline legal sérieux fait souvent les trois : split par article (1) + parent doc (2) + mini-contexte Haiku (3). La règle anti-pattern : chaque couche que tu ajoutes doit gagner des points mesurés sur Ragas, sinon tu la retires. Une couche LLM (contextual retrieval) qui coûte de l'argent et ne bouge pas la context_precision est une dette, pas une feature.

Donnée structurée ?  ──non──▶ prose libre/transcript ? ──▶ semantic / speaker-turn
       │ oui

structure-aware (par section/article/champ)


besoin précision + contexte ?  ──oui──▶ + small-to-big


chunk perd son référent isolé ?  ──oui──▶ + contextual retrieval / late chunking


        Ragas avant/après chaque couche — sinon retire la couche

🛠️ Code minimal

Le baseline naïf que tout le monde fait au début, et qu'on dépasse en 5 minutes :

python
# bad_baseline.py — ne livre jamais ça en prod
def fixed_chunks(text: str, size: int = 512, overlap: int = 50) -> list[str]:
    chunks = []
    for i in range(0, len(text), size - overlap):
        chunks.append(text[i:i + size])
    return chunks

Pourquoi c'est mauvais : tu coupes au milieu d'un mot, d'une phrase, d'un article de loi. L'embedding ne sait plus de quoi tu parles.

La version "correcte par défaut" en 2026 avec LangChain :

python
# recursive_char.py — bon défaut tant que tu n'as pas mesuré mieux
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n\n", "\n", ". ", "? ", "! ", "; ", ", ", " ", ""],
    length_function=len,
)
chunks = splitter.split_text(document)

Le splitter essaie d'abord de couper sur \n\n, puis \n, etc. Tu gardes la cohérence sémantique sans effort.

🎬 Cas d'usage concrets

Scénario 1 — Cabinet d'avocats parisien, contrats commerciaux

Qui : cabinet 14 avocats, spécialité M&A, 12 000 contrats archivés (NDA, SPA, term sheets, baux commerciaux).

Problème : leur premier RAG (un junior avait branché un Pinecone + chunks 1000 tokens) ne trouvait jamais la bonne clause. Question "clause de non-concurrence dans le SPA Acme 2024" → renvoyait la clause de confidentialité d'un autre contrat. Faux pas catastrophique en M&A.

Solution : chunking par article. Chaque contrat est parsé en Article N — Titre → chaque article devient un chunk avec metadata {contract_id, article_num, article_title, contract_type}. Plus un parent doc (le contrat entier) pour le re-stitching.

python
import re
from dataclasses import dataclass

ARTICLE_RE = re.compile(r"^Article\s+(\d+)\s*[-—:.]?\s*(.*)$", re.MULTILINE)

@dataclass
class ContractChunk:
    contract_id: str
    article_num: int
    article_title: str
    body: str

def split_contract(contract_id: str, text: str) -> list[ContractChunk]:
    matches = list(ARTICLE_RE.finditer(text))
    chunks = []
    for i, m in enumerate(matches):
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        chunks.append(ContractChunk(
            contract_id=contract_id,
            article_num=int(m.group(1)),
            article_title=m.group(2).strip(),
            body=text[start:end].strip(),
        ))
    return chunks

Gains : context precision passe de 0.54 → 0.91 (mesuré sur 80 paires Q/R hand-crafted). Temps moyen de recherche d'une clause par un avocat : 8 min → 45 s. ROI annoncé au client : 23 k€/mois récupérés (12 avocats × 30 min/jour × 230 €/h chargé). TJM facturé pour la mission : 1 200 €/j × 18 jours.

Scénario 2 — E-commerce lyonnais, 220 K fiches produits

Qui : pure-player textile B2C, catalogue 220 K SKU, fiches produit générées par contrib + IA.

Problème : leur chatbot ne répondait pas bien à "robe rouge en lin pour mariage été". Les fiches contenaient l'info, mais en pavé non structuré, et le chunking fixed-size noyait la matière (composition, occasion, couleur).

Solution : chunking par schéma JSON. Chaque fiche produit a un schéma structuré (extrait par LLM en pré-prod) : {title, brand, color, fabric, occasions, season, sizes}. Chaque champ pertinent devient un chunk avec template :

python
def product_to_chunks(product: dict) -> list[dict]:
    sku = product["sku"]
    chunks = []
    if product.get("description"):
        chunks.append({
            "sku": sku,
            "field": "description",
            "text": f"{product['title']} ({product['brand']}). {product['description']}",
        })
    if product.get("fabric"):
        chunks.append({
            "sku": sku,
            "field": "fabric",
            "text": f"Composition de {product['title']} : {product['fabric']}.",
        })
    if product.get("occasions"):
        chunks.append({
            "sku": sku,
            "field": "occasions",
            "text": f"{product['title']} convient pour : {', '.join(product['occasions'])}.",
        })
    return chunks

Gains : recall@10 passe de 0.41 → 0.78. Conversion sur recherches conversationnelles +12 %. Le client (CA 18 M€) a chiffré le delta à 65 k€/mois de marge brute supplémentaire. Mission packagée à 11 k€ HT pour le freelance (9 jours × 1 250 €/j).

Scénario 3 bis — Comptes-rendus de réunion (industrie automobile)

Qui : équipementier auto Tier-1 (chiffres confidentiels), 8 000 réunions enregistrées/an dont CR Word ou Otter.ai.

Problème : un ingé qualité cherche "qui a tranché sur le rappel du joint EPDM série 7 en réunion juin 2024" — la décision est noyée dans un CR de 18 pages. Vector RAG noie le contexte et attribue la phrase au mauvais speaker.

Solution : speaker-turn chunking. Chaque tour de parole devient un chunk, avec metadata {meeting_id, ts, speaker, role}. On peut filtrer "uniquement les décisions de Pierre Martin (Directeur Qualité)".

python
def split_meeting(turns: list[dict]) -> list[dict]:
    return [
        {
            "meeting_id": m["id"], "ts": t["start"],
            "speaker": t["speaker"], "role": t["role"],
            "text": t["text"], "is_decision": "décide" in t["text"].lower() or "valide" in t["text"].lower(),
        }
        for t in turns if len(t["text"]) > 30
    ]

Gains : temps de recherche d'une décision passée 25 min → 2 min. Audit qualité ISO 9001 facilité. Mission : 10 jours × 1 300 €/j = 13 k€.

Scénario 3 — Startup HR-tech, CV multi-pages

Qui : ATS pour ESN, 1,2 M CV en base, recherche sémantique pour matcher candidats/missions.

Problème : un CV de 4 pages chunké fixed-size devient incohérent. La query "5 ans React + AWS + parle allemand" matche un CV qui a "5 ans React" sur page 1, "AWS" en formation page 3, "allemand" dans hobbies page 4 — mais aucun chunk ne contient les trois. Les embeddings sont noyés.

Solution : chunking par section CV (Expériences, Formation, Compétences, Langues, Hobbies) + un chunk "résumé global" généré par LLM en preprocessing. Small-to-big : retrieval sur les chunks-section, mais on injecte aussi le résumé global pour le re-stitching.

python
SECTION_HEADERS = {
    "experiences": ["expérience", "experience", "parcours pro"],
    "formation": ["formation", "education", "diplômes"],
    "skills": ["compétences", "skills", "technologies"],
    "languages": ["langues", "languages"],
}

def detect_section(line: str) -> str | None:
    low = line.lower().strip()
    for section, kws in SECTION_HEADERS.items():
        if any(kw in low for kw in kws) and len(low) < 40:
            return section
    return None

Gains : matching candidat-mission, score F1 passe de 0.62 → 0.81. Réduction des "fausses bonnes" propositions au client final : -45 %. Le commercial passe 1h30/jour de moins à filtrer. Mission : 14 jours × 1 400 €/j.

🛠️ Exemple end-to-end

Pipeline complet : 1 000 contrats PDF → contextual retrieval Anthropic → pgvector → eval Ragas.

Le cas d'usage : tu signes une mission chez un cabinet d'avocats à Bordeaux qui a 1 000 contrats commerciaux et veut un assistant interne pour ses juniors. Tu factures 18 k€ (15 jours × 1 200 €/j).

python
# ingest_contracts.py
import os
import asyncio
import hashlib
import logging
from pathlib import Path
from dataclasses import dataclass

import anthropic
import psycopg
from psycopg.rows import dict_row
from pypdf import PdfReader
from openai import AsyncOpenAI

PG = "postgresql://rag:rag@localhost:5432/contracts"
CLAUDE = anthropic.AsyncAnthropic(max_retries=4)  # SDK retries 429/5xx with backoff
EMB = AsyncOpenAI()
EMB_MODEL = "text-embedding-3-small"
# Contextualisation est une tâche simple, faite 1M+ fois → on prend le modèle le moins
# cher. Haiku 4.5 (1 $/5 $ par M tok) suffit largement. On NE met PAS de suffixe de date :
# l'alias `claude-haiku-4-5` est complet tel quel.
CTX_MODEL = "claude-haiku-4-5"

# Anthropic contextual retrieval prompt
CTX_PROMPT = """<document>
{document}
</document>
Here is the chunk we want to situate within the whole document:
<chunk>
{chunk}
</chunk>
Please give a short succinct context (50-100 tokens) to situate this chunk
within the overall document for improving search retrieval. Answer only with
the context, no preamble."""

@dataclass
class Chunk:
    contract_id: str
    article_num: int
    body: str
    context: str = ""
    embedding: list[float] | None = None

async def read_pdf(path: Path) -> str:
    reader = PdfReader(path)
    return "\n".join(p.extract_text() or "" for p in reader.pages)

def split_by_article(text: str) -> list[tuple[int, str]]:
    import re
    parts = re.split(r"\n(?=Article\s+\d+)", text)
    out = []
    for p in parts:
        m = re.match(r"Article\s+(\d+)", p)
        if m:
            out.append((int(m.group(1)), p.strip()))
    return out

async def contextualize(document: str, chunk: str) -> str:
    # Prompt caching = LE levier coût d'Anthropic contextual retrieval.
    # Le doc parent est identique pour TOUS les chunks d'un même contrat → on le met
    # dans le préfixe stable avec cache_control: ephemeral. Le 1er chunk paie le
    # cache-write (~1.25x), les suivants lisent à ~0.1x. Sur un contrat de 40 articles
    # ça divise le coût de contextualisation par ~8-10.
    # ⚠️ Le contenu VOLATILE (le chunk courant) DOIT venir APRÈS le breakpoint, sinon
    # le préfixe change à chaque appel et le cache ne sert jamais.
    resp = await CLAUDE.messages.create(
        model=CTX_MODEL,
        max_tokens=200,
        system=[{
            "type": "text",
            "text": "You enrich legal chunks with situational context. "
                    "Answer only with the context, 50-100 tokens, no preamble.",
            "cache_control": {"type": "ephemeral"},
        }],
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": f"<document>\n{document}\n</document>",
                    "cache_control": {"type": "ephemeral"},  # cache le gros doc
                },
                # volatile → après le dernier breakpoint
                {"type": "text", "text": f"<chunk>\n{chunk}\n</chunk>\nContext:"},
            ],
        }],
    )
    # Observabilité coût : sur le 1er chunk d'un doc, cache_creation > 0 ;
    # sur les suivants, cache_read doit être > 0. Si cache_read reste à 0 sur
    # tout un contrat → un invalidant silencieux casse le préfixe (doc non
    # déterministe, espaces, ordre des blocs). Logge-le, ne le devine pas.
    u = resp.usage
    logging.debug(
        "ctx tokens in=%d cache_w=%d cache_r=%d out=%d",
        u.input_tokens, u.cache_creation_input_tokens,
        u.cache_read_input_tokens, u.output_tokens,
    )
    return resp.content[0].text.strip()

async def embed_batch(texts: list[str]) -> list[list[float]]:
    resp = await EMB.embeddings.create(model=EMB_MODEL, input=texts)
    return [d.embedding for d in resp.data]

async def process_contract(path: Path, sem: asyncio.Semaphore) -> list[Chunk]:
    async with sem:
        text = await read_pdf(path)
        cid = hashlib.sha1(str(path).encode()).hexdigest()[:12]
        parts = split_by_article(text)
        chunks = [Chunk(contract_id=cid, article_num=n, body=b) for n, b in parts]
        # Contextualize each chunk against the full document (prompt cache wins)
        for c in chunks:
            c.context = await contextualize(text, c.body)
        # Embed [context + body] together
        embs = await embed_batch([f"{c.context}\n\n{c.body}" for c in chunks])
        for c, e in zip(chunks, embs):
            c.embedding = e
        return chunks

async def store(chunks: list[Chunk]) -> None:
    async with await psycopg.AsyncConnection.connect(PG) as conn:
        async with conn.cursor() as cur:
            await cur.executemany(
                """INSERT INTO contract_chunks
                   (contract_id, article_num, body, context, embedding)
                   VALUES (%s, %s, %s, %s, %s)""",
                [(c.contract_id, c.article_num, c.body, c.context, c.embedding)
                 for c in chunks],
            )

async def main(pdf_dir: str = "./contracts"):
    sem = asyncio.Semaphore(8)
    paths = list(Path(pdf_dir).glob("*.pdf"))
    print(f"Processing {len(paths)} contracts")
    tasks = [process_contract(p, sem) for p in paths]
    for fut in asyncio.as_completed(tasks):
        chunks = await fut
        await store(chunks)
        print(f"Stored {len(chunks)} chunks for {chunks[0].contract_id}")

if __name__ == "__main__":
    asyncio.run(main())

Schéma SQL associé :

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE contract_chunks (
    id            BIGSERIAL PRIMARY KEY,
    contract_id   TEXT NOT NULL,
    article_num   INT  NOT NULL,
    body          TEXT NOT NULL,
    context       TEXT NOT NULL,
    embedding     VECTOR(1536) NOT NULL,
    created_at    TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON contract_chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON contract_chunks (contract_id, article_num);

Eval Ragas — ce qui te vend la mission au prochain rendez-vous :

python
# eval_ragas.py
import asyncio
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import (
    faithfulness, context_precision, context_recall, answer_relevancy,
)

# 50 paires Q/R hand-crafted par un avocat senior du cabinet
GROUND_TRUTH = [
    {
        "question": "Quelle est la durée de la clause de non-concurrence dans le SPA Acme 2024 ?",
        "ground_truth": "24 mois après la cession effective des titres.",
        "contract_id": "acme_spa_2024",
    },
    # ... 49 autres
]

async def run_eval(retrieve_fn, generate_fn):
    rows = []
    for ex in GROUND_TRUTH:
        contexts = await retrieve_fn(ex["question"])
        answer = await generate_fn(ex["question"], contexts)
        rows.append({
            "question": ex["question"],
            "answer": answer,
            "contexts": [c["body"] for c in contexts],
            "ground_truth": ex["ground_truth"],
        })
    ds = Dataset.from_list(rows)
    return evaluate(ds, metrics=[
        faithfulness, context_precision, context_recall, answer_relevancy,
    ])

if __name__ == "__main__":
    from rag_pipeline import retrieve, generate
    print(asyncio.run(run_eval(retrieve, generate)))

Résilience au volume (ce qui sépare un POC d'une prod). Sur 1 000 contrats × 40 articles = 40 000 appels Haiku, tu vas prendre des 429 et des 529. Le SDK retry déjà max_retries=4 avec backoff exponentiel, mais un staff engineer attrape explicitement les exceptions typées pour décider quoi parker vs quoi relancer :

python
import anthropic

async def contextualize_safe(document: str, chunk: str) -> str:
    try:
        return await contextualize(document, chunk)
    except anthropic.RateLimitError:
        # Le SDK a déjà retenté 4× : on a saturé le TPM. On parke, on reprendra.
        raise  # remonte → le caller met le chunk dans une dead-letter queue
    except anthropic.APIStatusError as e:
        if e.type == "overloaded_error":      # 529 : Anthropic surchargé
            raise
        logging.error("ctx skip chunk: %s", e)
        return ""  # dégradation gracieuse : chunk sans contexte plutôt que crash de l'ingest
    except anthropic.APITimeoutError:
        return ""

Pour un ingest de 1 M+ chunks, passe carrément à la Message Batches API (50 % moins cher, tolère plusieurs heures) plutôt qu'à l'AsyncAnthropic en ligne : tu soumets un batch, tu polles, tu encaisses les deux économies (batch + cache).

Résultats que tu mets dans le README du livrable (chiffres typiques que j'ai vus sur ce genre de mission) :

Stratégie chunkingfaithfulnesscontext_precisioncontext_recallanswer_relevancy
Fixed-size 5120.710.540.610.68
Recursive char 800/1000.780.660.720.74
Par article0.860.830.810.82
Par article + contextual (Anthr.)0.920.910.880.89

Ces chiffres tu les ressors en RDV commercial. Le client signe.

🎯 Patterns courants

Pattern 1 — Small-to-big

Tu embed des chunks petits (~200-400 tokens) pour la précision du retrieval, mais tu renvoies au LLM le parent (paragraphe entier ou doc) pour le contexte.

python
# Retrieval renvoie chunk_id, on lookup le parent doc en SQL
async def retrieve_with_parent(query: str, k: int = 5):
    chunks = await vector_search(query, k=k)
    parent_ids = {c["parent_id"] for c in chunks}
    parents = await fetch_parents(parent_ids)
    return parents

Quand : docs structurés (contrats, articles, rapports). Évite : data unstructured genre logs.

Pattern 2 — Late chunking (Jina)

Tu embed le doc entier avec un modèle long-context (Jina v3), puis tu pool les token embeddings par chunk. Tu obtiens des embeddings de chunks qui connaissent le contexte global. Pas besoin de LLM de contextualisation → moins cher qu'Anthropic contextual retrieval.

Quand : tu as un long-context embedder dispo (Jina v3, Voyage 3 large). Évite : pas de support multilingue FR de qualité chez tous les providers.

Pattern 3 — Anthropic contextual retrieval

Tu génères un mini-contexte (50-100 tokens) par chunk via claude-haiku-4-5, en utilisant prompt caching sur le doc parent (économies ~90 %). Tu prépends le contexte au chunk avant embedding.

python
chunk_for_embed = f"{ctx_summary}\n\n{chunk}"

Quand : tu peux te payer un pre-processing LLM. Marche partout. Évite : volumes énormes (> 1 M chunks) où le coût explose.

Comment un staff engineer raisonne sur le choix de modèle. La contextualisation est une tâche simple, à très haut volume, hors-ligne (batch). Trois leviers :

  • Modèle : prends le moins cher qui tienne la qualité. claude-haiku-4-5 (1 $/5 $ par M tok) suffit — pas besoin de claude-sonnet-4-6 (3 $/15 $) ni d'claude-opus-4-8 (5 $/25 $). L'alias claude-haiku-4-5 est complet : n'ajoute jamais de suffixe de date (-20251022 → 404).
  • Pas de thinking. Haiku ne prend pas de budget de réflexion ; et même sur Opus/Sonnet la contextualisation n'a aucun besoin de raisonnement étendu. Pour mémoire : sur la famille 4.7/4.8, l'ancienne syntaxe thinking={"type": "enabled", "budget_tokens": N} est supprimée et renvoie un HTTP 400 — on utilise thinking={"type": "adaptive"} + output_config={"effort": "low"}. Sur Haiku, aucun des deux n'est requis.
  • Throughput : pour 1 M+ chunks, la Message Batches API divise par 2 le prix (50 %) et tolère une latence de quelques heures, parfaite pour un ingest offline. Tu combines batch + prompt caching pour empiler les deux économies. Pour un ingest en ligne, garde AsyncAnthropic + asyncio.Semaphore (voir l'exemple end-to-end) avec max_retries et un timeout par appel.

Pattern 4 — Document-structure aware

Tu parses la structure (Markdown headers, HTML DOM, XML, PDF outline) et tu chunks par section. Indispensable sur tech docs, articles de blog, manuels.

python
from langchain.text_splitter import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
)
docs = splitter.split_text(md_text)

Pattern 5 — Semantic chunking (embedding similarity boundary)

Tu calcules l'embedding de chaque phrase, tu coupes quand la similarité tombe sous un seuil. Sépare les changements de sujet.

python
def semantic_split(sentences: list[str], embs: list[list[float]], threshold: float = 0.78):
    chunks, current = [], [sentences[0]]
    for i in range(1, len(sentences)):
        sim = cosine(embs[i - 1], embs[i])
        if sim < threshold:
            chunks.append(" ".join(current))
            current = [sentences[i]]
        else:
            current.append(sentences[i])
    chunks.append(" ".join(current))
    return chunks

Coûteux (embedder par phrase). Quand : prose libre, articles, transcripts.

Pattern 6 — Speaker-turn (transcripts)

Pour comptes-rendus de réunion, podcasts, support calls : tu chunks par tour de parole + metadata {speaker, timestamp, role}. Indispensable pour ne pas attribuer une phrase au mauvais interlocuteur.

python
def split_transcript(turns: list[dict]) -> list[dict]:
    return [{
        "speaker": t["speaker"],
        "role": t.get("role", "unknown"),
        "ts": t["start"],
        "text": t["text"],
    } for t in turns if len(t["text"]) > 20]

🔄 Versions & écosystème 2026

OutilVersion 2026Notes
LangChain text_splitter0.3.xRecursiveCharacterTextSplitter toujours solide
LlamaIndex SemanticSplitterNodeParser0.12.xBon défaut pour semantic
Unstructured.io0.16.xBest pour PDF complexes, tables, OCR
Anthropic prompt caching5 min / 1 hIndispensable pour contextual retrieval
Jina embeddings v38K contextLate chunking natif
Voyage-3-large32K contextMultilingue FR très fort
Cohere Embed v4MultimodalLate chunking expérimental
Ragas0.2.xLLM-as-judge → utilise Sonnet ou Haiku pour eval
Chonkie0.4.xLib légère pour pipelines de chunking testables

Tendance 2026 : chunking devient un préprocessing LLM (Anthropic, Cohere, Jina poussent tous ça). Le "split à la regex" disparait pour les budgets sérieux.

⚠️ Pitfalls

  1. Overlap de 50 % en fixed-size → tu doubles ta base, recall ne bouge pas. Reste 10-20 % d'overlap max.
  2. Chunk trop gros (> 1500 tokens) → l'embedding "moyenne" trop de signaux. Le LLM ne sait plus où regarder. Garde 200-800.
  3. Embedder sans tokenizer matching → tu mesures chunk_size en caractères au lieu de tokens, et tu dépasses la fenêtre du modèle. Toujours len(tokenizer.encode(text)).
  4. Pas de metadata → tu retrouves un chunk pertinent mais tu ne sais pas de quel doc il vient. Toujours {source, page, section, ts, author}.
  5. Chunking commun pour types de doc hétérogènes → contrats + emails + tickets ne se chunkent pas pareil. Pipeline par type.
  6. Re-embed à chaque déploiement → tu cramework des $$ chez OpenAI. Hash du chunk + cache embeddings (Redis ou table dédiée).
  7. Pas d'eval avant/après changement → tu changes le chunking, tu "trouves que c'est mieux", c'est faux 1 fois sur 2. Ragas obligatoire.
  8. Contextual retrieval sans prompt caching → ton run coute 10× plus cher que nécessaire. Cache cache_control: ephemeral sur le doc parent.
  9. Tables PDF chunkées comme du texte → la table devient illisible. Utilise Unstructured.io pour extraire en HTML/Markdown.
  10. Headers Markdown ignorés → tu coupes un H1 du contenu qui le suit. MarkdownHeaderTextSplitter, pas RecursiveCharacterTextSplitter.
  11. Chunking unique pour FR/EN/AR mélangés → ton tokenizer est probablement EN-centric et explose la taille effective sur du FR. Mesure en tokens du modèle cible.
  12. Pas d'unicité de chunk_id → tu re-ingest le même contrat, tu doublonnes en base. Hash stable du contenu en clé business.

💰 Pricing / ROI client

Coût d'un pipeline contextual retrieval sur 1 M chunks (chiffres avril 2026) :

ÉtapeVolumeModèleCoût total
Contextualisation (Haiku + cache)1 M chunksclaude-haiku-4-5~110 €
Embedding (chunk + ctx)1 M × 600 tktext-embedding-3-sm~12 €
Stockage pgvector1 M vecteursHetzner CX3118 €/mois
Total one-shot ingest~140 €

Comparé à un chunking naïf qui te fait perdre 30 % de qualité : ton client passe de 0.65 à 0.91 en context precision. Sur un volume de 5 000 requêtes/mois et un taux d'escalade humain à 30 €/ticket évité : 4 500 €/mois économisés. ROI < 1 semaine.

Comment le packager en mission :

  • Audit chunking (3 jours, 3 600 €) : tu mesures leur recall actuel, tu proposes 2 stratégies, tu chiffres.
  • Implémentation (10-15 jours, 12-18 k€) : pipeline + Ragas + dashboard.
  • Retainer mensuel (2 jours/mois, 2 400 €) : monitoring, re-eval, A/B test de nouvelles stratégies.

🔭 Production : observabilité, sécurité, scale

Ce qui sépare ton livrable d'un POC de stagiaire, c'est tout ce qui n'est pas dans le notebook.

Observabilité — ce que tu logges en prod (et qui te sauve à 3h du matin)

Le chunking est un préprocessing offline : ses bugs ne se voient pas sur un dashboard de latence, ils se voient en qualité de réponse, des semaines plus tard. Tu instrumentes donc deux plans :

  • Plan ingest (offline). Par run d'ingestion, logge la distribution des tailles de chunk en tokens (p50/p95/max), le taux de chunks orphelins (sans metadata source/section), et les métriques de cache Anthropic (cache_creation vs cache_read — voir l'exemple end-to-end). Un p95 qui explose = un type de doc mal parsé. Un cache_read à zéro = un invalidant silencieux qui te coûte 10× le prix. Tu veux un histogramme, pas une moyenne : la moyenne cache un long tail de chunks de 4 000 tokens qui débordent ta fenêtre d'embedding.
  • Plan retrieval (online). Par requête, logge les chunk_id retournés, leur score de similarité, et un flag answered/escalated. Au bout de 2 semaines tu as un jeu de requêtes réelles : les requêtes qui escaladent pointent vers des zones de ta base mal chunkées. C'est ta vraie liste de priorités, pas tes 50 paires Q/R hand-crafted.

Le KPI produit que tu présentes au client n'est pas context_precision, c'est le taux d'escalade humaine. La métrique Ragas est ton instrument ; le taux d'escalade est le résultat business.

Sécurité & PII — le piège qu'on oublie sur de la donnée d'entreprise

Tes chunks partent chez deux providers tiers : Anthropic (contextualisation) et ton embedder (OpenAI/Voyage). Sur des contrats M&A, des CV, des CR de réunion, c'est de la donnée personnelle et confidentielle qui sort de l'UE. Trois réflexes de staff :

  1. Data residency. Vérifie où embed ton provider. Pour du RGPD-sensible : Voyage/Mistral hébergés UE, ou un embedder self-hosted (bge-m3) si le client l'exige. Annonce-le dans l'audit, pas après signature.
  2. Cloisonnement multi-tenant. Si ta base sert plusieurs clients/utilisateurs, le tenant_id doit être une colonne filtrée dans le WHERE du vector search, jamais un simple champ de metadata qu'on « oublie » de filtrer. Un chunk du cabinet A retrouvé par une requête du cabinet B, c'est une fuite, pas un bug de pertinence.
  3. PII dans les logs. Tu logges des chunk_id, pas des chunk.body. Le body d'un chunk = potentiellement le salaire d'un candidat ou une clause confidentielle. Logge l'identifiant, ré-hydrate à la demande, applique une rétention.

Scale — les seuils où chaque pattern casse

VolumeCe qui casseLa parade
< 100 K chunksrien ; HNSW pgvector en mémoire, AsyncAnthropic en ligne suffitreste simple
100 K – 1 Ml'ingest en ligne prend des heures et mange du TPMMessage Batches API (−50 %) + prompt caching empilés
1 M – 10 Ml'index HNSW ne tient plus en RAM ; re-embed = facture OpenAIquantization (halfvec), index partitionné, cache d'embeddings par hash
> 10 Mcontextual retrieval LLM devient trop cherbascule sur late chunking (pas de LLM par chunk)
re-ingest quotidientu re-embed des chunks inchangéshash stable du contenu → ON CONFLICT DO NOTHING, skip si hash connu

🧪 Testing / Eval

Mesure avant/après toujours. Le set d'eval :

python
# tests/test_chunking.py
import pytest
from chunking import split_by_article, semantic_split, contextual_chunks

def test_article_split_keeps_article_intact():
    text = "Article 1 — Objet\nLe présent contrat...\nArticle 2 — Durée\nDeux ans."
    chunks = split_by_article(text)
    assert len(chunks) == 2
    assert chunks[0][0] == 1
    assert "Objet" in chunks[0][1]
    assert "Article 2" not in chunks[0][1]

def test_no_chunk_exceeds_max_tokens():
    from transformers import AutoTokenizer
    tok = AutoTokenizer.from_pretrained("BAAI/bge-m3")
    text = open("fixtures/long_contract.txt").read()
    chunks = split_by_article(text)
    for _, body in chunks:
        assert len(tok.encode(body)) <= 8192

@pytest.mark.asyncio
async def test_contextual_summary_short():
    summary = await contextualize("...long doc...", "Article 5 — Prix : 100k€")
    n_words = len(summary.split())
    assert 20 <= n_words <= 150

Ragas en CI sur 50 paires Q/R :

yaml
# .github/workflows/eval.yml
name: RAG eval
on: [pull_request]
jobs:
  ragas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install -r requirements.txt
      - run: python eval_ragas.py --out=eval_results.json
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      - uses: actions/github-script@v7
        with:
          script: |
            const r = require('./eval_results.json')
            const body = `## Ragas\n- faithfulness: ${r.faithfulness}\n- context_precision: ${r.context_precision}`
            github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })

Seuils minimaux que je négocie chez le client : faithfulness ≥ 0.85, context_precision ≥ 0.80. Sous ces seuils, on ne livre pas.

🔁 Quand utiliser / éviter

StratégieQuand utiliserQuand éviter
Fixed-sizePrototype 1 jour, benchmark baselineProduction, jamais
Recursive charDéfaut raisonnable, prose libreDocs très structurés (contrats, HTML)
SemanticArticles longs, prose libre, transcriptsVolume > 100k docs (coût embedder élevé)
Document-structure awareTech docs, contrats, HTML/MarkdownTexte sans structure
Small-to-bigToujours en production si tu peuxLatence ultra-critique (< 100ms)
Anthropic contextual retrievalDomaine spécialisé (legal, médical, finance)Catalogue produit simple
Late chunking (Jina/Voyage)Tu as un embedder long-context et budget limitéPas de support FR du provider
Speaker-turnTranscripts (réunions, calls, podcasts)Tout sauf transcripts

🏋️ Exercices

Progression du « j'implémente » au « je casse, je répare, je défends le chiffre ». Pas de « change cette constante » : on veut du dur.

Exercice 1 — Le splitter par structure qui ne perd jamais un token

Objectif : écrire un MarkdownAwareSplitter qui chunke par section (#/##/###) ET respecte un budget de 512 tokens du modèle d'embedding cible, en re-découpant les sections trop longues sans couper une phrase.

Contrainte dure : mesure en tokens via le tokenizer réel (AutoTokenizer.from_pretrained("BAAI/bge-m3")), pas en caractères. Aucun chunk > 512 tokens, aucune phrase coupée en deux, header de section préfixé à chaque sous-chunk (## Durée\n…).

Indice/Solution : MarkdownHeaderTextSplitter pour la structure, puis un second passage qui re-splitte chaque section sur ". " en accumulant jusqu'à ce que len(tok.encode(buf + phrase)) > 512. Test de non-régression : assert all(len(tok.encode(c)) <= 512 for c in chunks) + assert all(c.startswith("#") for c in chunks).

Exercice 2 — Contextual retrieval avec preuve de cache

Objectif : implémenter le pipeline de contextualisation claude-haiku-4-5 du fichier, et prouver par les métriques que le prompt caching fonctionne sur un contrat de 40 articles.

Contrainte dure : logge cache_creation_input_tokens et cache_read_input_tokens pour chaque appel. Assert que sur les 40 chunks d'un même contrat, exactement 1 appel a cache_creation > 0 et les 39 autres ont cache_read > 0. Calcule le coût réel et compare au coût sans cache.

Indice/Solution : le doc parent va dans le bloc avec cache_control: {"type": "ephemeral"}, le chunk volatile vient après le dernier breakpoint. Si cache_read reste à 0 partout → un invalidant silencieux casse le préfixe (PDF non déterministe, espaces, ordre des blocs, datetime.now() quelque part). Le break-even du cache 5 min : dès le 2ᵉ appel (write 1.25× + read 0.1× = 1.35× vs 2× sans cache).

Exercice 3 — Casse-le : le piège du tokenizer FR

Objectif : démontrer empiriquement qu'un chunk_size mesuré en caractères explose la fenêtre sur du français, puis le réparer.

Contrainte dure : prends un contrat FR, chunke à chunk_size=512 (caractères), passe chaque chunk dans count_tokens du modèle d'embedding ET via client.messages.count_tokens(model="claude-haiku-4-5", …). Montre le ratio tokens/caractères. Trouve un chunk qui dépasse la fenêtre du modèle d'embedding. Répare en mesurant en tokens.

Indice/Solution : sur du FR/code, le tokenizer produit nettement plus de tokens que len(text)/4 ne le laisse croire (accents, mots composés). N'estime jamais avec tiktoken (c'est OpenAI, faux pour le modèle cible) : utilise le count_tokens du provider. La réparation : length_function=lambda t: len(tok.encode(t)) dans le splitter.

Exercice 4 — Eval honnête : prouve que ton chunking est meilleur

Objectif : monter le harnais Ragas du fichier sur 2 stratégies (fixed-size vs par-article + contextual) et produire un verdict statistiquement défendable, pas un « je trouve que c'est mieux ».

Contrainte dure : 50 paires Q/R minimum. Reporte context_precision et context_recall avec un intervalle de confiance bootstrap (1 000 rééchantillonnages). Si les IC à 95 % se chevauchent, conclus « pas de différence significative » — n'annonce pas un gain que tu ne peux pas défendre en RDV.

Indice/Solution : evaluate() te donne des scores par exemple ; bootstrap sur ces scores par-exemple pour l'IC. Le piège classique : 8 questions, +5 pts, « c'est mieux » → bruit. Avec 50 questions et bootstrap, soit le gain tient, soit il ne tient pas — et tu le dis.

Exercice 5 — Rends-le production-grade : ingest idempotent + résilient

Objectif : transformer l'ingest end-to-end en pipeline qui (a) ne double pas en base si on relance, (b) survit aux 429/529 Anthropic, (c) chiffre son propre coût.

Contrainte dure : clé business = hash stable du contenu du chunk (re-ingest du même contrat → 0 doublon, via ON CONFLICT DO NOTHING). Attrape RateLimitError/OverloadedError/APITimeoutError typées et route les chunks échoués vers une dead-letter table. À la fin, somme resp.usage sur tous les appels et imprime le coût réel en €.

Indice/Solution : contrainte UNIQUE(content_hash) + INSERT … ON CONFLICT. Pour la résilience, le wrapper contextualize_safe du fichier ; ne jamais swallow un 529 (Anthropic surchargé) → backoff/dead-letter, sinon tu perds des chunks silencieusement. Coût = Σ(input × 1$/M + output × 5$/M) moins les cache_read facturés à ~0.1×.

Exercice 6 — Défends le chiffre (mission, pas code)

Objectif : on te dit « 0.54 → 0.91 de context precision, ça vaut 23 k€/mois ». Reconstruis le calcul, identifie l'hypothèse la plus fragile, et propose la mesure qui la valide chez le client.

Contrainte dure : décompose les 23 k€/mois en variables (nb avocats × min/jour économisées × TJM chargé × jours ouvrés). Pour chaque variable, dis comment tu la mesures (pas comment tu la supposes). Trouve celle qui, si elle est fausse de 2×, fait s'effondrer le ROI.

Indice/Solution : la variable fragile est presque toujours « minutes économisées par requête », auto-déclarée et gonflée. Mesure-la par A/B (temps de recherche avec vs sans l'assistant, sur 20 requêtes chronométrées) avant de l'écrire dans le deck. Un staff ingénieur livre un chiffre qu'il peut défendre, pas un chiffre qui vend.

Exercice 7 — Casse le cloisonnement multi-tenant, puis blinde-le

Objectif : sur une base pgvector partagée par plusieurs cabinets, démontrer qu'un filtre tenant en post-traitement applicatif fuit, puis le rendre étanche au niveau base et le prouver par un test adversarial.

Contrainte dure : ingère 2 cabinets (A, B) avec un tenant_id par chunk. Écris d'abord un retrieval « naïf » qui fait le top-k vector search PUIS filtre tenant_id en Python. Construis une requête de A dont le plus proche voisin est un chunk de B et montre que k=5 ne ramène que des chunks de B → 0 résultat pour A alors que la réponse existe (ou pire, selon l'implémentation, une fuite). Répare en poussant WHERE tenant_id = %s dans le SQL avant le ORDER BY embedding, idéalement avec une Row-Level Security policy. Écris un test CI : assert all(c["tenant_id"] == "A" for c in retrieve("A", query)) sur 50 requêtes croisées.

Indice/Solution : le bug du filtre-après-coup, c'est que le LIMIT k s'applique avant le filtre tenant : tes 5 meilleurs voisins peuvent tous appartenir au mauvais tenant, et tu te retrouves soit à 0 résultat, soit (si le filtre est oublié quelque part) avec une fuite. Le filtre doit être un prédicat poussé dans la requête vectorielle (WHERE tenant_id = %s ORDER BY embedding <=> %s LIMIT k), pas un .filter() applicatif. RLS Postgres (CREATE POLICY ... USING (tenant_id = current_setting('app.tenant')::text)) est la ceinture-bretelles : la contrainte tient même quand un dev oublie le WHERE. C'est exactement la question d'entretien « comment tu garantis qu'un cabinet ne voit pas l'autre » sous forme de code.

🎤 En entretien

« Pourquoi un mauvais chunking tue le RAG avant même le retrieval ? » Parce que l'embedding moyenne le sens du chunk : un chunk qui mélange deux sujets produit un vecteur « au milieu » que la query ne matche jamais ; un chunk coupé en plein milieu d'une clause perd le sujet. Le chunking définit l'unité atomique de récupération — tout le reste (reranking, génération) hérite de ses défauts.

« Contextual retrieval Anthropic : quel est le vrai coût, et comment tu le divises par 10 ? » Le coût naïf, c'est 1 appel LLM par chunk (50-100 tokens out) en re-passant le doc parent entier à chaque fois. Le levier, c'est le prompt caching : le doc parent est identique pour tous les chunks d'un contrat → on le met dans le préfixe stable avec cache_control: ephemeral (write 1.25× une fois, read ~0.1× ensuite). Sur claude-haiku-4-5 à 1 $/5 $ par M tok, plus la Batches API à -50 % pour l'offline, 1 M chunks descend à ~100 €. Le piège : si le contenu volatile (le chunk) passe avant le breakpoint, le cache ne sert jamais.

« Small-to-big, c'est quoi et quand ça casse ? » Tu embeds des chunks petits (200-400 tok) pour la précision du retrieval, mais tu renvoies au LLM le parent (paragraphe/doc) pour le contexte. Ça casse sur de la donnée non structurée (logs) où il n'y a pas de « parent » naturel, et sous contrainte de latence ultra-serrée (< 100 ms) où le lookup parent en SQL ajoute un aller-retour.

« Comment tu prouves qu'un changement de chunking améliore le système, et pas juste ton ressenti ? » Eval avant/après obligatoire sur un jeu de Q/R hand-crafted (Ragas : context_precision, context_recall, faithfulness), avec intervalle de confiance bootstrap. Si les IC se chevauchent, il n'y a pas de gain — peu importe l'intuition. En prod, je câble Ragas en CI sur PR avec des seuils (ex. precision ≥ 0.80) qui bloquent le merge sous le seuil.

« Quand est-ce que le contextual retrieval Anthropic ne vaut PAS le coup ? » Quand la donnée est déjà structurée et auto-portée : un catalogue produit où chaque champ (fabric, occasions) est déjà un chunk sans ambiguïté de référent n'a rien à gagner d'un mini-contexte LLM — tu paies un préprocessing pour zéro point de precision. Idem sur des volumes > 1 M chunks où le coût explose : là je passe au late chunking (le contexte global vient de l'embedder long-context, pas d'un appel LLM par chunk). La règle : le contextual retrieval gagne quand le chunk perd son référent une fois isolé (legal, médical, finance), pas par défaut.

« Ta base RAG sert 12 cabinets d'avocats. Comment tu garantis qu'un cabinet ne retrouve jamais le contrat d'un autre ? » Le tenant_id n'est pas de la metadata décorative, c'est un prédicat dans le WHERE du vector search (WHERE tenant_id = %s ORDER BY embedding <=> %s), poussé au niveau base, jamais filtré en post-traitement applicatif où un oubli de code = une fuite. Idéalement renforcé par Row-Level Security Postgres pour que la contrainte tienne même si un dev se trompe dans la requête. Et je teste ce cloisonnement avec une requête adversariale en CI : une query du tenant A ne doit jamais ramener un chunk_id du tenant B.

🔗 Liens

Bibliothèque tech perso — Achref