Accueil / Articles

RAG hybride pour les textes juridiques : segmentation en chunks, BM25, BGE et réclassement

Un pipeline RAG légal pour la Constitution de l’Inde : segmentation en blocs parent-enfant, récupération d’informations via BM25 et BGE, fusion des classements, réclassement à l’aide d’un encodeur croisé et expansion de la requête, testé sur des benchmarks.

Éditeur : REACTAPP.TOP20 min de lecture4590 mots

Sources: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)

Le texte juridique représente un défi important pour tout système de recherche. Prenons par exemple la Constitution de l’Inde : elle compte des centaines d’articles et de clauses, ainsi que des dispositions supplémentaires, des annexes, des amendements, des tableaux et un réseau de références croisées. Le passage qui répond à une question peut n’être qu’une phrase, mais sa lecture correcte peut dépendre de l’article qui l’entoure, d’une exception connexe ou d’une autre disposition à laquelle il fait référence. Un système utile doit donc trouver un équilibre entre précision et contexte, en localisant le passage exact sans le couper du texte qui lui confère son sens.

De nombreuses configurations de base pour la génération augmentée par récupération d’informations (RAG) suivent une méthode simple : extraire du texte à partir d’un PDF, le diviser en blocs de taille fixe, les intégrer et retourner les résultats les plus pertinents. C’est un bon point de départ, mais les systèmes en production ont généralement besoin de plus. Ils doivent conserver la structure du document, prendre en charge à la fois la recherche exacte et sémantique, réclasser les candidats, récupérer le contexte environnant, tracer chaque résultat jusqu’à sa source et vérifier si les preuves nécessaires ont bien été retrouvées.

Cet article décrit un projet de recherche qui reconstitue un flux de travail de type production permettant d’inspecter chaque étape individuellement, en utilisant la Constitution de l’Inde comme premier corpus, car sa structure met en évidence de nombreuses faiblesses de la recherche vectorielle classique. Il ne s’agit pas d’un produit final, mais d’une tentative d’étudier la manière dont les composants habituels d’un pipeline RAG de production s’assemblent, où ils échouent, et comment l’évaluation peut guider les décisions de conception ultérieures.

1. Ingestion : du PDF juridique au contenu structuré

Au préalable de toute segmentation, d’incorporation ou de récupération, le PDF source doit être transformé en un contenu que le logiciel puisse traiter de manière fiable. Un PDF enregistre l’apparence d’un document, mais pas nécessairement la manière dont un programme doit le lire. Les titres, les phrases, les notes de bas de page, les listes, les tableaux et les limites des pages peuvent sembler évidents pour un être humain, mais restent dans le texte extrait de base sous forme de segments non connectés. Si cette structure est perdue lors de l’ingestion, les étapes suivantes produiront des segments avec un ordre de lecture incorrect, des titres séparés ou des tableaux endommagés.

Le projet a utilisé MinerU pour convertir la Constitution en Markdown tout en conservant les informations importantes relatives à la mise en page. Ce outil a été choisi parce qu, selon la comparaison de l’auteur, il gérait les tableaux et les listes plus fiablement que les autres méthodes d’extraction testées. L’objectif n’était pas seulement de collecter chaque mot, mais aussi de préserver les relations entre eux. Le contenu extrait conservait :

  • la manière dont les titres sont imbriqués,
  • où commence et se termine chaque paragraphe et clause,
  • les listes à puces et numérotées,
  • les tableaux,
  • la page d’origine de chaque élément, et
  • les notes de bas de page ainsi que la structure qui les entoure.

Markdown convient à ce rôle en tant que format intermédiaire. Les gens peuvent le lire lors de l’inspection manuelle, et il contient des marqueurs structurels que Python peut traiter. Un titre tel que ## Droits fondamentaux peut ensuite guider la création de blocs parentaux, tandis que les listes et les tableaux peuvent être reconnus et traités comme des blocs distincts.

L’extraction a été effectuée dans Google Colab et non sur un ordinateur local. L’OCR et l’analyse du formatage peuvent être gourmands en ressources, surtout pour un document long rempli de tableaux et de formats complexes ; un ordinateur portable doté d’un matériel limité a eu du mal à gérer cette charge de travail. Colab offre un environnement isolé mieux adapté, avec des capacités de calcul plus puissantes, ce qui en fait une option intéressante pour cette étape si votre matériel local est modeste. La commande utilisée dans le notebook était :

!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en

2. Fragmentation

Avec une constitution écrite en Markdown structuré, la question suivante était de savoir comment la diviser pour en faciliter la récupération. La réponse courante est la fragmentation à taille fixe : on coupe après un nombre déterminé de caractères ou de tokens, souvent avec une certaine superposition. Cette méthode est simple et fonctionne de manière acceptable pour du texte en prose uniforme, mais les documents juridiques sont organisés selon leur sens et non selon leur longueur. Une limite fixe peut :

  • séparer un en-tête de l’article qui le suit,
  • couper une clause du dispositif qui la qualifie,
  • diviser une liste en deux au milieu de ses éléments,
  • transformer un tableau en fragments incomplets, et
  • regrouper des articles sans rapport simplement parce qu’ils tiennent dans une même fenêtre de token.

Des fragments plus gros conservent plus de contexte, mais rendent la récupération plus floue. Des fragments plus petits permettent des correspondances plus précises, mais des conditions environnantes importantes peuvent disparaître. Le projet a donc adopté une stratégie à deux niveaux :

Structured document
        ↓
Heading-aware parent chunks
        ↓
Semantic child chunks

Pourquoi deux niveaux sont utiles

La recherche s’effectue au niveau des enfants de petite taille, car leur contenu restreint fournit à BM25 des signaux de récupération plus nets. Une fois un enfant sélectionné, le parent_id qu’il contient renvoie vers la section parente plus grande :

Query
  ↓
Retrieve focused child
  ↓
Read parent_id
  ↓
Return complete parent context

Cela divise les responsabilités en deux. Les fragments enfants assurent la précision du retrait des informations, tandis que les fragments parents garantissent l’exhaustivité du contexte.

Fragments parents conscients des en-têtes

L’algorithme de segmentation en fragments parents s’appuie sur deux éléments générés lors de l’ingestion :

data/processed/cr.md
data/extracted/auto/blockpage.json

Le fichier Markdown contient le texte nettoyé ainsi que sa structure visible. Le fichier blockpage.json contient les blocs de mise en page ordonnés générés lors de l’extraction, y compris les informations relatives aux titres. Markdown reste la source principale du texte de base, tandis que le JSON sert de référence structurée ordonnée pour relier les titres Markdown au document extrait. L’implémentation se trouve dans un seul script :

code/chunking_parent.py

L'algorithme fonctionne dans un ordre fixe.

Étape 1 : charger les deux fichiers. Le script lit le Markdown nettoyé ainsi que le fichier JSON contenant les blocs de mise en page :

md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
    blockpage = json.load(file)

L’utilisation des deux éléments empêche le chunker de traiter le Markdown comme du texte simple et non structuré. Le Markdown fournit le contenu, tandis que le JSON aide à conserver l’ordre des titres trouvés lors de l’extraction.

Étape 2 : diviser le Markdown en blocs. Les lignes vides servent de séparateur :

for raw_block in md_text.split("\n\n"):

Chaque bloc est ensuite classé dans l’un de plusieurs types :

heading
paragraph
list
table
superscript
blank

Le texte qui commence par #, ## ou un autre niveau de titre est reconnu comme un titre :

HEADING_RE = re.compile(r"^(#{1,6})\s+")

Les listes, les tableaux et les blocs en surcharge disposent de leurs propres règles de détection, ce qui les maintient liés à la section à laquelle ils appartiennent.

Étape 3 : suivre le titre actif. Lorsqu’un titre apparaît, son texte est ajouté au heading_path actuel. Le script cherche également le titre correspondant extrait dans blockpage.json, en parcourant ce fichier JSON de manière séquentielle sans reprendre la recherche depuis le début à chaque fois :

def find_heading(heading_text: str):
    nonlocal search_pos
    for index in range(search_pos, len(blockpage)):
        block = blockpage[index]        if (
            block.get("type") == "text"
            and block.get("text") == heading_text
            and "text_level" in block
        ):
            search_pos = index + 1
            return block

Le balayage vers l’avant conserve uniquement les titres dans leur ordre initial et évite de tomber sur un titre identique antérieur.

Étape 4 : rassembler le contenu pertinent. Les paragraphes, listes, tableaux et blocs similaires sont ajoutés à la pile parentale actuelle. Lorsqu’un nouveau titre apparaît après que le texte principal a déjà été collecté, la pile parentale actuelle est fermée et une nouvelle en commence une. Conceptuellement, c’est ainsi que cela se présente :

Heading
  ├── Paragraph
  ├── Clause
  ├── List
  └── Table
        ↓
     Parent chunk

Étape 5 : attribuer des métadonnées stables. Chaque élément parent finalisé est stocké avec un ID séquentiel, un ID de document, le chemin du titre et le texte complet :

{
    "parent_id": f"parent_{len(chunks):04d}",
    "doc_id": DOC_ID,
    "heading_path": heading_path.copy(),
    "text": "\n\n".join(stack),
}

Un parent simplifié a cet aspect :

{
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}

Un parent isolé peut bénéficier de plusieurs dispositions connexes, par exemple :

PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund

Le processus a généré 333 blocs parents.

Blocs enfants sémantiques

Les blocs parents conservent le contexte intact, mais certains sont trop larges pour une correspondance précise ; c’est pourquoi les plus grands sont divisés en blocs enfants sémantiques plus petits. Ce travail est consigné dans un carnet :

code/semantic-children.ipynb

Les éléments courts, évalués selon le seuil approximatif de 400 tokens du carnet, sont conservés tels quels en tant qu’un seul enfant, car diviser quelque chose déjà suffisamment compact pour constituer une unité cohérente ne sert à rien. Tout élément plus long est traité par un outil de segmentation qui respecte la structure et compare le sens.

Étape 1 : diviser l’élément principal en blocs. Le texte d’origine est séparé en titres, paragraphes, listes et tableaux. Les tables HTML sont reconnues grâce à leur propre schéma :

table_pattern = re.compile(
    r"(<table[\s\S]*?</table>)",
    re.IGNORECASE
)

Le texte ordinaire est séparé par des lignes vides et classé à l’aide des titres Markdown et des marqueurs de liste, ce qui permet de gérer la prose, les listes et les tableaux séparément, sans supposer qu’une seule structure convienne à tous.

Étape 2 : associer les titres à leur contenu. Un titre ne doit jamais exister seul en tant qu’élément enfant. Le module de segmentation conserve temporairement chaque titre et le place en tête du bloc qui suit :

if pending_heading:
    block.text = pending_heading + "\n\n" + block.text
    pending_heading = None

Un enfant récupéré conserve ainsi l’intitulé qui identifie son sujet. Les blocs de liste consécutifs sont également regroupés avant comparaison sémantique, ce qui permet de préserver l’ordre logique des clauses et des éléments énumérés.

Étape 3 : comparer les paragraphes voisins. Pour les blocs de paragraphe adjacents, le carnet génère des embeddings BGE :

embedder = SentenceTransformer(
    "BAAI/bge-base-en-v1.5"
)

Le carnet de notes calcule ensuite la similarité cosinus entre chaque paragraphe et le précédent :

similarity = cosine_similarity(
    emb1,
    emb2
)[0][0]

Si deux blocs ont un sens suffisamment proche, ils peuvent rester dans le même enfant, et une baisse de similarité indique que le bloc suivant commence un nouveau sujet. Le seuil configuré est :

Similarity threshold: 0.40

Cependant, la similarité n’est qu’un seul critère ; le type et la taille du bloc sont également pris en compte.

Étape 4 : appliquer les règles de taille et de structure. Les paramètres par défaut du segmentateur sémantique sont :

Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters

Un bloc peut être ajouté au enfant actuel lorsque celui-ci est encore en deçà de la taille minimale souhaitée, lorsque les paragraphes voisins sont suffisamment similaires, lorsqu’un des blocs est très petit et ne devrait pas rester seul, ou lorsque des blocs consécutifs appartiennent à la même liste ou tableau. L’enfant combiné doit généralement rester dans les limites de 1 600 caractères. Un tableau ne sera pas ajouté à du texte ordinaire simplement pour atteindre la taille minimale :

if (
    current_type == BlockType.TABLE
    and block.type != BlockType.TABLE
) or (
    block.type == BlockType.TABLE
    and current_type != BlockType.TABLE
):
    is_under_min = False

Une liste trop longue peut être divisée à ses marqueurs d’éléments, tandis qu’un bloc unique de taille excessive qui n’est pas une liste reste une seule unité structurelle et n’est pas coupé de manière aléatoire. Le résultat n’est ni un découpage purement sémantique ni purement basé sur la taille. Il combine les deux :

Document structure
        +
Semantic similarity
        +
Minimum and maximum sizes

Étape 5 : conserver le lien parent. Chaque enfant généré reçoit son propre ID et conserve l’ID de son parent :

{
    "parent_id": parent["parent_id"],
    "child_id": f"{parent['parent_id']}_child_{index}",
    "doc_id": parent.get("doc_id", ""),
    "heading_path": parent.get("heading_path", []),
    "text": chunk["text"],
    "type": chunk.get("type", "mixed"),
    "embedding": encode_text(chunk["text"])
}

En utilisant le dossier Finance comme exemple pratique :

parent_0164
│
├── parent_0164_child_1
│   Article 264 and introductory Finance context
│
├── parent_0164_child_2
│   Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
    Article 267 concerning the Contingency Fund

Tous les trois enfants conservent :

{
  "parent_id": "parent_0164"
}

Un enfant stocké a cet aspect :

{
  "child_id": "parent_0164_child_2",
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "type": "list",
  "text": "265. Taxes not to be imposed save by authority of law...",
  "embedding": [0.012, -0.034, 0.021]
}

Finalement, le corpus contient 871 éléments enfants associés à 333 parents.

3. Recherche hybride, de la requête au contexte parent

Avec 871 éléments enfants en place, la question était de savoir comment trouver les preuves appropriées pour répondre à la requête d’un utilisateur. La recherche s’effectue sur des fragments d’éléments enfants et non sur les parents, car de courtes parties s’accordent plus facilement avec un article, une proposition ou une idée spécifique. Les parents plus larges ne sont récupérés qu’une fois que les éléments enfants les plus pertinents ont été identifiés.

Pourquoi une seule méthode ne suffit pas

Les questions juridiques prennent différentes formes. Une requête telle que « Que dit l’Article 23 ? » contient un numéro d’article qui figure également dans la source, ce qui rend très adaptée la recherche par mots-clés. Un utilisateur peut poser la même question différemment : « L’État peut-il exiger de quelqu’un qu’il accomplisse un service obligatoire ? » Cette formulation ne mentionne pas l’Article 23, donc le système doit relier « exiger de quelqu’un qu’il accomplisse un service » à l’idée de service obligatoire à des fins publiques. Ces deux cas exigent des capacités différentes :

  • une correspondance littérale, pour les numéros d’article, les expressions juridiques, les noms et les termes inhabituels, et
  • une correspondance basée sur le sens, lorsque la formulation de la question s’éloigne du texte.

Au lieu de choisir, le système fait passer le même corpus secondaire par deux méthodes distinctes : la recherche sparse BM25 et la recherche dense BGE.

La branche sparsa : BM25

BM25 classe les résultats en fonction des mots que la requête a en commun avec chaque enfant. Lors de l’indexation, les chemins des titres et le texte des enfants sont mis en minuscules et tokenisés, puis l’index indique quels enfants contiennent chaque terme, avec quelle fréquence il apparaît et à quel point il est rare dans le corpus. Trois facteurs sont importants : la fréquence d’apparition d’un terme de requête dans un enfant, le degré de rareté de ce terme dans le corpus, et la longueur de l’enfant par rapport à la moyenne. Les termes rares pèsent plus lourd que les mots courants, ce qui rend BM25 particulièrement adapté aux numéros d’articles et aux expressions juridiques spécifiques.

Une recherche pour Article 23 compulsory service, par exemple, favorise fortement les enfants contenant 23, compulsory et service. BM25 n’a pas besoin de comprendre le sens plus large, puisque la présence directe des termes constitue déjà un signal fort. Sa faiblesse réside dans le fait qu’un passage pertinent peut obtenir de mauvais scores lorsque la requête et le texte utilisent un vocabulaire différent.

La branche dense : BGE et FAISS

La branche dense effectue des recherches en fonction du sens et non seulement sur la correspondance de mots. Chaque enfant est représenté par son chemin de titre ainsi que par son texte :

embedding_text = heading_path + child_text

L’inclusion du titre ajoute un contexte, ce qui permet aux passages et aux requêtes associés de se trouver proches les uns des autres dans l’espace d’incorporation.

Sélection du modèle d’incorporation à l’aide d’un benchmark

La première version du projet utilisait nomic-ai/nomic-embed-text-v1.5, mais l’auteur souhaitait confirmer qu’il s’agissait du meilleur choix pour ce corpus. Un benchmark de récupération dense contrôlé a comparé quatre configurations : Nomic sans préfixes, Nomic avec les préfixes de requête et de document recommandés, BAAI/bge-base-en-v1.5 et intfloat/e5-base-v2.

Chaque modèle a recherché dans le même ensemble de 871 fragments enfants fixes, avec 383 questions d’évaluation qui avaient été mappées automatiquement.

  • MRR : accorde plus de points lorsque le premier fragment enfant pertinent se trouve plus près du haut.
  • Recall@10 et Recall@20 : fraction de questions pour lesquelles un enfant pertinent se trouve dans le top 10 ou 20.
  • All-recall@10 : part correspondant à chaque enfant pertinent présent dans le top 10.
  • Mean coverage@10 : part moyenne des enfants pertinents se trouvant dans le top 10, calculée sur l’ensemble des questions.
  • BGE a obtenu les meilleurs résultats en termes de MRR, Recall@10, Recall@20 et mean coverage@10. Le MRR est passé de 0,624 avec Nomic à 0,652, tandis que Recall@20 est monté de 0,877 à 0,911. Cela était important car la fusion et le réclassement ultérieurs utilisent un pool de candidats limité, et un enfant pertinent absent lors de la récupération dense ne peut pas être retrouvé par la suite.

    Nomic a obtenu un score légèrement plus élevé sur All-recall@10 (0,347 contre 0,334), ce qui signifie qu’il était légèrement plus performant pour récupérer tous les enfants étiquetés dans les questions concernant plusieurs enfants. Néanmoins, trouver au moins un enfant pertinent plus tôt comptait davantage pour restituer le contexte parent. La latence de requête était presque identique. Les embeddings des enfants stockés par Nomic ont donc été remplacés par des embeddings BGE, tout en conservant les frontières sémantiques existantes, de sorte que ce changement d’embeddings restait indépendant des choix de segmentation antérieurs.

    Les enfants sont intégrés sous forme de texte brut, sans préfixe d’instruction. En revanche, les requêtes reçoivent l’instruction de récupération recommandée par BGE :

    query_text = (
        "Represent this sentence for searching relevant passages: "
        + user_query
    )
    

    Le même modèle BGE intègre des requêtes et des enfants. Les vecteurs sont normalisés selon la norme L2, de sorte que le produit scalaire se comporte comme une similarité cosinus, avec des scores plus élevés indiquant une similarité plus grande.

    L’index FAISS

    Les vecteurs enfants normalisés sont intégrés dans un index FAISS Hierarchical Navigable Small World :

    index = faiss.IndexHNSWFlat(
        embedding_dimension,
        32,
        faiss.METRIC_INNER_PRODUCT,
    )
    
    index.hnsw.efConstruction = 200
    index.hnsw.efSearch = 64
    index.add(embeddings)
    

    HNSW organise les embeddings en un graphe navigable, de sorte que la recherche se déroule à travers des voisins prometteurs sans comparer la requête avec chaque vecteur. Une comparaison exhaustive resterait gérable pour 871 enfants de données. HNSW a été choisi afin que la structure continue de fonctionner lorsque de nouveaux textes législatifs, règlements, jugements et documents similaires rejoindront le corpus. Les enfants de données ont été ré-embedded à l’aide de code/reembed_children_bge.py, et l’index a été reconstruit avec code/rebuild_dense_index.py. La préparation des benchmarks, les définitions des modèles, les requêtes et les résultats se trouvent dans code/dense_benchmark/v2/.

    Combinaison des deux classements

    BM25 et la récupération dense génèrent deux listes de classement distinctes. Un enfant peut figurer en haut des deux listes, être fortement classé dans l’une seulement ou ne pas apparaître dans l’autre. Les scores bruts ne peuvent pas être simplement additionnés, car un score BM25 et une similarité vectorielle relèvent de échelles différentes. Ces deux classements sont fusionnés au moyen de la Fusion de rangs réciproques :

    RRF score = Σ 1 / (k + rank + 1)
    

    Le pipeline utilise k = 60. Chaque enfant gagne des points en fonction de sa position dans chaque liste, et non en fonction de son score initial. Un enfant qui se classe bien dans les deux branches reçoit des contributions des deux et progresse généralement, tandis qu’un enfant fort dans une seule branche peut encore rester dans l’ensemble des candidats. L’étape de fusion simplifiée :

    sparse_results = bm25_search(query)
    dense_results = dense_search(query)
    
    fused_candidates = reciprocal_rank_fusion(
        sparse_results,
        dense_results,
    )
    

    RRF crée un vaste pool de candidats. Il augmente les chances que des preuves utiles survivent, mais il ne compare pas l’ensemble de la requête avec chaque passage en détail. C’est là la tâche du reranker.

    Reranking par encodage croisé

    La recherche dense est peu coûteuse, car la requête et les éléments enfants sont encodés séparément, ce qui permet de calculer les embeddings des éléments enfants une seule fois et de les réutiliser pour chaque requête. Le prix à payer est que cet encodage indépendant limite la capacité du modèle à comparer précisément leurs mots les uns par rapport aux autres. Un encodage croisé emprunte une autre approche et reçoit à la fois la requête et un élément enfant candidat :

    pairs = [
        (query, child_text),
        ...
    ]
    
    scores = reranker.predict(pairs)
    

    Puisque la requête et le passage sont soumis en tant qu’entrée unique, le modèle peut examiner directement la relation entre leurs tokens, ce qui permet généralement de déterminer plus précisément leur pertinence par rapport aux résultats obtenus avec BM25 ou la recherche dense.

    candidates = fused_candidates[:30]
    reranked_children = cross_encoder.rank(query, candidates)
    

    Le réclassificateur évalue à nouveau chaque élément et trie à nouveau les candidats. Les positions précédentes de BM25, dense et RRF ne servent qu’à créer la liste de candidats, tandis que le score du codeur croisé détermine l’ordre final. Le système examine ensuite les 20 premiers éléments réclassés et conserve ceux qui obtiennent au moins 0,30 de score :

    selected_children = [
        child
        for child in reranked_children[:20]
        if child.score >= 0.30
    ]
    

    Cette limite représente une politique de filtrage spécifique à ce modèle et à cette chaîne de traitement. Elle ne doit pas être interprétée comme une probabilité universelle de pertinence, et le changement de réordonneur impliquerait de la revaluer et de la recalibrer. Les tableaux nécessitent une attention particulière, car certains dépassent la limite d’entrée de 512 tokens imposée par le réordonneur. Avant la tokenisation, le flux de travail identifie les paires de tableaux trop longues, afin qu’elles ne provoquent ni erreurs d’indexation ni disparaissent silencieusement.

    Inclusion des éléments enfants dans le contexte parent

    Le réordonneur continue de traiter les éléments enfants, car ils constituent des unités de comparaison ciblées. Cependant, ne retenir que ces éléments pourrait exclure les clauses, conditions, exceptions ou articles associés qui les entourent. Chaque élément enfant conserve donc le parent_id attribué lors du découpage :

    {
      "child_id": "parent_0082_child_1",
      "parent_id": "parent_0082",
      "text": "23. Prohibition of traffic in human beings and forced labour..."
    }
    

    Lorsque le filtrage par seuil est terminé, le système prend le parent_id de chaque enfant sélectionné et recherche l’ensemble du parent dans la collection parent-chunk :

    parent_id = selected_child["parent_id"]
    parent = parent_lookup[parent_id]
    

    Plusieurs enfants sélectionnés peuvent partager un même parent, et le renvoyer à plusieurs reprises entraînerait une duplication du contexte ; c’est pourquoi les enfants sont regroupés par parent_id. Chaque parent unique est renvoyé une seule fois, accompagné des enfants qui lui sont rattachés, et le score du meilleur enfant devient alors le score de ce parent source :

    Selected child 1 ──┐
    Selected child 2 ──┼── parent_0082
    Selected child 3 ──┘
    

    Un petit enfant contenant le langage pertinent de l’Article 23 pourrait faire appel à parent_0082, dont le parent fournira alors la section plus large intitulée « Droit contre l’exploitation », y compris les clauses adjacentes et le contenu connexe de l’Article 24. Cela permet de maintenir une séparation claire des responsabilités : les éléments provenant des enfants déterminent quels éléments de preuve sont pertinents, tandis que les éléments provenant des parents fournissent le contexte dans lequel ils apparaissent.

    La source parentale retournée conserve également son identifiant de document et le chemin de son en-tête, les identifiants des enfants correspondants, leurs types de blocs ainsi que leurs scores de réévaluation, ce qui permet de retracer la source retournée à travers tout le processus afin de comprendre pourquoi elle a été choisie. L’expérience de recherche hybride se trouve dans code/hybridrag.ipynb.

    4. Expansion des requêtes et questions à plusieurs étapes : combler le fossé lexical

    Le pipeline hybride fonctionnait bien lorsque les termes utilisés par l’utilisateur correspondaient étroitement à ceux de la Constitution. Une question directe comme « Qu’est-ce que l’Article 21 ? » contient une référence exacte, ce qui fournit un signal puissant à BM25 et aux méthodes de récupération dense.

    Les questions conceptuelles étaient plus difficiles. Les utilisateurs décrivent souvent un principe à l’aide de termes tirés de décisions judiciaires, de manuels ou de commentaires juridiques, et non de la langue propre de la Constitution. Une question concernant la « doctrine de la structure de base » peut nécessiter les Articles 13 et 368, même si l’expression « structure de base » n’apparaît dans aucun d’eux. En d’autres termes, l’utilisateur a la bonne idée juridique, mais le document l’exprime différemment.

    Ce que l’évaluation a montré

    Le problème a été testé dans un notebook Kaggle comprenant des questions directes ainsi qu’un ensemble plus difficile de questions conceptuelles correspondant à des articles constitutionnels attendus. L’expérience a eu lieu avant la migration vers BGE. À ce moment-là, les 871 enfants utilisaient des embeddings provenant de nomic-ai/nomic-embed-text-v1.5, et les requêtes étaient encodées avec le même modèle, sans préfixes de récupération. Ce détail est important car l’expérience visait à isoler l’effet du reformulage des requêtes, et non à évaluer les modèles d’embeddings ; par conséquent, le même pipeline hybride basé sur Nomic traitait les deux versions de chaque question conceptuelle.

    Une question n’est considérée comme validée que si le contexte parent récupéré contenait tous les articles cibles qui lui étaient associés ; par conséquent, le pourcentage indiqué mesure la couverture complète de la récupération des cibles et non la précision factuelle de toute réponse écrite par un LLM. La comparaison révélatrice concerne les deux ensembles conceptuels. L’expansion de la requête a porté le taux de réussite de la récupération de 56,0 % à 74,5 %, soit une augmentation de 18,5 points de pourcentage. Elle a permis de récupérer l’ensemble complet des cibles pour 37 questions supplémentaires et a réduit le nombre d’échecs de 88 à 51.

    Les résultats suggèrent que de nombreux échecs n’étaient pas dus à l’absence de documents ou à des limites de segments incorrectes. Les éléments nécessaires existaient dans le corpus, mais la requête initiale ne fournissait pas au système de recherche le vocabulaire requis pour les trouver.

    Expansion sans modification de l’intention

    L’expansion reformule une question conceptuelle en des termes que la source est plus susceptible de contenir. Supposons que la question demande ce qui empêche le Parlement de modifier l’identité essentielle de la Constitution. La version étendue pourrait faire appel à la doctrine de la structure de base, au noyau inmodifiable de la Constitution, aux lois en conflit avec les droits fondamentaux, à l’article 13, ainsi qu’au pouvoir de modification prévu à l’article 368. Ce que souhaite l’utilisateur n’a pas changé, mais le texte plus long ajoute des signaux de recherche tels que :

    • le nom commun de la doctrine,
    • la terminologie constitutionnelle associée,
    • des expressions alternatives de la même idée,
    • le langage institutionnel ou structurel pertinent, et
    • les numéros d’article explicites, lorsque ceux-ci peuvent être déduits avec certitude.

    Le même traitement portait sur la séparabilité, l’essence et le contenu, l’activisme judiciaire, ainsi que sur la relation entre les droits fondamentaux et les principes directeurs.

    Lors des essais sur Kaggle, les questions étendues avaient été rédigées à l’avance et des articles probables étaient même listés ; il s’agit donc d’une expérience contrôlée de reformulation de requête et non d’une évaluation d’un modèle d’expansion automatique. Cela montre dans quelle mesure la récupération d’informations peut s’améliorer lorsque la requête contient un langage aligné avec la source, mais un planificateur de requêtes en temps réel nécessiterait une évaluation distincte sans accès aux étiquettes cibles. Le benchmark ultérieur a remplacé Nomic par BGE parce que ce dernier performait mieux sur le corpus enfant fixe, mais les résultats de Kaggle ont néanmoins influencé la conception : un meilleur modèle d’embedding améliorait la récupération sémantique, tandis que l’expansion des requêtes traitait un problème différent, à savoir l’écart de vocabulaire entre l’utilisateur et le document.

    Expansion versus sous-requêtes

    L’expansion est utile lorsque un besoin d’information est formulé en termes peu familiers ou indirects, car elle permet de créer une requête plus riche. Les questions à plusieurs étapes sont différentes, car elles comprennent plusieurs besoins d’information qui peuvent faire référence à différentes parties du document. Prenons l’exemple d’une demande visant à comparer les remèdes prévus aux articles 32 et 226. Si l’on effectue une recherche en tant que seule requête longue, les signaux se contredisent : un résultat excellent pour l’article 32 peut dominer la liste des candidats, tandis que l’article 226 ne reçoit que peu de représentation, et un réclassement des candidats combinés ne garantit pas que les deux aspects soient pris en compte.

    Une question à plusieurs étapes peut plutôt être divisée en sous-requêtes ciblées :

    1. Quels remèdes et quelle compétence juridictionnelle prévoit l’article 32 ?
    2. Quels pouvoirs et quelle compétence juridictionnelle prévoit l’article 226 ?
    3. En quoi les articles 32 et 226 diffèrent-ils sur le plan constitutionnel ?

    Chaque sous-requête parcourt l’ensemble du processus de récupération (BM25 et recherche dense, puis fusion des classements, ensuite réclassement via l’encodeur croisé, enfin expansion des éléments parentaux), et les éléments parentaux résultants sont fusionnés et dédoublonnés pour former des preuves combinées. Cela empêche qu’une partie d’une question complexe ne consomme la capacité destinée à une autre, et cela facilite l’inspection de la récupération, puisque chaque branche correspond à un besoin spécifique.

    La stratégie de routage obtenue

    L’évaluation a montré qu’aucune transformation de requête unique ne convient à toutes les questions. Les questions directes possèdent déjà des signaux forts et devraient rester telles quelles. Les questions conceptuelles bénéficient d’une expansion du vocabulaire, tandis que les questions à plusieurs étapes nécessitent une segmentation afin que chaque exigence dispose de son propre branche :

    • Questions factuelles directes ou spécifiques à un article : effectuer la recherche avec la requête originale.
  • Questions conceptuelles présentant un déséquilibre de vocabulaire : les transformer en une seule requête alignée sur la source.
  • Questions multi-étapes, comparatives ou portant sur plusieurs articles : les diviser en sous-requêtes ciblées et exécuter chaque branche séparément.
  • Cette méthode d’acheminement évite de modifier inutilement les requêtes simples tout en fournissant aux requêtes complexes la structure dont elles ont besoin. L’évaluation initiale de Kaggle est implémentée dans code/hybrid_eval.ipynb, tandis que les expériences ultérieures d’extension et de décomposition apparaissent dans code/hybridrag.ipynb.

    Leçons tirées du projet

    La leçon principale est que des systèmes RAG de meilleure qualité ne dépendent rarement d’un seul modèle parfait. La qualité de la récupération provient de décisions interconnectées concernant l’extraction, le découpage en blocs, les embeddings, la combinaison des candidats, le réclassement et la récupération du contexte. L’évaluation a modifié plusieurs choix au fil du processus : le benchmark dense a remplacé Nomic par BGE tout en conservant les frontières sémantiques, l’expérience sur Kaggle a montré que les questions conceptuelles bénéficient d’une terminologie plus proche de celle de la source, et les échecs liés à plusieurs étapes ont indiqué la nécessité d’utiliser des sous-requêtes ciblées ainsi que des branches de récupération indépendantes.

    Les étapes suivantes naturelles consistent à intégrer ce pipeline dans une application de chatbot dotée d’un backend structuré et d’une interface pour poser des questions et examiner les sources, puis à le packager avec Docker et à le déployer sur une plateforme cloud, où le chargement des modèles, l’utilisation de la mémoire, la latence, les index persistants, le journalisation, l’échelle et les coûts deviennent autant de problématiques à prendre en compte. Le système est encore en évolution et certains défauts restent non résolus, ce qui correspond justement à l’objectif de cet exercice : maintenir chaque étape inspectable, identifier où les preuves se perdent et laisser l’évaluation guider les prochaines améliorations.