Retour aux series

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, \n dé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

Rapport annuel long pour tester le chunking

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.

Résultat des stratégies de chunking

Comparaison des réponses RAG selon le chunking


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.