Aller au contenu
NET&PRO...
Tutoriels

Construire un RAG qui répond juste : le pas-à-pas

Découpage, recherche hybride, reclassement, jeu d'évaluation : les étapes qui décident vraiment de la justesse d'un RAG, avec le code et les ratés.

Série « Un assistant IA dans la PME, de l'idée au service » · épisode 2

Illustration isométrique d'un meuble à fiches de bibliothèque aux tiroirs ouverts, une fiche soulevée au-dessus de l'un d'eux

Construire un RAG qui fonctionne prend une après-midi. Construire un RAG qui répond juste prend beaucoup plus longtemps, et le travail n'est presque jamais là où on le cherche. Ce pas-à-pas suit l'ordre réel des causes : le découpage d'abord, la recherche ensuite, le modèle en dernier. Avec du code exécutable et les endroits où ça casse.

Étape 0 : lire l'échec avant de le corriger

Votre système répond à côté. Avant de changer quoi que ce soit, ouvrez ce qu'il a réellement envoyé au modèle.

for i, passage in enumerate(contexte):
    print(f"--- {i} | {passage['source']} | score {passage['score']:.3f}")
    print(passage["texte"][:300])

Trois lectures possibles, et une seule mène au modèle.

Le bon passage n'est pas dans le contexte. C'est le cas le plus fréquent. La réponse existe dans vos documents mais la recherche ne l'a pas remontée. Rien de ce que vous ferez côté prompt ou côté modèle n'y changera quoi que ce soit.

Le bon passage est là, mais tronqué. Le seuil de la réponse est coupé au milieu, ou la phrase qui donne le sujet est dans le morceau précédent. Le modèle voit « le taux est de 4,5 % » sans savoir de quel taux il s'agit. Problème de découpage.

Le bon passage est là, entier, et le modèle répond quand même de travers. Là seulement, le modèle est en cause. Sur nos propres mesures, ce cas représente une minorité des échecs. Il ressemble d'ailleurs beaucoup au mécanisme décrit dans pourquoi l'IA invente : un modèle qui n'a pas l'information produit quand même une phrase plausible, et rien dans sa sortie ne signale la différence.

Notez la répartition sur vingt échecs réels : elle décide de votre ordre de travail.

Des bandes de papier crème déchirées en morceaux inégaux, certaines très courtes, posées sur une surface grise.

Étape 1 : découper les documents, la variable dominante

Le découpage définit les unités que votre système peut retrouver. Si la réponse ne tient dans aucun morceau, aucun embedding, aucune recherche hybride et aucun reclassement ne la fera apparaître.

Un exemple de mauvais découpage, à 200 caractères sans recouvrement, sur un contrat :

[chunk 41] ...Le prestataire s'engage à intervenir dans un délai
maximum de quatre heures ouvrées à compter de la déclaration
d'incident critique. Pour les incidents
[chunk 42] de niveau 2, ce délai est porté à deux jours ouvrés.
Les pénalités prévues à l'article 9 s'appliquent...

Question posée : « quel est le délai d'intervention sur un incident critique ? ». La recherche remonte le chunk 42, qui parle de délai et de niveau, et le modèle répond « deux jours ouvrés ». La réponse est fausse, le système n'a rien halluciné, il a lu ce qu'on lui a donné.

Trois règles, dans cet ordre d'importance.

Respectez la structure avant de compter les caractères. Découpez d'abord aux titres, aux articles, aux cellules de tableau. Un découpage à taille fixe qui ignore les ## d'un fichier Markdown produit systématiquement des morceaux orphelins.

Recollez le titre parent dans chaque morceau. Deux lignes de contexte en tête de chunk coûtent quelques jetons et sauvent le rappel : le morceau « ce délai est porté à deux jours » devient trouvable quand il commence par « Contrat cadre > Article 7 > Niveaux de service ».

Taille et recouvrement viennent après. Point de départ raisonnable : 500 à 800 jetons par morceau, 10 à 20 % de recouvrement. Ces valeurs se règlent ensuite sur votre corpus, pas sur un billet de blog.

Le code d'ingestion, structure d'abord, taille ensuite :

import re, uuid

def decouper_markdown(texte, source, taille=700, recouvrement=100):
    """Découpe aux titres, puis subdivise les sections trop longues."""
    sections, titre_courant = [], ""
    tampon = []
    for ligne in texte.splitlines():
        if ligne.startswith("#"):
            if tampon:
                sections.append((titre_courant, "\n".join(tampon)))
                tampon = []
            titre_courant = ligne.lstrip("# ").strip()
        else:
            tampon.append(ligne)
    if tampon:
        sections.append((titre_courant, "\n".join(tampon)))

    chunks = []
    for titre, corps in sections:
        mots = corps.split()
        if not mots:
            continue
        pas = max(taille - recouvrement, 1)
        for debut in range(0, len(mots), pas):
            fragment = " ".join(mots[debut:debut + taille])
            chunks.append({
                "id": str(uuid.uuid4()),
                "texte": f"{source} > {titre}\n\n{fragment}",
                "source": source,
                "titre": titre,
            })
    return chunks

Ce que vous devez voir à cette étape : imprimez dix morceaux au hasard et lisez-les. Chacun doit se comprendre seul, sans le précédent. Si vous ne savez pas de quoi parle un morceau, votre système ne le saura pas non plus.

Étape 2 : pourquoi le vectoriel seul rate les références

La recherche vectorielle compare des sens. C'est exactement ce qu'on lui demande, et exactement ce qui la met en défaut sur trois familles de requêtes.

Les noms propres rares. « Le dossier Benkirane » et « le dossier Benjelloun » sont proches dans l'espace des vecteurs : même forme, même contexte syntaxique, même rôle. Un embedding n'a aucune raison de les séparer nettement.

Les références et les numéros. « Article L. 122-4 », « facture 2026-0418 », « erreur ORA-01555 ». Un vecteur encode une notion de référence, pas une chaîne exacte. Vous obtenez des articles de loi voisins, jamais forcément le bon.

Les versions. Une requête sur une version précise d'un produit ramène volontiers la documentation de la précédente, sémantiquement identique à un chiffre près.

Le remède n'est pas un meilleur modèle d'embedding, c'est un second moteur qui, lui, travaille sur les mots. BM25 fait ça depuis les années 1990 : il compte les occurrences, pondère par la rareté du terme dans le corpus, et il n'a aucun état d'âme sur le sens. Un numéro de facture rare y obtient un score élevé parce qu'il est rare.

💡 Le test qui vous dit si vous avez besoin d'hybride

Prenez vingt requêtes réelles de vos utilisateurs et comptez celles qui contiennent une référence, un numéro, un nom propre ou une version. Au-delà de trois ou quatre, le vectoriel seul vous fera défaut. Si le cadrage de ce genre de projet est votre point de blocage, commencez par le brief d'une page qui ouvre cette série ; le mener avec vous est le métier de notre accompagnement IA.

Aquarelle : deux filets de mailles différentes sur les mêmes galets, le fin retient les petits, le large les gros, et ils se recouvrent au centre.

Étape 3 : la recherche hybride et sa fusion

Deux moteurs, deux listes de résultats, et un problème : un score BM25 de 12,4 et une similarité cosinus de 0,85 ne vivent pas sur la même échelle. Les normaliser suppose qu'ils veulent dire la même chose, ce qui est faux.

La fusion par rang réciproque, ou RRF, contourne le problème en jetant les scores et en ne gardant que les positions. Elle vient d'un article de Cormack, Clarke et Büttcher présenté à SIGIR en 2009, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. La formule tient sur une ligne :

score(d) = Σ  1 / (k + rang_r(d))

Chaque moteur r contribue l'inverse du rang qu'il attribue au document, plus une constante d'amortissement k. Les auteurs ont retenu k = 60 à l'issue d'une étude pilote, en notant que l'optimum est plat entre 20 et 100. Une valeur basse privilégie la première position, une valeur haute favorise le consensus entre moteurs. RRF est aujourd'hui la méthode de fusion par défaut de la plupart des moteurs de recherche vectoriels du marché.

import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

encodeur = SentenceTransformer("intfloat/multilingual-e5-base")

def indexer(chunks):
    corpus = [c["texte"] for c in chunks]
    bm25 = BM25Okapi([t.lower().split() for t in corpus])
    vecteurs = encodeur.encode(
        [f"passage: {t}" for t in corpus], normalize_embeddings=True
    )
    return bm25, np.asarray(vecteurs)

def rrf(listes, k=60):
    scores = {}
    for liste in listes:
        for rang, idx in enumerate(liste, start=1):
            scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rang)
    return sorted(scores, key=scores.get, reverse=True)

def rechercher(question, chunks, bm25, vecteurs, n=30):
    lex = np.argsort(bm25.get_scores(question.lower().split()))[::-1][:n]
    q = encodeur.encode([f"query: {question}"], normalize_embeddings=True)
    vec = np.argsort(vecteurs @ q[0])[::-1][:n]
    return [chunks[i] for i in rrf([list(lex), list(vec)])]

Le préfixe query: et passage: n'est pas décoratif : les modèles de la famille E5 sont entraînés avec, et les omettre dégrade les résultats sans le moindre message d'erreur. Vérifiez toujours la convention du modèle que vous chargez ; elle diffère d'une famille à l'autre.

Ce que vous devez voir : sur une requête contenant un numéro de facture, le bon passage doit maintenant apparaître dans les cinq premiers. Faites le test avant/après sur cette requête précise, c'est là que le gain est visible à l'œil nu.

Étape 4 : le reclassement, et ce qu'il coûte

La fusion améliore le rappel : le bon passage est quelque part dans les trente. Elle n'améliore pas forcément la précision en tête de liste, et c'est ce qui reste à régler, parce que vous n'enverrez pas trente passages au modèle.

Un reclasseur de type cross-encoder lit la question et le passage ensemble, dans le même passage avant, et rend un score de pertinence. C'est beaucoup plus juste qu'une comparaison de vecteurs calculés séparément, et beaucoup plus cher : il faut un passage du modèle par candidat.

from sentence_transformers import CrossEncoder

reclasseur = CrossEncoder("BAAI/bge-reranker-v2-m3")

def reclasser(question, candidats, garder=5):
    paires = [(question, c["texte"]) for c in candidats]
    scores = reclasseur.predict(paires)
    ordonnes = sorted(zip(scores, candidats), key=lambda x: -x[0])
    return [c for s, c in ordonnes[:garder]]

Le coût, mesuré et non supposé. Un reclassement de trente candidats ajoute typiquement quelques centaines de millisecondes sur GPU, davantage sur CPU, et le temps croît linéairement avec le nombre de candidats. Deux conséquences pratiques : ne reclassez jamais plus de candidats que nécessaire, et mesurez sur votre matériel plutôt que de reprendre un chiffre lu ailleurs. Sur un service interrogé par une interface conversationnelle, cette demi-seconde s'ajoute à tout le reste du budget de latence, dont le détail est traité dans agents IA en production.

Plusieurs fournisseurs proposent un reclasseur par API : un modèle de moins à héberger, un aller-retour réseau et une ligne de facture de plus, à calculer avec la méthode de maîtriser la facture d'API IA.

Étape 5 : le jeu d'évaluation, à faire avant d'optimiser

C'est l'étape que tout le monde repousse et qui rend toutes les autres mesurables. Sans elle, vous testez trois questions à la main et vous décidez sur une impression.

Il n'en faut pas beaucoup. Trente à cinquante questions écrites par quelqu'un qui connaît le métier, chacune associée à l'identifiant du passage qui contient la réponse. Un fichier JSON suffit.

import json

def evaluer(jeu, chercher, k=5):
    """chercher(question) -> liste ordonnée de chunks."""
    rappel, mrr = 0, 0.0
    for cas in jeu:
        resultats = chercher(cas["question"])[:k]
        ids = [c["id"] for c in resultats]
        if cas["chunk_attendu"] in ids:
            rappel += 1
            mrr += 1.0 / (ids.index(cas["chunk_attendu"]) + 1)
    n = len(jeu)
    return {"rappel@k": rappel / n, "mrr": mrr / n, "n": n}

jeu = json.load(open("evaluation.json", encoding="utf-8"))
print(evaluer(jeu, lambda q: rechercher(q, chunks, bm25, vecteurs)))

Deux mesures suffisent au départ. Le rappel@k dit si le bon passage est dans les k envoyés au modèle : c'est le plafond de tout le reste, aucun modèle ne répond juste sur un contexte qui ne contient pas la réponse. Le MRR dit à quelle position il arrive, ce qui mesure l'apport du reclassement.

Un piège méthodologique à connaître : les questions générées par un modèle à partir de vos documents produisent un jeu trop facile. Elles reprennent le vocabulaire du passage, donc le vectoriel les résout toutes. Les vraies questions d'utilisateurs sont mal formulées, elliptiques, et emploient les mots du métier plutôt que ceux du document. Écrivez au moins la moitié du jeu à la main.

Étape 6 : les métadonnées, le levier qu'on oublie

Avant de changer de modèle d'embedding, regardez ce que vous savez déjà de vos documents et que vous n'utilisez pas : date, service émetteur, type de document, version, statut.

Filtrer avant de chercher réduit l'espace de recherche et supprime des familles entières d'erreurs. Une requête sur une procédure en vigueur n'a aucune raison d'aller chercher dans les versions archivées.

def rechercher_filtre(question, chunks, filtre=None, n=30):
    if filtre:
        indices = [i for i, c in enumerate(chunks) if filtre(c)]
    else:
        indices = list(range(len(chunks)))
    sous_corpus = [chunks[i] for i in indices]
    bm25_f, vecteurs_f = indexer(sous_corpus)
    return rechercher(question, sous_corpus, bm25_f, vecteurs_f, n=n)

Ce code réindexe à chaque appel, ce qui ne tient pas au-delà de quelques centaines de morceaux : en production, le filtrage se fait dans le moteur, qui restreint la recherche par prédicat sur les métadonnées. Le principe reste le même.

Rapporté à l'effort, ce levier bat régulièrement un changement de modèle d'embedding. Il coûte une ligne de plus à l'ingestion et se règle sans réindexer tout le corpus.

Ce qui reste incertain

Trois points où je n'ai pas de réponse ferme, et où les sources se contredisent.

Le découpage sémantique. Découper aux ruptures de sens plutôt qu'aux titres ou au nombre de jetons paraît évidemment meilleur. Les évaluations publiées ne le confirment pas systématiquement face à un découpage à taille fixe bien réglé. Mesurez sur votre corpus avant d'investir dedans.

Le recouvrement. Il sauve les phrases coupées, il introduit aussi de la redondance qui occupe des places dans le top-k. Le bon réglage dépend de la densité de vos documents.

Le contexte long comme substitut. Envoyer cinquante passages plutôt que cinq est tentant depuis que les fenêtres se sont élargies. La sélection reste préférable, pour les raisons de coût et d'attention détaillées dans ce que change le contexte long.

Dernière remarque, presque tout ce qui existe sur ces réglages est publié en anglais. Les tutoriels francophones s'arrêtent le plus souvent à la démonstration d'ingestion, avant le découpage sérieux, la fusion et l'évaluation. C'est dommage, parce que le français ajoute ses propres difficultés au vectoriel : accords, élisions, accents perdus dans les requêtes réelles. Raison de plus pour construire votre jeu d'évaluation dans votre langue et sur vos documents.

L'ordre à suivre, en résumé

  1. Lisez le contexte envoyé au modèle sur vingt échecs réels, et classez-les.
  2. Écrivez trente à cinquante questions d'évaluation, à la main, avant toute optimisation.
  3. Corrigez le découpage : structure d'abord, titre parent recollé, taille ensuite.
  4. Ajoutez BM25 à côté du vectoriel et fusionnez par rang réciproque.
  5. Mesurez. Si le rappel monte mais que la précision stagne, ajoutez le reclassement.
  6. Exploitez vos métadonnées en filtre avant de toucher au modèle d'embedding.

Le modèle de génération arrive en dernier, et c'est rarement lui qu'il faut changer.

Partager

Le sujet de fond, dans les mémos

Un article éclaire un angle ; un mémo du Lab prend le sujet de A à Z, sommaire compris. Sur ce thème, voici où creuser.

À lire ensuite

NET&PRO

L'IA, c'est bien. L'IA qui tourne chez vous, c'est mieux.

Vous avez lu la théorie. NET&PRO s'occupe de la mise en œuvre : automatisation de process, intégration de modèles dans vos outils métier, formation de vos équipes.