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 :
- 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à. - Le retrieval a-t-il besoin de précision (petits chunks) ET le LLM de contexte (gros chunks) ? Si oui →
small-to-bigpar-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. - 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 retrievaloulate chunkingpour 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 :
# 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 chunksPourquoi 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 :
# 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.
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 chunksGains : 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 :
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 chunksGains : 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é)".
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.
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 NoneGains : 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).
# 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é :
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 :
# 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 :
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 chunking | faithfulness | context_precision | context_recall | answer_relevancy |
|---|---|---|---|---|
| Fixed-size 512 | 0.71 | 0.54 | 0.61 | 0.68 |
| Recursive char 800/100 | 0.78 | 0.66 | 0.72 | 0.74 |
| Par article | 0.86 | 0.83 | 0.81 | 0.82 |
| Par article + contextual (Anthr.) | 0.92 | 0.91 | 0.88 | 0.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.
# 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 parentsQuand : 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.
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 declaude-sonnet-4-6(3 $/15 $) ni d'claude-opus-4-8(5 $/25 $). L'aliasclaude-haiku-4-5est 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 utilisethinking={"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) avecmax_retrieset 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.
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.
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 chunksCoû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.
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
| Outil | Version 2026 | Notes |
|---|---|---|
| LangChain text_splitter | 0.3.x | RecursiveCharacterTextSplitter toujours solide |
| LlamaIndex SemanticSplitterNodeParser | 0.12.x | Bon défaut pour semantic |
| Unstructured.io | 0.16.x | Best pour PDF complexes, tables, OCR |
| Anthropic prompt caching | 5 min / 1 h | Indispensable pour contextual retrieval |
| Jina embeddings v3 | 8K context | Late chunking natif |
| Voyage-3-large | 32K context | Multilingue FR très fort |
| Cohere Embed v4 | Multimodal | Late chunking expérimental |
| Ragas | 0.2.x | LLM-as-judge → utilise Sonnet ou Haiku pour eval |
| Chonkie | 0.4.x | Lib 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
- Overlap de 50 % en fixed-size → tu doubles ta base, recall ne bouge pas. Reste 10-20 % d'overlap max.
- Chunk trop gros (> 1500 tokens) → l'embedding "moyenne" trop de signaux. Le LLM ne sait plus où regarder. Garde 200-800.
- 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)). - Pas de metadata → tu retrouves un chunk pertinent mais tu ne sais pas de quel doc il vient. Toujours
{source, page, section, ts, author}. - Chunking commun pour types de doc hétérogènes → contrats + emails + tickets ne se chunkent pas pareil. Pipeline par type.
- Re-embed à chaque déploiement → tu cramework des $$ chez OpenAI. Hash du chunk + cache embeddings (Redis ou table dédiée).
- 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.
- Contextual retrieval sans prompt caching → ton run coute 10× plus cher que nécessaire. Cache
cache_control: ephemeralsur le doc parent. - Tables PDF chunkées comme du texte → la table devient illisible. Utilise Unstructured.io pour extraire en HTML/Markdown.
- Headers Markdown ignorés → tu coupes un H1 du contenu qui le suit. MarkdownHeaderTextSplitter, pas RecursiveCharacterTextSplitter.
- 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.
- 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) :
| Étape | Volume | Modèle | Coût total |
|---|---|---|---|
| Contextualisation (Haiku + cache) | 1 M chunks | claude-haiku-4-5 | ~110 € |
| Embedding (chunk + ctx) | 1 M × 600 tk | text-embedding-3-sm | ~12 € |
| Stockage pgvector | 1 M vecteurs | Hetzner CX31 | 18 €/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_creationvscache_read— voir l'exemple end-to-end). Un p95 qui explose = un type de doc mal parsé. Uncache_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_idretournés, leur score de similarité, et un flaganswered/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 :
- 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.
- Cloisonnement multi-tenant. Si ta base sert plusieurs clients/utilisateurs, le
tenant_iddoit être une colonne filtrée dans leWHEREdu 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. - PII dans les logs. Tu logges des
chunk_id, pas deschunk.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
| Volume | Ce qui casse | La parade |
|---|---|---|
| < 100 K chunks | rien ; HNSW pgvector en mémoire, AsyncAnthropic en ligne suffit | reste simple |
| 100 K – 1 M | l'ingest en ligne prend des heures et mange du TPM | Message Batches API (−50 %) + prompt caching empilés |
| 1 M – 10 M | l'index HNSW ne tient plus en RAM ; re-embed = facture OpenAI | quantization (halfvec), index partitionné, cache d'embeddings par hash |
| > 10 M | contextual retrieval LLM devient trop cher | bascule sur late chunking (pas de LLM par chunk) |
| re-ingest quotidien | tu re-embed des chunks inchangés | hash stable du contenu → ON CONFLICT DO NOTHING, skip si hash connu |
🧪 Testing / Eval
Mesure avant/après toujours. Le set d'eval :
# 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 <= 150Ragas en CI sur 50 paires Q/R :
# .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égie | Quand utiliser | Quand éviter |
|---|---|---|
| Fixed-size | Prototype 1 jour, benchmark baseline | Production, jamais |
| Recursive char | Défaut raisonnable, prose libre | Docs très structurés (contrats, HTML) |
| Semantic | Articles longs, prose libre, transcripts | Volume > 100k docs (coût embedder élevé) |
| Document-structure aware | Tech docs, contrats, HTML/Markdown | Texte sans structure |
| Small-to-big | Toujours en production si tu peux | Latence ultra-critique (< 100ms) |
| Anthropic contextual retrieval | Domaine 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-turn | Transcripts (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
- Anthropic — Contextual Retrieval (le post de référence) : https://www.anthropic.com/news/contextual-retrieval
- Jina AI — Late chunking : https://jina.ai/news/late-chunking-in-long-context-embedding-models/
- LangChain text splitters : https://python.langchain.com/docs/concepts/text_splitters/
- LlamaIndex node parsers : https://docs.llamaindex.ai/en/stable/module_guides/loading/node_parsers/
- Ragas docs : https://docs.ragas.io
- Unstructured.io : https://unstructured.io/
- Chonkie (lib chunking) : https://github.com/bhavnicksm/chonkie
- pgvector indexing : https://github.com/pgvector/pgvector
- Voyage embeddings : https://docs.voyageai.com/docs/embeddings
- Greg Kamradt — chunking visualizations : https://github.com/FullStackRetrieval-com/RetrievalTutorials