Accueil / Articles

Le découpage parent-enfant et l’indexation hiérarchique pour une meilleure récupération RAG.

Pourquoi de petits blocs remportent la partie mais échouent à fournir la réponse, et comment le découpage parent-enfant, l’expansion par suppression des doublons et l’indexation hiérarchique restituent le contexte dans la récupération RAG.

Éditeur : REACTAPP.TOP10 min de lecture2363 mots

Sources: Parent–Child Chunking and Hierarchical Indexing in RAG: A Practical Guide to Better Retrieval

Lorsqu’un système RAG fournit une réponse presque correcte mais manquant du détail crucial, le point faible réside généralement dans la manière dont le texte a été divisé, et non dans les embeddings. Deux techniques traitent directement ce problème : le chunking parent-enfant, qui examine de petits fragments mais lit de grands ensembles, et l’indexation hiérarchique, qui restreint la recherche en suivant la structure propre du document. Ces deux méthodes visent à fournir à un modèle de langage à la fois précision et contexte.

Pourquoi la segmentation est l’étape difficile

Le processus de base est simple : diviser les documents en fragments, les intégrer, les stocker, récupérer les k meilleurs résultats pour une requête et les transmettre à un modèle. Lorsqu’un problème survient, il provient généralement de la toute première étape. Les fragments trop grands affaiblissent la pertinence et altèrent les embeddings, tandis que les fragments trop petits en excluent les phrases environnantes dont dépend une question. La segmentation à taille fixe, récursive, sémantique ou par fenêtre de phrase sont autant de tentatives pour équilibrer cette tension au sein d’une même unité de texte.

Une autre approche mérite d’être explorée : cesser de chercher une taille parfaite et utiliser deux tailles en même temps, chacune adaptée à une tâche différente. Avant d’introduire cette modification, il est utile de comprendre précisément où échoue une seule taille.

Un petit exemple d’échec

Considérons un passage de deux phrases tiré d’un document technique. La première phrase indique que le cache rend un système beaucoup plus rapide. La seconde avertit que l’invalidation insuffisante du cache peut laisser des données obsolètes.

Un séparateur de petite taille pourrait le diviser en deux parties. La partie A correspond à la phrase vantant les performances, et la partie B à la phrase concernant les données obsolètes dues à une invalidation déficiente. Maintenant, demandons-nous : quel est le risque lié à l’utilisation du cache ? Le script ci-dessous intègre cette question ainsi que les deux parties, puis les compare par similarité cosinus.

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("all-MiniLM-L6-v2")

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

query = "What is a risk of caching?"
chunk_a = "Caching improves system performance significantly."
chunk_b = "However, improper cache invalidation can lead to stale data issues."

query_embedding = model.encode(query)
score_a = cosine_similarity(query_embedding, model.encode(chunk_a))
score_b = cosine_similarity(query_embedding, model.encode(chunk_b))

print(f"Chunk A similarity: {score_a:.4f}  - '{chunk_a}'")
print(f"Chunk B similarity: {score_b:.4f}  - '{chunk_b}'")

Lorsqu’on le lance, il affiche le degré de similarité de chaque morceau avec la question.

Chunk A similarity: 0.5891  — 'Caching improves system performance significantly.'
Chunk B similarity: 0.5103  — 'However, improper cache invalidation can lead to stale data issues.'

Le bloc A obtient un score plus élevé, d’environ 0,59 contre environ 0,51, bien que ce soit le bloc B qui réponde à la question. Les deux mentionnent le stockage en cache, ce qui les fait paraître liés, et le modèle d’incorporation n’a aucune raison particulière de relier le mot « risque » à une phrase sur des données obsolètes qui n’utilise jamais ce mot. Un système de récupération qui ne prend que la meilleure correspondance transmettrait avec assurance la mauvaise partie du texte au modèle.

La leçon est que la similarité sémantique ne garantit pas la complétude. Un bloc peut être jugé pertinent pour le sujet sans pour autant contenir l’information nécessaire à la question, et plus le bloc est petit, plus il est probable que cette information se trouve dans une phrase voisine.

Chunking parent-enfant

L’idée est de stocker en même temps deux niveaux de détail du même texte, chacun remplissant une fonction différente.

Les fragments pour enfants sont de petite taille et précis. Ils comprennent généralement de 100 à 400 tokens et sont conçus pour des recherches par embedding offrant un taux de rappel élevé. Leur petite taille permet d’obtenir des vecteurs plus nets, car moins de matériel non pertinent dilue ce que chaque vecteur représente. Cependant, réduire leur taille à quelques mots se révèle contre-productif, car un fragment peut manquer du sens autonome nécessaire pour assurer une correspondance fiable.

Les blocs parents sont volumineux et contextuels. Ils couvrent souvent de 800 à 3000 tokens ou plus, englobant toute une section ou unité logique, et servent à la réflexion plutôt qu’à la correspondance. Dans l’exemple de mise en cache, un bloc parent contenant le texte intégral inclut les deux parties de l’idée ; ainsi, quel que soit le bloc enfant qui correspond, le modèle perçoit toujours l’ensemble du contexte. La limite supérieure est déterminée par la fenêtre de contexte du modèle, car plusieurs blocs parents importants regroupés dans une même demande peuvent éclipser tout le reste que cette demande doit contenir.

La relation peut se résumer en une phrase : les blocs enfants sont ceux qui sont recherchés, tandis que les blocs parents sont ceux qui sont lus. Un bloc enfant doit être suffisamment précis pour remporter le concours de similarité, et son bloc parent assure que le modèle reçoive le contexte dont dépend sa réponse.

Comment cela se passe en pratique

Imaginez une section intitulée « Optimisation des performances de la base de données ». Un séparateur parent-enfants fonctionne en deux étapes. D’abord, il identifie la limite du parent, c’est-à-dire toute la section consacrée à l’indexation, à l’optimisation des requêtes, au cache et aux compromis associés. Ensuite, il divise ce même texte en petits éléments enfants à l’intérieur, par exemple :

  • un élément enfant sur l’indexation qui améliore la vitesse des requêtes en réduisant le temps de balayage,
  • un élément enfant sur le cache qui diminue les calculs répétés, au risque de données obsolètes,
  • un élément enfant sur la réécriture des requêtes SQL aux fins d’optimisation.

Ce qui permet à ce schéma de fonctionner, c’est que chaque élément enfant se souvient de son origine. La méthode la plus simple consiste à stocker le texte complet du parent dans les métadonnées de chaque élément enfant, comme dans cette fonction d’indexation.

def build_parent_child_index(document_section, parent_text, child_splitter, embedding_model):
    child_chunks = child_splitter(parent_text)
    records = []
    for child_text in child_chunks:
        records.append({
            "text": child_text,
            "embedding": embedding_model.encode(child_text),
            "parent_text": parent_text  # full parent stored directly on every child
        })
    return records


parent_text = """Indexing improves query speed by reducing scan time across large tables.
Caching reduces repeated computation but may introduce staleness if not invalidated properly.
Query optimization involves rewriting SQL queries to use more efficient execution plans."""

child_texts = [
    "Indexing improves query speed by reducing scan time across large tables.",
    "Caching reduces repeated computation but may introduce staleness if not invalidated properly.",
    "Query optimization involves rewriting SQL queries to use more efficient execution plans.",
]

records = [
    {"text": t, "parent_text": parent_text} for t in child_texts
]

for r in records:
    print(f"Child: {r['text'][:50]}...")
    print(f"  → linked to parent ({len(r['parent_text'])} chars)\n")

Dupliquer le parent dans chaque enfant entraîne des coûts de stockage supplémentaires, mais n’exige aucune recherche distincte au moment de la requête, car le parent se trouve déjà dans le enregistrement de l’enfant. L’alternative consiste à ne stocker que un parent_id et à récupérer le parent lorsque c’est nécessaire, ce qui permet d’économiser de l’espace à grande échelle mais ajoute une étape de recherche. Dans les deux cas, chaque enfant reste recherchable de manière indépendante, comme n’importe quel autre élément, tout en disposant d’un chemin direct vers son contexte complet.

Récupérer petit, étendre grand

Cela inverse le flux habituel. Le RAG standard suit la séquence : récupérer, envoyer, répondre. La version parent-enfant suit plutôt la séquence : récupérer un enfant, l’étendre jusqu’à son parent, envoyer le contexte enrichi, répondre. Voici cette séquence mise en œuvre, en poursuivant l’exemple de mise en cache.

def parent_child_retrieve(query, child_records, embedding_model, top_k=3):
    query_embedding = embedding_model.encode(query)
    scored = [
        (np.dot(query_embedding, r["embedding"]) /
         (np.linalg.norm(query_embedding) * np.linalg.norm(r["embedding"])), r)
        for r in child_records
    ]
    scored.sort(key=lambda x: x[0], reverse=True)
    top_children = scored[:top_k]

    # Expand each matched child to its parent - but deduplicate first
    seen_parents = set()
    expanded_context = []

    for score, record in top_children:
        parent = record["parent_text"]
        if parent not in seen_parents:
            expanded_context.append(parent)
            seen_parents.add(parent)

     return expanded_context

for r in records:
    r["embedding"] = model.encode(r["text"])

context = parent_child_retrieve("What is a risk of caching?", records, model, top_k=2)
print("Context sent to the LLM:\n")

for c in context:
    print(c)

Faites attention à la vérification seen_parents. Lorsque les deux premiers enfants appartiennent au même parent, ce qui est fréquent car des segments voisins d’une même section ont souvent des scores similaires, l’expansion de chacun d’eux séparément envoyerait deux fois le même texte de parent et gaspillerait du contexte. La suppression des doublons au niveau du parent fait en sorte que chaque fragment de contexte apparaisse une seule fois, quel que soit le nombre d’enfants correspondants.

Pour la partie de mise en cache, le modèle reçoit désormais la phrase qui a remporté la correspondance ainsi que l’explication qui l’entoure, de sorte que les deux parties arrivent ensemble, quel que soit l’enfant qui a obtenu la première place.

Pourquoi cela fonctionne

  • Précision avec contexte. Les enfants trouvent la bonne place, et les parents fournissent ce qu’un seul enfant ne peut pas transmettre.
  • Mieux raisonnement. Les modèles ont du mal avec des fragments désconnectés, et un parent restaure le fil narratif.
  • Moins de détails inventés. En tenant compte des contraintes et des explications environnantes, le modèle a moins de raisons de combler les lacunes par des suppositions.
  • Matériel technique. Les références API, les documents SDK, les wikis techniques, les articles scientifiques et les textes juridiques ne se résument rarement à une seule phrase, et c’est souvent l’exception ou la condition apparue deux phrases plus tard qui est importante.
  • Indexation hiérarchique

    Si la relation parent-enfant est de deux niveaux, l’indexation hiérarchique en comporte de nombreux. Au lieu d’un parent et de ses enfants, on crée une structure qui reflète l’organisation du document. Une hiérarchie de exemple pourrait ressembler à ceci.

    Document: "Cloud Architecture Guide"
      Section: Networking
        Subsection: Load Balancing
          Chunk: Round-robin method
          Chunk: Least connections method
        Subsection: CDN usage
      Section: Security
        Subsection: IAM policies
        Subsection: Encryption
    

    Notez d’où provient cette hiérarchie. Certains systèmes classent les éléments par catégorie, telle que le département, le sujet ou des étiquettes de métadonnées attribuées indépendamment de tout document. Ici, la hiérarchie provient des titres et sections du document lui-même, de la manière dont son auteur l’a organisé. Les deux approches restreignent l’espace de recherche avant la recherche elle-même, mais l’une organise toute une base de connaissances par sujet tandis que l’autre organise un seul document à l’aide de son sommaire.

    La plupart des documents ne sont pas de simples textes. Les titres et sous-sections codent déjà la manière dont les idées sont liées. L’indexation hiérarchique conserve cette structure et permet au système de trouver d’abord la bonne section, puis la sous-section, enfin le fragment exact, un peu comme lorsqu’on parcourt un sommaire avant de lire attentivement.

    Récupération en pratique

    Supposons que la requête soit « Comment fonctionne le équilibrage de charge par le moins de connexions ? ». Le système trouve la section Réseaux, se concentre sur la sous-section Équilibrage de charge, puis récupère le fragment qui explique que la méthode du moins de connexions choisit le serveur ayant le moins de connexions actives.

    Chaque étape de concentration nécessite un mécanisme réel, car rien de tout cela ne se produit automatiquement. Les options incluent un classificateur entraîné qui prédit à quelle section appartient une requête, une appel à un LLM qui lit la requête et sélectionne la section la plus probable à partir d’une liste connue, ou une correspondance basée sur des règles utilisant les titres des sections et des mots-clés. Celui qui est choisi est exécuté à chaque niveau, une fois pour la section et une fois de plus pour la sous-section, avant que la recherche au niveau des fragments ne commence.

    La profondeur supplémentaire en vaut-elle la peine ? Deux niveaux suffisent pour la plupart des documents. Une hiérarchie à plusieurs niveaux s’avère utile pour des documents longs et fortement structurés, où une section contient suffisamment de sous-thèmes distincts pour que rechercher tout le contenu en même temps affaiblisse la pertinence des résultats. Une page FAQ courte ou un article sur un seul sujet n’a généralement pas besoin de plus de deux niveaux, tandis qu’un guide d’architecture de cent pages avec des dizaines de sous-sections en peut avoir besoin. Comme pour toute décision d’indexation, évaluez avant d’ajouter de la complexité.

    Conseils rapides de mise en forme

    Commencez par des tailles d’enfants situées vers le milieu de la fourchette, autour de 200 tokens, et vérifiez le taux de rappel sur un petit nombre de questions réelles avant de modifier quoi que ce soit. Enregistrez quels parents sont étendus pour chaque requête afin de repérer les sections toujours incluses et qui pourraient être trop larges. Limitez le nombre de parents envoyés au modèle, car trois ou quatre sections importantes peuvent déjà remplir une fenêtre de contexte modeste, et conservez le texte d’origine des enfants pour permettre des citations, de sorte que les utilisateurs puissent voir exactement quelle phrase correspond.

    Comparaison des deux méthodes

    • Portée : la méthode parent-enfant fonctionne sur deux niveaux de granularité du même texte, tandis que l’indexation hiérarchique modélise l’ensemble de la structure.
    • Avantage principal : la méthode parent-enfant préserve le contexte autour de la correspondance, tandis que l’approche hiérarchique restreint la zone de recherche au point de départ.
    • Coût : la relation parent-enfant nécessite des liens entre les enfants et les parents, tandis que la hiérarchie exige une détection de la structure ainsi qu’une étape de routage à chaque niveau.
    • Adéquation : la relation parent-enfant convient le mieux aux documents longs, tandis que la hiérarchie est adaptée aux sources volumineuses et fortement structurées.

    Elles se combinent bien, car une hiérarchie peut se terminer par des blocs de type parent-enfant en bas de la structure.

    Un pipeline prêt à être déployé

    1. Ingestion. Parser les documents et détecter les titres. Le chargeur doit conserver les informations relatives aux titres, c’est-à-dire déterminer quel texte appartient à quelle section, plutôt que de fusionner tout cela en un seul flux, car les étapes suivantes en dépendent.
    2. Division en blocs. Créer des blocs parents à partir des sections complètes, puis diviser chacun d’eux en blocs enfants, en identifiant d’abord la frontière du parent.
    3. Indexation. Intégrer les éléments enfants et stocker les liens des éléments parents, soit en copiant le texte du parent dans les métadonnées de chaque enfant, soit en stockant un parent_id pour permettre des recherches.
    4. Requête. Intégrer la question, rechercher parmi les éléments enfants, sélectionner les résultats les plus pertinents, récupérer leurs éléments parents en éliminant les doublons, assembler le contexte enrichi et l’envoyer au modèle.

    Erreurs à éviter

    • Seuls les éléments enfants. Vous gagnez en précision mais perdez le contexte de raisonnement, ce qui explique l’échec dans l’exemple de mise en cache, où le bloc B ne reçoit jamais la phrase qui complète la réponse.
    • Seuls les éléments parents. Vous conservez le contexte mais perdez en précision de récupération, car l’incorporation d’une section à plusieurs sujets en un seul vecteur brouille les informations de la même manière qu’un bloc trop volumineux.
    • Ignorance de la structure. Si le chargeur élimine les en-têtes, il ne reste plus de délimitation de section que le routage hiérarchique puisse utiliser, quel que soit le niveau de qualité du routeur.
    • Metadata insuffisants. En l’absence de liens parentaux explicites tels que parent_text ou parent_id, l’expansion devient impossible par la suite. Les métadonnées doivent être capturées au moment de la création du chunk, car elles ne peuvent être récupérées une fois éliminées.

    Où cela est le plus utile

    Ces approches se révèlent particulièrement efficaces dans les documents longs, structurés et interconnectés : bases de connaissances d’entreprise, documentation interne, bots d’assistance, archives juridiques et de conformité, wikis d’ingénierie et assistants de recherche. Dans de tels documents, aucune taille de chunk unique ne peut répondre à la fois à la précision et au contexte.

    RAG évolue d’une simple recherche suivie de génération vers un modèle qui examine d’abord des connaissances organisées avant de raisonner sur ce qu’il a trouvé. Le découpage en blocs parent-enfant améliore la compréhension locale, de sorte que, là où se trouve une correspondance, l’explication correspondante est également fournie. L’indexation hiérarchique améliore la navigation globale, permettant à la recherche de commencer dans le bon contexte plutôt que d’explorer tout le contenu. Le découpage plat ressemble à l’arrachage de pages d’un livre en comptant sur le modèle pour deviner l’intrigue. Ces deux techniques conservent le livre relié tout en permettant de trouver une ligne précise. En pratique, cela signifie moins de réponses presque correctes, ce qui est l’échec qui frustre le plus les utilisateurs, ainsi qu’une couche de récupération dont le comportement est plus facile à expliquer lorsque quelqu’un demande pourquoi un passage particulier est parvenu au modèle.