De Dev Web a Ingenieur IA
De Dev Web à Ingénieur IA #6 — Chunking Stratégies : Fixed-size, Semantic, Recursive
Pourquoi la façon dont tu découpes tes documents change tout dans un pipeline RAG

Intro
Dans l’article précédent, on a construit notre premier pipeline RAG complet. Mais le chunking utilisé était basique : content.split(/\n\n+/). Pour des petits documents d’un seul paragraphe, ça passait. Mais dans la vraie vie, un document peut faire 10, 50 ou 200 pages.
Un mauvais chunking casse le RAG. Si l’information est coupée en deux, perdue dans un chunk trop gros, ou mélangée avec du bruit, la recherche vectorielle ne trouve pas la bonne réponse.
Aujourd’hui on compare 3 stratégies de chunking sur un vrai document long, pour comprendre leurs forces et faiblesses.
Le concept : pourquoi le chunking est crucial
Le chunking est la première étape de l’ingestion : on découpe un document en morceaux (chunks) qu’on vectorise. Plus tard, la recherche retrouve les chunks les plus proches de la question.
Trois contraintes contradictoires :
-
Assez petit pour que le chunk soit focalisé sur un seul sujet
-
Assez grand pour avoir assez de contexte sémantique
-
Bien découpé pour ne pas couper une information en plein milieu
Un chunk de 100 caractères n’a pas assez de sens. Un chunk de 2000 caractères noie l’information dans du bruit. Un chunk qui commence au milieu d’une phrase est inexploitable.
Les 3 stratégies
Note : dans le code,
\ndésigne un saut de ligne (appui sur Entrée).\n\n= deux sauts de ligne = ligne vide entre deux paragraphes. C’est le séparateur naturel le plus courant dans un document texte.
1. Fixed-size chunking
Le plus simple : on prend les N premiers caractères, on avance de N - overlap, on recommence.
Texte original (450 caractères) :
Les ventes de juin atteignent 182 000 euros,
soit +6% par rapport à mai. Il est décidé
d'ajouter un créneau de 16h à 18h...
Chunk size = 200, overlap = 50 → on avance de 150 à chaque fois :
Chunk #1 (caractères 0 à 200) :
Les ventes de juin atteignent 182 000
euros, soit +6% par rapport à mai. Il
est décidé d'ajouter
← coupé en plein milieu
Chunk #2 (caractères 150 à 350) :
rapport à mai. Il est décidé d'ajouter
un créneau de 16h à 18h pendant trois
semaines. Sophie mettra à jour
← overlap : la phrase coupée est reprise
Chunk #3 (caractères 300 à 450) :
trois semaines. Sophie mettra à jour
les procédures avant le 8 juillet.
Karim suivra les...
-
Avantage : O(1), déterministe, ignorable en calcul
-
Inconvénient : aucune conscience du texte — coupe au milieu d’une phrase, d’un mot, d’une idée
-
Quand l’utiliser : jamais en production (sauf sur des données non structurées où on n’a pas le choix)
2. Semantic chunking
On utilise les frontières naturelles du texte : paragraphe, phrase. On découpe uniquement à ces marqueurs, avec une taille min/max pour éviter les chunks trop petits ou trop longs.
texte = "Paragraphe 1\n\nParagraphe 2\n\nParagraphe 3..."
↓
["Paragraphe 1", "Paragraphe 2", "Paragraphe 3..."]
-
Avantage : chunks cohérents, pas de coupure arbitraire
-
Inconvénient : peut produire des chunks trop longs (paragraphe de 1500 chars)
-
Quand l’utiliser : sur des documents bien formatés (rapports, articles, emails)
3. Recursive chunking
C’est la stratégie utilisée par LangChain et LlamaIndex. L’idée : on a une liste de séparateurs classés du plus large au plus fin (paragraphe → phrase → mot). On commence par le plus large, et si un chunk est trop long, on le re-découpe avec le séparateur suivant.
Comme un entonnoir : on verse le texte au niveau le plus large, ce qui passe reste entier, ce qui est trop gros descend au niveau suivant.
Document d'entrée :
Section A
Section B. Avec une longue phrase qui fait
plus de 600 caractères parce qu'elle continue
encore et encore sans s'arrêter...
Section C
maxSize = 600 caractères
Étape 1 — On découpe d'abord par \n\n (paragraphe) :
[Section A (200 chars)] ✓ passe (< 600)
[Section B... (700 chars)] ✗ trop long → on re-découpe
[Section C (180 chars)] ✓ passe (< 600)
Étape 2 — Section B est trop longue → on la re-découpe par . (phrase) :
["Section B." (50 chars)] ✓
["Avec une longue phrase..." (200 chars)] ✓
[etc.] ✓
→ tout passe, terminé
Si un chunk était encore trop long après les phrases, on descendrait à , , puis à l’espace, puis au caractère en dernier recours. Le résultat : des chunks aussi grands que possible sans dépasser la limite, et toujours cohérents.
-
Avantage : s’adapte à n’importe quel format, chunks toujours cohérents
-
Inconvénient : plus complexe, paramètres à ajuster
-
Quand l’utiliser : partout — c’est la valeur par défaut
Les données
Pour comparer les stratégies de chunking, les 8 fichiers de l’article 5 sont trop courts : chaque document tient dans un seul chunk, donc aucune différence visible entre strategies.
J’ai donc généré rapport-annuel-long.txt, un rapport d’activité d’environ 3000 caractères répartis en 5 paragraphes. Chaque paragraphe couvre un sujet différent (finances, logistique, incident, RH, objectifs) — parfait pour voir comment chaque stratégie les découpe.
Comment générer le tien
Le prompt ChatGPT qui a produit ce document :
Génère UN document professionnel en français pour une PME de
logistique (univers cohérent : Marie Dupont, Karim Petit,
Boulangerie Martin, etc.). Le document doit faire 800-1000 mots
et être composé de 5-6 paragraphes séparés par des lignes vides.
Le format est du texte BRUT, sans aucun formatage (pas de ##,
pas de markdown, pas de titres). Le genre de texte qu'on
obtiendrait en copiant-collant depuis un export PDF ou un email
long.
Contenu : un rapport d'activité annuel (année fiscale juillet
2025 - juin 2026) qui couvre plusieurs sujets sans titres —
chiffres de vente, problèmes logistiques, un incident livraison,
ressources humaines (congés, formations), et objectifs pour
l'année à venir. Chaque paragraphe doit pouvoir être extrait
individuellement (ils traitent de sujets différents mais le tout
forme un document cohérent).
Écris juste le contenu, sans introduction, sans méta.
Fichier : rapport-annuel-long.txt

Les 8 documents originaux restent dans datas/ pour les articles suivants.
Setup
cp -r article-05-rag-basics article-06-chunking
cd article-06-chunking && rm -rf node_modules package-lock.json
npm install
Vérifie que le rapport long est accessible :
ls ../datas/rapport-annuel-long.txt
Les modèles sont les mêmes que l’article 5 (qwen3:4b, qwen3-embedding:4b). Vérifie que PostgreSQL tourne :
docker compose up -d
Le code
On crée 3 tables PostgreSQL distinctes (une par stratégie), on ingère le rapport avec chaque stratégie, et on pose les mêmes questions pour comparer :
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
const { Pool } = pg;
const CHAT_MODEL = "qwen3:4b";
const EMBED_MODEL = "qwen3-embedding:4b";
const OLLAMA_BASE = process.env.OLLAMA_HOST ?? "http://localhost:11434";
const DATAS_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"datas",
);
const EMBED_DIM = 2560;
const pool = new Pool({
host: process.env.PGHOST ?? "localhost",
port: Number(process.env.PGPORT ?? 5432),
user: process.env.PGUSER ?? "blog",
password: process.env.PGPASSWORD ?? "blog",
database: process.env.PGDATABASE ?? "blog",
});
// ─── 3 stratégies de chunking
function fixedSizeChunks(
text: string,
size: number,
overlap: number,
): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + size, text.length);
chunks.push(text.slice(start, end));
start += size - overlap;
}
return chunks;
}
function semanticChunks(text: string, maxSize = 600): string[] {
const paragraphs = text
.split(/\n\n+/)
.map((p) => p.trim())
.filter(Boolean);
const chunks: string[] = [];
let buffer = "";
for (const p of paragraphs) {
if (p.length > maxSize) {
// Découpage des longs paragraphes par phrase
const sentences = p.split(/(?<=[.!?])\s+/).filter(Boolean);
for (const s of sentences) {
if ((buffer + " " + s).length > maxSize && buffer) {
chunks.push(buffer.trim());
buffer = s;
} else {
buffer = buffer ? buffer + " " + s : s;
}
}
} else if (buffer.length + p.length > maxSize) {
chunks.push(buffer.trim());
buffer = p;
} else {
buffer = buffer ? buffer + "\n\n" + p : p;
}
}
if (buffer) chunks.push(buffer.trim());
return chunks;
}
function recursiveChunks(text: string, maxSize = 600, overlap = 50): string[] {
const separators = ["\n\n", ". ", ", ", " "];
function split(text: string, sepIdx: number): string[] {
if (text.length <= maxSize || sepIdx >= separators.length) {
return [text];
}
const sep = separators[sepIdx];
const parts = text.split(sep);
const result: string[] = [];
let buffer = "";
for (let i = 0; i < parts.length; i++) {
// On garde le séparateur au début de chaque morceau (sauf le premier)
const piece = i === 0 ? parts[i] : sep + parts[i];
if ((buffer + piece).length > maxSize && buffer) {
result.push(buffer);
buffer = piece;
} else {
buffer += piece;
}
}
if (buffer) result.push(buffer);
// Re-découpage des chunks trop longs avec le séparateur suivant
return result.flatMap((chunk) => {
if (chunk.length > maxSize && sepIdx + 1 < separators.length) {
return split(chunk, sepIdx + 1);
}
return [chunk];
});
}
const base = split(text, 0);
// Ajout du chevauchement entre chunks consécutifs
const chunks: string[] = [];
for (let i = 0; i < base.length; i++) {
let chunk = base[i];
if (i > 0 && overlap > 0) {
const prev = base[i - 1];
chunk = prev.slice(-overlap) + chunk;
}
chunks.push(chunk);
}
return chunks;
}
// ─── Embeddings
async function embed(text: string): Promise<number[]> {
const res = await fetch(`${OLLAMA_BASE}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: EMBED_MODEL, input: text }),
});
if (!res.ok)
throw new Error(`Embedding HTTP ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { embeddings: number[][] };
return data.embeddings[0];
}
// ─── Chat
async function chat(system: string, user: string): Promise<string> {
const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: CHAT_MODEL,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
stream: false,
}),
});
if (!res.ok) throw new Error(`Chat HTTP ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { message: { content: string } };
return data.message.content;
}
// ─── Helpers BDD
async function ensureTable(tableName: string) {
await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${tableName} (
id SERIAL PRIMARY KEY,
strategy TEXT NOT NULL,
filename TEXT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(${EMBED_DIM})
)
`);
}
async function dropTable(name: string) {
await pool.query(`DROP TABLE IF EXISTS ${name}`);
}
// ─── Spinner
async function withSpinner<T>(label: string, fn: () => Promise<T>): Promise<T> {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r${frames[i]} ${label}`);
i = (i + 1) % frames.length;
}, 80);
try {
const result = await fn();
process.stdout.write(`\r✓ ${label}\n`);
return result;
} catch (e) {
process.stdout.write(`\r✗ ${label}\n`);
throw e;
} finally {
clearInterval(interval);
}
}
// ─── Ingestion d'une stratégie
async function ingestStrategy(
name: string,
tableName: string,
chunks: string[],
filename: string,
) {
console.log(`\n ${name} (${chunks.length} chunks) :`);
for (let i = 0; i < chunks.length; i++) {
const vector = await embed(chunks[i]);
await pool.query(
`INSERT INTO ${tableName} (strategy, filename, chunk_index, content, embedding)
VALUES ($1, $2, $3, $4, $5)`,
[name, filename, i, chunks[i], `[${vector.join(",")}]`],
);
const preview =
chunks[i].length > 100 ? chunks[i].slice(0, 100) + "..." : chunks[i];
console.log(` [#${i}] (${chunks[i].length} chars) ${preview}`);
}
}
// ─── Requête
async function queryStrategy(
tableName: string,
question: string,
): Promise<{ context: string; answer: string }> {
const qVector = await embed(question);
const result = await pool.query(
`SELECT content FROM ${tableName} ORDER BY embedding <=> $1::vector LIMIT 2`,
[`[${qVector.join(",")}]`],
);
const context = result.rows.map((r) => r.content).join("\n\n---\n\n");
const systemPrompt =
`Tu es un assistant spécialisé. Réponds UNIQUEMENT à partir du contexte fourni. ` +
`Si le contexte ne contient pas la réponse, dis-le. Réponds en français.\n\nContexte :\n${context}`;
const answer = await chat(systemPrompt, question);
return { context, answer };
}
// ─── Point d'entrée
async function main() {
const content = readFileSync(
join(DATAS_DIR, "rapport-annuel-long.txt"),
"utf-8",
).trim();
// Création des 3 tables
for (const t of [
"documents_fixed",
"documents_semantic",
"documents_recursive",
]) {
await withSpinner(`Nettoyage table ${t}...`, () =>
dropTable(t).then(() => ensureTable(t)),
);
}
// Ingestion
console.log("\n=== Ingestion ===\n");
await ingestStrategy(
"fixed-size",
"documents_fixed",
fixedSizeChunks(content, 300, 50),
"rapport-annuel-long.txt",
);
await ingestStrategy(
"semantic",
"documents_semantic",
semanticChunks(content),
"rapport-annuel-long.txt",
);
await ingestStrategy(
"recursive",
"documents_recursive",
recursiveChunks(content),
"rapport-annuel-long.txt",
);
// Requêtes
const questions = [
"Que s'est-il passé avec Boulangerie Martin en février 2026 ?",
"Quels sont les objectifs fixés par la direction pour l'exercice 2026-2027 ?",
"Combien d'heures de formation ont été dispensées ?",
];
const strategies = [
{ label: "--- Fixed-size ---", table: "documents_fixed" },
{ label: "--- Semantic ---", table: "documents_semantic" },
{ label: "--- Recursive ---", table: "documents_recursive" },
] as const;
for (const q of questions) {
console.log(`\n❓ ${q}\n`);
for (const { label, table } of strategies) {
const { context, answer } = await queryStrategy(table, q);
console.log(` ${label}`);
console.log(
` 📚 Contexte : ${context.slice(0, 150).replace(/\n/g, " ")}...`,
);
console.log(` 🤖 ${answer}\n`);
}
}
await pool.end();
}
main().catch((err) => {
console.error(`\nErreur : ${err.message}`);
process.exit(1);
});
Exécution
npm start
Résultat complet :
✓ Nettoyage table documents_fixed...
✓ Nettoyage table documents_semantic...
✓ Nettoyage table documents_recursive...
=== Ingestion ===
fixed-size (33 chunks) :
[#0] (300 chars) Marie Dupont, directrice des opérations...
[#1] (300 chars) pprovisionnement parfois allongés chez...
...
[#32] (148 chars) e aux aléas logistiques et de poursuivre...
semantic (17 chunks) :
[#0] (583 chars) Marie Dupont... bilan de l'exercice fiscal...
[#1] (459 chars) Les principaux clients, parmi lesquels...
...
[#16] (483 chars) Enfin, la direction entend maintenir...
recursive (19 chunks) :
[#0] (582 chars) Marie Dupont...
[#1] (508 chars) ...186 000 colis et palettes.\n\nLes principaux clients...
...
❓ Que s'est-il passé avec Boulangerie Martin en février 2026 ?
--- Fixed-size ---
📚 Contexte : ...L'exercice a néanmoins été marqué par un
incident de livraison...
🤖 En février 2026, une erreur de chargement combinée à une
mauvaise identification de plusieurs palettes destinées à
Boulangerie Martin a provoqué l'envoi d'une partie de la
marchandise vers un dépôt régional incorrect.
--- Semantic ---
📚 Contexte : ...Les réunions hebdomadaires entre les services
commerciaux, logistiques et administratifs... (2e chunk : l'incident)
🤖 L'incident a été causé par une erreur de chargement et une
mauvaise identification des palettes de Boulangerie Martin,
entraînant un retard de 24h.
--- Recursive ---
📚 Contexte : ...L'exercice a néanmoins été marqué par un
incident de livraison...
🤖 En février 2026, une erreur de chargement combinée à une
mauvaise identification de palettes a provoqué un retard de
24h pour Boulangerie Martin.
❓ Quels sont les objectifs fixés par la direction pour l'exercice 2026-2027 ?
--- Fixed-size ---
📚 Contexte : ...dernières années.\n\nPour l'exercice couvrant
juillet 2026 à juin 2027...
🤖 Des objectifs ambitieux mais réalistes, avec priorité à
l'amélioration continue de la qualité de service.
--- Semantic ---
📚 Contexte : ...Les indicateurs d'absentéisme restent maîtrisés...
(mauvais paragraphe !)
🤖 Le contexte ne précise pas les objectifs chiffrés.
--- Recursive ---
📚 Contexte : ...dernières années.\n\nPour l'exercice couvrant
juillet 2026 à juin 2027...
🤖 Objectifs : taux de livraison > 98%, réduction des km à
vide, diminution des réclamations clients.
❓ Combien d'heures de formation ont été dispensées ?
--- Fixed-size ---
🤖 Plus de six cents heures.
--- Semantic ---
🤖 Plus de six cents heures.
--- Recursive ---
🤖 Plus de six cents heures.


Analyse des résultats
Les résultats sont très parlants :
Question 1 — Boulangerie Martin
| Stratégie | Contexte récupéré | Qualité |
|---|---|---|
| Fixed-size | L’incident (dans un chunk chevauchant) | Correct |
| Semantic | Un paragraphe hors sujet sur les réunions + l’incident | Correct mais bruit |
| Recursive | L’incident complet (overlap préserve la continuité) | Correct |
Toutes les stratégies trouvent l’information, mais semantic remonte un chunk non pertinent en premier — le LLM doit piocher dans le second.
Question 2 — Objectifs 2026-2027 (le cas qui tue)
| Stratégie | Contexte récupéré | Qualité |
|---|---|---|
| Fixed-size | Le dernier paragraphe (l’overlap rattrape le saut) | Correct, réponse vague |
| Semantic | Mauvais paragraphe (absentéisme) | Échec : le LLM dit “le contexte ne précise pas” |
| Recursive | Le dernier paragraphe | Correct, réponse détaillée (98%, km à vide…) |
C’est le résultat le plus important de l’article. Semantic a raté la cible : la question “objectifs 2026-2027” a été sémantiquement rapprochée du paragraphe sur l’absentéisme — qui parle aussi de “prévention” et “années” — plutôt que du paragraphe des objectifs. Fixed-size et recursive ont réussi grâce à l’overlap : la fin du paragraphe précédent (“dernières années”) se chevauche avec le début du paragraphe des objectifs.
Question 3 — Heures de formation
Question factuelle simple, toutes les stratégies trouvent l’information sans difficulté. C’est le cas nominal où le chunking n’a pas d’impact.
| Stratégie | Nb chunks | Résultat |
|---|---|---|
| Fixed-size | 33 | Survit grâce à l’overlap mais chunking excessif |
| Semantic | 17 | Rate la cible sur la question 2 |
| Recursive | 19 | Résultats les plus fiables sur les 3 questions |
Analyse attendue
| Stratégie | Nb chunks | Résultat |
|---|---|---|
| Fixed-size | 33 | Survit grâce à l’overlap, mais chunking excessif (perte perf) |
| Semantic | 17 | Rate la cible sur la question 2 (paragraphe hors sujet) |
| Recursive | 19 | Résultats les plus fiables sur les 3 questions |
Comment ça marche
Fixed-size : on avance dans le texte par fenêtre glissante. L’overlap de 50 caractères permet d’éviter qu’une information tombe exactement à la jointure entre deux chunks. Mais un overlap trop petit (ou nul) crée des “trous” d’information entre les chunks — la recherche peut rater la phrase qui chevauche deux chunks.
Semantic : on repère les séparateurs de paragraphe (\n\n) comme frontières naturelles. Les paragraphes raisonnables restent intacts. Si un paragraphe dépasse la taille max (600 chars), on le découpe par phrase à la place. C’est le meilleur rapport simplicité/qualité pour des documents bien formatés.
Recursive : on essaie les séparateurs du plus large au plus fin. D’abord le paragraphe (\n\n), puis la phrase (. ), puis la proposition (, ), puis l’espace. Chaque niveau ne split que les chunks qui dépassent encore la taille max. Résultat : on obtient le chunk le plus grand possible qui reste sous la limite et cohérent. L’overlap entre chunks est rajouté ensuite pour lisser les transitions.
Pendant ce temps, sous le capot
Chaque stratégie appelle le modèle d’embedding une fois par chunk. Si fixed-size produit 10 chunks et semantic 6, fixed-size coûte ~60% plus d’embedding calls et prend plus de place dans pgvector. La différence est négligeable sur un document, mais sur 10 000 documents, ça compte.
Le vrai coût est implicite : un mauvais chunking qui rate la réponse = une question qui échoue. C’est une perte de confiance dans le système bien plus coûteuse que quelques embeddings supplémentaires.
Ce qu’on a appris
-
Fixed-size — simple, rapide, mais coupe arbitrairement. À éviter sur des textes structurés.
-
Semantic — respecte les frontières du texte. Parfait pour des documents bien formatés (rapports, emails).
-
Recursive — l’approche production : essaie plusieurs séparateurs, s’adapte à tout format
-
L’overlap — indispensable en fixed-size pour ne pas perdre d’information aux jointures
-
Pas de stratégie universelle — le bon chunking dépend du format de tes documents
En pratique, la stratégie recursive est celle utilisée par LangChain et LlamaIndex :
new RecursiveCharacterTextSplitter({
chunkSize: 600,
chunkOverlap: 50,
separators: ["\n\n", ". ", ", ", " "],
});
Prochain article
De Dev Web à Ingénieur IA #7 — Embeddings & Vector Search — comment les modèles d’embedding transforment le texte en vecteurs, quel modèle choisir, et comment la dimension impacte la qualité de la recherche.