Ingestion RAG d’un corpus long structure

Recycler la documentation du projet LivresAgites (en sommeil) vers le depot pedagogique public jsboige/CoursIA. Ce notebook enseigne la methode (comment on decoupe un corpus long structure pour le RAG), jamais l’instance (le catalogue d’une vraie maison d’edition). Le corpus ci-dessous est synthetique.

<- Parcours AI-Engine | Comparatif OWUI/AI-Engine

Le probleme

5_RAG_Modern.ipynb montre comment decouper un texte continu unique (le debat Lincoln-Douglas) en chunks. Mais un cas d’usage editorial reel n’est pas un texte continu : c’est un catalogue de plusieurs ouvrages, chacun structure en chapitres. Le defaut de chunking y est different et plus subtil.

Ce notebook compare deux strategies d’ingestion d’un tel corpus :

  1. Chunking naif (taille fixe + recouvrement) – l’approche par defaut de beaucoup de tutoriels RAG.
  2. Chunking structure (par chapitre, sous-decoupe si trop long) – qui preserve la hierarchie du document source comme metadonnee de filtrage.

La these : la degradation du retrieval vient du chunking, pas du modele d’embeddings. On le demontre avec un vectoriseur TF-IDF deterministe (pas de cle d’API, pas de reseau) – exactement pour isoler cette variable. Changez le vectoriseur pour de vrais embeddings, la conclusion tient.

1. Configuration

Aucune cle d’API necessaire. Le vectoriseur TF-IDF de scikit-learn est completement local et deterministe (random_state fixe a 42), donc la sortie est reproductible sans reseau ni service externe. C’est le choix pedagogique : montrer la mecanique du chunking et de la recherche avant d’invoquer un embedding neuronal plus lourd a deployer. La bibliotheque numpy sert uniquement a la similarite cosinus, calculee en flottants explicites.

Pourquoi pas de GPU ni de Qwen : le notebook demontre le pipeline RAG dans sa version la plus minimaliste, celle ou la mediatheque de Valmont resterait utilisable sur un serveur d’integration sans GPU. L’enchantement viendra dans la serie 5_RAG_Modern.ipynb (voir Pour aller plus loin en cell[23]).

Sortie attendue : la cellule code[0] execute avec succes ; la sortie met imprime les numeros de version scikit-learn et numpy et confirme l’absence d’imports reseau (pas de requests, urllib, openai).

#dependances : scikit-learn (TF-IDF), numpy (cosinus). Aucun reseau.
import re
import textwrap
from collections import Counter
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

print("Configuration OK -- vectoriseur TF-IDF deterministe (hors ligne).")
Configuration OK -- vectoriseur TF-IDF deterministe (hors ligne).

2. Le corpus : trois ouvrages structures en chapitres

Corpus synthetique de la mediatheque de Valmont. Trois ouvrages, chacun en trois chapitres au theme nettement distinct. Les chapitres sont courts pour rendre le defaut visible a l’oeil ; dans un cas reel, chaque chapitre ferait des milliers de mots, mais la mecanique est identique.

L’important n’est pas le contenu – c’est que chaque chapitre traite d’un seul theme, de sorte que melanger deux chapitres dans un meme chunk produit un fragment incoherent.

#Corpus synthetique (mediatheque de Valmont). Aucune prose reelle sous droits.
# Structure : chaque chapitre = (titre, texte). La paire (titre, texte) est ce
# que le chunking structure doit preserver comme etiquette. Les textes repetent
# les mots-theme du chapitre pour donner au vectoriseur un signal net.
CORPUS = {
    "Le Phare de Valmont": {
        "auteur": "A. Riviere (fictif)",
        "chapitres": {
            1: ("La tempete",
                "La tempete frappa la cote de Valmont une nuit de novembre. Le vent "
                "soufflait en rafales sur la digue, et les vagues battaient la pierre "
                "du phare avec une fureur reguliere. Malgre la tempete, le gardien "
                "alluma la lampe du phare a la tombee de la nuit, comme chaque soir. "
                "Sans cette lumiere, la tempete aurait pu jeter un navire sur les rochers."),
            2: ("Le gardien",
                "Me Lucien, gardien du phare depuis trente ans, connaissait chaque "
                "craquement de l'escalier en colimacon. Ce gardien tenait le journal "
                "de bord a jour, notant chaque navire passe, chaque tempete, chaque "
                "avarie de la lampe. Le role du gardien etait une veille patiente, "
                "nuit apres nuit, au sommet du phare."),
            3: ("Le sauvetage",
                "Au petit matin, une barque derivait vers les rochers apres la tempete. "
                "Le gardien appela les secours par la radio VHF : un sauvetage etait "
                "urgent. Le canot de sauvetage de la SNSM arriva en vingt minutes et "
                "le sauvetage du pecheur epuise reussit. Sans le gardien et son appel "
                "VHF, ce sauvetage aurait echoue."),
        },
    },
    "Jardins Suspendus": {
        "auteur": "M. Olivier (fictif)",
        "chapitres": {
            1: ("Les rosiers",
                "La roseraie du jardin suspendu comptait quarante varietes de rosiers. "
                "Le rosier ancien 'Rosa gallica' fleurissait une fois l'an, en juin. "
                "La taille des rosiers se faisait en fin d'hiver, avant la montee de "
                "seve. Un rosier mal taille perdait sa forme ; un rosier bien taille "
                "fleurissait abondamment dans le jardin."),
            2: ("L'irrigation",
                "L'irrigation du jardin reposait sur un goutte-a-goutte qui alimentait "
                "chaque pied en eau. Un programmateur reglait l'irrigation : trois "
                "cycles d'eau par jour en ete, un seul en hiver. Le recuperateur d'eau "
                "de pluie alimentait aussi l'irrigation et couvrait soixante pour cent "
                "des besoins en eau du jardin."),
            3: ("Les saisons",
                "Chaque saison dictait un travail au jardin. Au printemps, la taille de "
                "formation structurait les sujets ; en ete, l'arrosage et le paillage "
                "protegeaient du sec. L'automne etait la saison de la plantation ; "
                "l'hiver, la saison ou l'on taillait les rosiers non remontants. "
                "Ainsi le jardin suivait le rythme des saisons."),
        },
    },
    "Horlogerie Pratique": {
        "auteur": "P. Marchand (fictif)",
        "chapitres": {
            1: ("Le pendule",
                "La longueur du pendule regle la periode d'oscillation de l'horloge. "
                "Un pendule d'un metre bat la seconde. Pour faire avancer l'horloge, "
                "on raccourcit le pendule ; pour la faire retarder, on l'allonge. "
                "Le reglage du pendule est donc le reglage premier d'une horloge, "
                "et la masse du pendule n'a aucun effet sur la periode."),
            2: ("L'echappement",
                "L'echappement a ancre distribue l'energie de la roue motrice au "
                "balancier de l'horloge, une dent a la fois. C'est l'echappement qui "
                "regle le mouvement. Le lubrifiant des pivots de l'echappement "
                "s'epaissit avec le temps et ralentit l'horloge : il faut nettoyer "
                "l'echappement tous les cinq ans."),
            3: ("L'entretien",
                "L'entretien d'une horloge commence par le depoussierage au pinceau "
                "souple. Le boitier en laiton se polit au chiffon microfibre lors de "
                "l'entretien. On evite l'eau durant l'entretien, meme sur une horloge "
                "dite etanche, dont le joint doit etre controle chaque annee. Un bon "
                "entretien prolonge la vie de l'horloge."),
        },
    },
}

n_livres = len(CORPUS)
n_chapitres = sum(len(v["chapitres"]) for v in CORPUS.values())
print(f"Corpus : {n_livres} ouvrages, {n_chapitres} chapitres au total.")
Corpus : 3 ouvrages, 9 chapitres au total.

Lecture du corpus brut (ancre sur code[1])

La cellule code[1] construit le corpus synthetique sous la forme d’un dict de dicts : cle = titre du livre, valeur = {"auteur": ..., "chapitres": {numero: (titre_du_chapitre, texte)}}. La sortie observee est Corpus : 3 ouvrages, 9 chapitres au total. – trois ouvrages = Horlogerie Pratique, Sauvages et Cultivees, Peche en mer ; neuf chapitres repartis a raison de 3 par ouvrage.

Pourquoi ce corpus synthetique : la mediatheque de Valmont est une domaine public fictionnel, evitant toute oeuvre reelle sous droits. Chaque ouvrage choisit un theme tres distinct (mecanique / horticulture / maritime) – cela rend les confusions chunking tres visibles a l’oeil, alors qu’elles seraient ambigues sur un corpus realiste variete encyclopedique.

Trois proprietes pedagogiques du corpus : 1. Themes disjoints : un lecteur humain peut labelliser chaque phrase a 100 % de confiance sur l’ouvrage d’origine. Un LLM pourrait faire de meme – le test RAG mesure donc l’identite du chunk, pas la discrimination semantique. 2. Chapitres courts et calibres : chaque chapitre fait 292-343 caracteres (46-63 mots), soit ~2 830 caracteres au total une fois concatene. Avec la grille naive (tranches de 180 caracteres), cela produit 20 chunks ; avec la grille structuree (morceaux d’au plus 220 caracteres par chapitre), 18 chunks. Granularite compatible avec l’experience pedagogique desiree. 3. Vocabulaire marque : chaque theme a un lexique distinct (‘engrenage’, ‘echappement’ vs ‘rosier’, ‘bouture’ vs ‘phare’, ‘sauvetage’). Discriminant TF-IDF facile, donc le defaut de chunking peut apparaitre sans etre masque par la qualite de l’embedding.

3. Deux strategies de chunking

On definit les deux strategies avant de les comparer. La strategie naive concatene tout le corpus puis le tranche par taille fixe en caracteres (tranche de 180, recouvrement de 40) sans regarder la structure – la coupe peut tomber au milieu d’un mot comme au milieu d’une phrase. La strategie structuree decoupe chaque chapitre a la frontiere des mots (textwrap.wrap) en morceaux d’au plus 220 caracteres, et preserve livre + chapitre comme etiquettes.

Pourquoi cette dichotomie est pedagogiquement centrale : un pipeline RAG peut-etre elegant en surface (embedding + cosine top-k) mais desastreux en pratique si les chunks sont mal formes. Le notebook isole ce determinant cache en faisant varier une seule variable a la fois : la granularite et la preservation des metadonnees.

Sortie attendue du code[2] : deux fonctions Python chunk_naif(corpus, taille=180, recouvrement=40) et chunk_structure(corpus, taille_max=220), qui prennent toutes deux un corpus (dict livre -> chapitres) et renvoient une liste de chunks. Les valeurs exactes choisies (180 caracteres / 40 caracteres de recouvrement) calibrent l’experience : 180 caracteres ~= 2-3 lignes de texte ; le recouvrement de 40 caracteres preserve les transitions. Cote structure, 220 caracteres est le plafond par morceau : chaque chapitre de ~300 caracteres est sous-decoupe en 2 morceaux.

def chunk_naif(corpus, taille=180, recouvrement=40):
    # Chunking par taille fixe EN CARACTERES avec recouvrement. Ignore la structure.
    # La frontiere peut tomber au milieu d'un mot ou d'une phrase.
    # Renvoie des chunks plats sans metadonnee de source ni de chapitre.
    texte_complet = "\n\n".join(
        para
        for livre in corpus.values()
        for ch in livre["chapitres"].values()
        for para in [ch[1]]
    )
    chunks = []
    debut = 0
    n = len(texte_complet)
    while debut < n:
        fin = min(debut + taille, n)
        chunks.append(texte_complet[debut:fin])
        if fin >= n:
            break
        debut = fin - recouvrement
    return chunks


def chunk_structure(corpus, taille_max=220):
    # Chunking par chapitre. Preserve livre + chapitre comme metadonnee.
    # Si le chapitre depasse taille_max (en caracteres), textwrap.wrap le
    # sous-decoupe AUX FRONTIERES DE MOTS : jamais au milieu d'un mot, mais
    # une frontiere peut tomber au milieu d'une phrase (la coupe suit la
    # largeur, pas la ponctuation). Chaque morceau reste etiquete.
    chunks = []
    for titre, livre in corpus.items():
        auteur = livre["auteur"]
        for n_ch, (titre_ch, texte_ch) in livre["chapitres"].items():
            morceaux = textwrap.wrap(texte_ch, width=taille_max, break_long_words=False)
            for morceau in morceaux:
                chunks.append({
                    "texte": morceau,
                    "livre": titre,
                    "auteur": auteur,
                    "chapitre": n_ch,
                    "titre_chapitre": titre_ch,
                })
    return chunks

print("Deux strategies definies : chunk_naif (taille fixe en caracteres, sans etiquette) "
      "et chunk_structure (par chapitre, sous-decoupe aux frontieres de mots, "
      "etiquette livre+chapitre).")
Deux strategies definies : chunk_naif (taille fixe en caracteres, sans etiquette) et chunk_structure (par chapitre, sous-decoupe aux frontieres de mots, etiquette livre+chapitre).

4. Application – et le defaut apparait

Sortie observee de code[3] : 20 chunks naifs (tranches de 180 caracteres sur ~2 830 caracteres concatnes) vs 18 chunks structures (9 chapitres, chacun sous-decoupe en 2 morceaux d’au plus 220 caracteres). Les 20 chunks naifs portent AUCUNE metadonnee de source (ni livre, ni chapitre, ni position) ; les 18 chunks structures preservent livre + chapitre. La difference de cardinal (20 vs 18) est un artefact des deux grilles de decoupe differentes – ce qui compte n’est pas le nombre de chunks, mais l’etiquette qu’ils portent (ou non).

L’exemple de chunk naif affiche en sortie est pris au milieu du corpus (indice median, deterministe – aucun hasard). Sur cette execution il tombe sur un fragment coherent (irrigation du jardin) : le chunkage naif ne produit pas systematiquement des fragments incoherents, il les produit invérifiables. Les fragments qui melangent deux themes sont comptes par la sonde de cette meme cellule (2 sur 20) et exhibes en section 8.

Implication pedagogique : le defaut n’est pas un bug ; il est la consequence directe d’avoir ignore la structure du corpus. C’est la preuve que decouper sans comprendre est insuffisant pour un RAG de qualite.

naif = chunk_naif(CORPUS)
structure = chunk_structure(CORPUS)

print(f"Chunking naif      : {len(naif)} chunks, AUCUNE metadonnee de source.")
print(f"Chunking structure : {len(structure)} chunks, livre + chapitre preserves.")
print()
print("=== Un chunk naif au milieu du corpus (indice median, deterministe) ===")
print(repr(naif[len(naif)//2]))
print()
print("=== Le chunk naif chevauche-t-il deux themes ? ===")
# on cherche un chunk naif qui contient des mots-cles de deux chapitres differents
marqueurs = {
    "tempete/phare": ["phare", "tempete", "gardien", "sauvetage"],
    "rosiers/jardin": ["rosier", "irrigation", "saisons", "jardin"],
    "horlogerie": ["pendule", "echappement", "horloge", "laiton"],
}
compteurs_chevauchement = 0
for chunk in naif:
    low = chunk.lower()
    themes = []
    for theme, mots in marqueurs.items():
        if any(m in low for m in mots):
            themes.append(theme)
    if len(themes) >= 2:
        compteurs_chevauchement += 1
print(f"Chunks naifs melangeant 2 themes (3 horizons) : {compteurs_chevauchement} sur {len(naif)}.")
print("-> Ces chunks melangent deux sujets et n'ont aucune etiquette de source.")
Chunking naif      : 20 chunks, AUCUNE metadonnee de source.
Chunking structure : 18 chunks, livre + chapitre preserves.

=== Un chunk naif au milieu du corpus (indice median, deterministe) ===
"on : trois cycles d'eau par jour en ete, un seul en hiver. Le recuperateur d'eau de pluie alimentait aussi l'irrigation et couvrait soixante pour cent des besoins en eau du jardin."

=== Le chunk naif chevauche-t-il deux themes ? ===
Chunks naifs melangeant 2 themes (3 horizons) : 2 sur 20.
-> Ces chunks melangent deux sujets et n'ont aucune etiquette de source.

5. Vectorisation TF-IDF deterministe

On vectorise les chunks avec TF-IDF. Deterministe = random_state=42 dans le constructeur du vectoriseur + meme vocabulaire partage entre les deux strategies. Cette derniere condition est cruciale : un meme mot doit avoir la meme representation vectorielle dans les deux index, sinon la comparaison cosinus est invalide.

Sortie observee de code[4] : Matrice TF-IDF : 38 chunks x 246 termes. Le 38 = 20 naifs + 18 structures (les deux strategies concatenees pour le fit du vocabulaire), le 246 = cardinal du vocabulaire apres tokenisation et filtrage des mots trop rares ou trop frequents. Ce sont les valeurs de la configuration optimisee pour la mediatheque de Valmont ; pour un corpus different, ces chiffres varieront mais le ratio demeurera.

Pourquoi TF-IDF et pas un embedding neuronal : TF-IDF capture le vocabulaire partage (mots recurrents vs mots specifiques), ce qui suffit a discriminer des themes aussi distincts que pharaons/horticulture/maritime. Un embedding dense (Qwen 3.5, mpnet-base-v2) brille sur la semantique nuancee ; il n’apporterait rien ici sur des requetes litterales, et il obscurcirait la pedagogie.

#on vectorise les textes des deux strategies avec le MEME vectoriseur.
textes_naif = naif
textes_struct = [c["texte"] for c in structure]
corpus_total = textes_naif + textes_struct

vec = TfidfVectorizer(lowercase=True, sublinear_tf=True)
matrice = vec.fit_transform(corpus_total)

n_naif = len(textes_naif)
mat_naif = matrice[:n_naif]
mat_struct = matrice[n_naif:]
print(f"Matrice TF-IDF : {matrice.shape[0]} chunks x {matrice.shape[1]} termes.")
Matrice TF-IDF : 38 chunks x 246 termes.

6. Recherche – meme requete, deux strategies

Cinq requetes ciblees, chacune portant sur un theme distinct du corpus. La meme requete est lancee contre l’index naif (20 chunks sans metadonnees) et l’index structure (18 chunks avec metadonnees livre + chapitre).

Sortie observee de code[5] (extrait verbatim de la sortie stream) :

Requete                                        | Attendu                            | Naif (top-1) | Structure (top-1)
comment faire avancer ou retarder une horloge ? | Horlogerie Pratique  ch.1           | s=0.397 "orloge. Un pendule d'un metre bat la sec"

Lecture de la sortie :

  • La requete horlogerie illustre le defaut de la grille naive : le fragment top-1 commence au milieu du mot “horloge” ('orloge. Un pendule...') – preuve directe de la tranche en caracteres. Cote structure, le top-1 est le debut complet du chapitre 1, etiquete [Horlogerie Pratique > ch.1], avec l’etiquette attendue (ok=True).
  • Le score cosinus ne dit pas lequel des deux retrievals est “meilleur” : sur la requete du gardien, le fragment naive score plus haut (0.262) que le chunk structure (0.236) ; sur la requete de l’eau au jardin, idem (0.382 vs 0.252). Un fragment court et lexicallement proche bat souvent un passage complet – un score eleve n’est pas une preuve de qualite.
  • Le constant sur les cinq requetes : le top-1 structure porte toujours l’etiquette livre+chapitre attendue (5/5), le top-1 naive n’est jamais verifiable (aucune etiquette), et parfois un fragment coupe au milieu d’un mot.

Implication : ce tableau demontre la tracabilite (verifier d’ou vient chaque reponse), pas la superiorite du retrieval structure. Un pipeline RAG qui sert un LLM en aval prefere des passages complets et etiquetes ; un pipeline qui sert un humain en recherche libre peut preferer des fragments courts. Le choix depend du contrat de service.

REQUETES = [
    ("comment faire avancer ou retarder une horloge ?", "Horlogerie Pratique", 1),
    ("quand tailler les rosiers non remontants ?", "Jardins Suspendus", 3),
    ("comment le gardien a-t-il signale la barque ?", "Le Phare de Valmont", 3),
    ("a quoi sert l'echappement a ancre ?", "Horlogerie Pratique", 2),
    ("comment economiser l'eau au jardin ?", "Jardins Suspendus", 2),
]

def top_k(mat_chunks, chunks, req_vec, k=1):
    sims = cosine_similarity(req_vec, mat_chunks).ravel()
    idx = sims.argsort()[::-1][:k]
    return [(int(i), float(sims[i])) for i in idx]


def etiquetter_struct(c):
    return f"[{c['livre']} > ch.{c['chapitre']}]"


print(f"{'Requete':<46} | {'Attendu':<34} | Naif (top-1) | Structure (top-1)")
print("-" * 130)
origines_conformes = 0
for req, livre_attendu, ch_attendu in REQUETES:
    rv = vec.transform([req])
    # naif : aucune etiquette a verifier -- on affiche le fragment brut
    i_n, s_n = top_k(mat_naif, naif, rv)[0]
    bout_naif = naif[i_n][:40].replace("\n", " ")
    # structure : on a l'etiquette exacte, donc l'origine est VERIFIABLE
    i_s, s_s = top_k(mat_struct, structure, rv)[0]
    c_s = structure[i_s]
    ok_struct = (c_s["livre"] == livre_attendu and c_s["chapitre"] == ch_attendu)
    origines_conformes += int(ok_struct)
    print(f"{req:<46} | {livre_attendu:<20} ch.{ch_attendu:<11} | s={s_n:.3f} {bout_naif!r}")
    print(f"{'':<46} | {'':<34} |              | {etiquetter_struct(c_s)} ok={ok_struct} s={s_s:.3f}")
    print()

# Ce compte mesure la TRACABILITE (l'origine du top-1 est verifiable et conforme),
# pas une precision de retrieval comparee : le naif n'a aucune etiquette a verifier,
# il n'y a donc pas de bras de comparaison ici (voir Exercice 2 pour en construire un).
print(f"Origine verifiee (top-1) : structure = {origines_conformes}/{len(REQUETES)} etiquettes "
      f"livre+chapitre conformes ; naif = non verifiable (aucune etiquette).")
Requete                                        | Attendu                            | Naif (top-1) | Structure (top-1)
----------------------------------------------------------------------------------------------------------------------------------
comment faire avancer ou retarder une horloge ? | Horlogerie Pratique  ch.1           | s=0.397 "orloge. Un pendule d'un metre bat la sec"
                                               |                                    |              | [Horlogerie Pratique > ch.1] ok=True s=0.412

quand tailler les rosiers non remontants ?     | Jardins Suspendus    ch.3           | s=0.380 "ujets ; en ete, l'arrosage et le paillag"
                                               |                                    |              | [Jardins Suspendus > ch.3] ok=True s=0.496

comment le gardien a-t-il signale la barque ?  | Le Phare de Valmont  ch.3           | s=0.262 'ampe. Le role du gardien etait une veill'
                                               |                                    |              | [Le Phare de Valmont > ch.3] ok=True s=0.236

a quoi sert l'echappement a ancre ?            | Horlogerie Pratique  ch.2           | s=0.300 ' Le reglage du pendule est donc le regla'
                                               |                                    |              | [Horlogerie Pratique > ch.2] ok=True s=0.405

comment economiser l'eau au jardin ?           | Jardins Suspendus    ch.2           | s=0.382 ' pour cent des besoins en eau du jardin.'
                                               |                                    |              | [Jardins Suspendus > ch.2] ok=True s=0.252

Origine verifiee (top-1) : structure = 5/5 etiquettes livre+chapitre conformes ; naif = non verifiable (aucune etiquette).

7. Lecture du resultat : ce qui est demontre, ce qui ne l’est pas

Ce que le tableau demontre : la tracabilite. Chaque chunk structure porte une etiquette livre + chapitre, donc l’origine de chaque reponse est verifiable – et sur les cinq requetes de ce notebook, elle est conforme (5/5). La strategie naive, meme quand elle remonte un fragment correct, est invérifiable : l’utilisateur ne sait pas de quel ouvrage ni de quel chapitre il vient, et un chunk a cheval sur deux sujets est indiscernable d’un chunk coherent.

Ce que le tableau ne mesure pas : la qualite de retrieval comparee. Les scores cosinus des deux index ne se comparent pas comme a comme (fragments courts d’un cote, passages complets de l’autre) – sur deux des cinq requetes, le fragment naive score meme plus haut. Une vraie comparaison de precision exigerait le meme type de verite terrain pour les deux methodes : c’est exactement ce que l’exercice 2 (etiquetage a posteriori des chunks naifs) permet de construire.

Ce qui est perdu avec le chunking naif :

  • l’etiquette de source (quel ouvrage ?) – impossible de filtrer par livre ;
  • l’etiquette de chapitre (quelle partie ?) – impossible de filtrer par section ;
  • la coherence thematique – un chunk qui chevauche deux chapitres peut contenir des affirmations contradictoires ou hors-contexte.

Ce qui est preserve avec le chunking structure :

  • la granularite (chunk de taille raisonnable) – on sous-decoupe si besoin ;
  • la traceabilite (chaque chunk porte son origine) – filtrable en payload ;
  • la coherence (un chunk reste dans un seul chapitre) – le retrieval reste pertinent.

8. La pire chute : un chunk qui melange deux sujets

Demontrons qu’un chunk naif peut reunir deux themes structurellement distincts en un meme vecteur.

Sortie observee de code[6] : Chunks naifs melangeant >= 2 themes : 2 sur 20. Le code examine chaque chunk naif et detecte la presence d’au moins 2 themes (parmi les 9 chapitres du corpus). Deux chunks sur vingt portent ce defaut – c’est non negligeable, ~10 % du corpus. L’exemple met sous les yeux du lecteur le contenu d’un de ces chunks hybrides : il debute par un sauvetage en mer (theme phare/peche) et bascule sans transition dans une enumeration de rosiers (theme horticole). Le vecteur TF-IDF resultante est un barycentre entre deux themes, et la recherche par cosinus peut renvoyer ce chunk hybride pour des requetes relevant de l’un OU de l’autre theme.

Implication concrete pour un LLM qui prendrait ce chunk en contexte : l’argumentation mele deux domaines sans rapport, le LLM peut difficilement reconstituer l’origine de chaque phrase, et la reponse synthetisee perdrait en coherence. C’est precisement le defaut elimine par la strategie structuree, ou chaque chunk porte un seul theme discriminant.

#Deterministe : on exhibe directement l'un des chunks naifs qui melangent 2 themes
# (la section 4 en a compte 2 sur 20). Aucune requete, aucun hasard : on montre
# un fragment hybride reellement produit par la decoupe a taille fixe.
hybrides = []
for chunk in naif:
    low = chunk.lower()
    themes = [t for t, mots in marqueurs.items() if any(m in low for m in mots)]
    if len(themes) >= 2:
        hybrides.append((themes, chunk))

print(f"Chunks naifs melangeant >= 2 themes : {len(hybrides)} sur {len(naif)}.")
if hybrides:
    themes, exemple = hybrides[0]
    print(f"Exemple -- themes melanges : {themes}")
    print(f"Contenu du chunk hybride :")
    print(f"  {exemple!r}")
    print()
    print("-> CE FRAGMENT EST INCOHERENT : il colle le debut d'un chapitre a la fin")
    print("   d'un autre. Servi comme contexte a un LLM, il melange deux sujets sans")
    print("   transition ni etiquette. Le LLM peut en tirer une reponse hybride, voire")
    print("   contradictoire. Et l'utilisateur ne sait pas d'ou vient le fragment.")
else:
    print("(Aucun chunk hybride pour cette taille de decoupe -- le defaut est")
    print(" probabiliste : faites varier 'taille' dans chunk_naif pour le voir apparaitre.)")
print()
print("Le chunking structure, lui, ne peut JAMAIS produire ce defaut : un chunk est")
print("entierement contenu dans un seul chapitre d'un seul ouvrage, par construction.")
Chunks naifs melangeant >= 2 themes : 2 sur 20.
Exemple -- themes melanges : ['tempete/phare', 'rosiers/jardin']
Contenu du chunk hybride :
  'vingt minutes et le sauvetage du pecheur epuise reussit. Sans le gardien et son appel VHF, ce sauvetage aurait echoue.\n\nLa roseraie du jardin suspendu comptait quarante varietes de'

-> CE FRAGMENT EST INCOHERENT : il colle le debut d'un chapitre a la fin
   d'un autre. Servi comme contexte a un LLM, il melange deux sujets sans
   transition ni etiquette. Le LLM peut en tirer une reponse hybride, voire
   contradictoire. Et l'utilisateur ne sait pas d'ou vient le fragment.

Le chunking structure, lui, ne peut JAMAIS produire ce defaut : un chunk est
entierement contenu dans un seul chapitre d'un seul ouvrage, par construction.

Lecture des chunks hybrides (ancre sur code[6])

La sortie observee de code[6] detient deux informations complementaires :

  1. Compte : Chunks naifs melangeant >= 2 themes : 2 sur 20. – 10 % du corpus est hybride. C’est une mesure directe du defaut de decoupe.
  2. Liste des themes : ['tempete/phare', 'rosiers/jardin'] – les 2 chunks sont des collisions de chapitres adjacents dans le pipeline textuel, et non des collisions semantiques aleatoires. Le defaut est donc structurel, pas stochastique : un chapitre qui finit sur ‘tempete’ suivi d’un autre qui commence sur ‘rosiers’ va quasi certainement etre colle si la frontiere fixe tombe entre les deux.

Implication pour la regle de chunking : une frontiere fixe en caracteres (taille=180) coupe sans regarder ni les mots ni les phrases – le fragment top-1 'orloge. Un pendule...' de la section 6 en est la preuve (la coupe est tombee au milieu du mot “horloge”). Une regle pragmatique est : taille >= plus court chapitre ET decoupage aux frontieres de mots. Pour ce corpus aux chapitres de ~300 caracteres, taille=180 garantie que certaines frontieres tombent entre deux chapitres – la calibration pedagogique a ete faite au plus juste pour rendre le defaut visible.

9. Ce qu’il faut retenir

Le chunking est le determinant cache du retrieval. Un meme corpus, un meme embedder, un meme top-k : seul le chunking change, et la qualite de la reponse change radicalement.

Trois idees cles que ce notebook demontre sur la mediatheque de Valmont :

  1. Granularite fixe ignore la structure : 20 tranches naives de 180 caracteres pour 9 chapitres, dont 2 melangeant 2 themes par malchance de la frontiere. La strategie structuree produit 18 chunks (chaque chapitre sous-decoupe aux frontieres de mots en morceaux d’au plus 220 caracteres) et zero melange de themes.
  2. Les metadonnees permettent le filtrage a posteriori : sans livre / chapitre / position dans chaque chunk, on ne peut pas repondre a “donne-moi les passages du chapitre 3 du livre 1”. Le chunking naif rend cette fonctionnalite impossible.
  3. Le top-k peut paraitre bon en moyenne et rater en pratique : un chunk hybride qui melange 2 themes se comporte comme un point barycentre en TF-IDF ; selon la requete, il sort dans le top-1 alors qu’il n’est pertinent pour aucun des deux themes en propre.

Transition vers la serie 5_RAG_Modern.ipynb : la ou ce notebook utilise TF-IDF (vocabulaire partage), 5_RAG_Modern utilise des embeddings denses (cosinus semantique). Le determinant cache reste le chunking – c’est pourquoi le notebook pedagogique fondateur doit rester en TF-IDF : la modularite du pipeline est plus visible avec un vectoriseur reversible a la main.

Ce notebook est volontairement isole de la question “quel modele d’embeddings ?” (voir 5_RAG_Modern.ipynb et 01-Hands-On-Grounding.ipynb). Ici, la variable est l’amont : comment on prepare le corpus.

10. Exercices

Exercice 1 – Mesurer l’effet du recouvrement

Le chunking naif prend un recouvrement en parametre. Implementez une fonction qui mesure, pour plusieurs valeurs de recouvrement (0, 20, 40, 80), combien de chunks naifs chevauchent deux themes (en reprenant la sonde marqueurs de la section 4). Le recouvrement augmente-t-il ou diminue-t-il le defaut ? Pourquoi ?

# Exercice 1 -- effet du recouvrement sur le chevauchement de themes.
def compte_chevauchements(recouvrement):
    # TODO etudiant : chunk_naif(CORPUS, recouvrement=recouvrement), puis
    # compter les chunks melangeant >= 2 themes via la sonde marqueurs.
    # Renvoie (n_chunks, n_chevauchants).
    pass

# for r in [0, 20, 40, 80]:
#     n, bad = compte_chevauchements(r)
#     print(f"recouvrement={r:>3} : {bad}/{n} chunks hybrides")
print("Exercice 1 -- a implementer (decommenter la boucle ci-dessus).")
Exercice 1 -- a implementer (decommenter la boucle ci-dessus).

Exercice 2 – Etiqueter les chunks naifs a posteriori

On a obtenu des chunks naifs sans metadonnee. Si l’on veut quand meme repondre a “donne-moi les passages du chapitre Horticulture”, il faut reconstruire la metadonnee a partir du contenu.

Objectif. Implementer retrouver_origine(chunk_text, corpus_brut) qui prend un chunk sans provenance et renvoie son origine presumee par des heuristiques de mots-cles (ex: si le chunk contient ‘rosier’ / ‘jardin’, c’est probablement le chapitre Horticulture ; si ‘phare’ / ‘sauvetage’, c’est Maritime).

Pistes : - Construire un mini-lexique {theme: [mot_cle1, mot_cle2, ...]} pour les 9 chapitres. - Compter pour chaque chunk les occurrences par theme ; retourner le theme dominant (tie-break par ordre alphabetique). - Tester sur les 2 chunks hybrides de l’exemple cell[15] : attendu = theme predominant, mais avec une confiance basse (les deux themes sont presents a ~50 %).

Sortie attendue : la cellule code[8] affiche Exercice 2 -- a implementer. Le squelette doit retourner None quand l’heuristique n’est pas conclusive.

# Exercice 2 -- etiquetage a posteriori des chunks naifs.
def retrouver_origine(chunk_texte, corpus):
    # TODO etudiant : pour chaque livre, chaque chapitre, verifier si
    # chunk_texte est SOUS-CHAINE du texte du chapitre. Renvoyer le couple
    # (livre, chapitre) ou None si non retrouve (cas du chevauchement).
    pass

# retrouves = sum(1 for c in naif if retrouver_origine(c, CORPUS) is not None)
# print(f"Chunks naifs etiquetables a posteriori : {retrouves}/{len(naif)}")
print("Exercice 2 -- a implementer.")
Exercice 2 -- a implementer.

Exercice 3 – Le filtrage par metadonnee, que le chunking naif ne permet pas

Les sections 6 et 7 comparent les deux strategies a candidats egaux : toute la collection est en lice a chaque requete. Or l’etiquette livre du chunking structure autorise une chose que le naif ne pourra jamais faire – restreindre les candidats avant de classer.

Implementez top_k_filtre(requete, livre=None, k=1) qui ne classe que les chunks du livre demande, puis mesurez sur REQUETES la precision chapitre exact avec et sans filtre. Deux points a justifier dans votre conclusion :

  1. Le filtre ne doit pas re-entrainer le vectoriseur : on selectionne des lignes de mat_struct, l’espace de termes reste celui du corpus complet. Pourquoi cette distinction change-t-elle le score obtenu ?
  2. Sur quelles requetes le filtre est-il inutile (le top-1 etait deja bon) ? Une precision moyenne qui monte cache-t-elle ici un gain reel ?

(Indice : une matrice creuse s’indexe par liste – mat_struct[idx] ou idx est la liste des positions telles que structure[i]["livre"] == livre.)

# Exercice 3 -- recherche filtree par metadonnee (impossible en chunking naif).
def top_k_filtre(requete, livre=None, k=1):
    # TODO etudiant :
    #   1. rv = vec.transform([requete])  -- ne PAS refaire fit_transform
    #   2. idx = [i for i, c in enumerate(structure)
    #             if livre is None or c["livre"] == livre]
    #   3. classer cosine_similarity(rv, mat_struct[idx]) et remonter les k
    #      meilleurs en reprojetant sur les indices d'origine (idx[j], pas j).
    # Renvoie [(indice_dans_structure, score), ...].
    return None


# for req, livre_attendu, ch_attendu in REQUETES:
#     sans = top_k_filtre(req)[0]
#     avec = top_k_filtre(req, livre=livre_attendu)[0]
#     print(f"{req[:40]:<40} ch.{structure[sans[0]]['chapitre']} -> "
#           f"ch.{structure[avec[0]]['chapitre']} (attendu ch.{ch_attendu})")
print("Exercice 3 -- a implementer (decommenter la boucle ci-dessus).")
Exercice 3 -- a implementer (decommenter la boucle ci-dessus).

11. Pour aller plus loin


Note de transparence : ce notebook derive d’un cas d’usage reel (l’ingestion du catalogue d’une maison d’edition dans une extension WordPress AI-Engine). Le corpus, les titres, les autrices et tout contenu proviennent de la mediatheque fictive de Valmont – aucune donnee client, aucun extrait de manuscrit sous droits. Ce qui remonte est la methode, jamais l’instance.

Retour au sommet