---
title: "Projet : un RAG mesuré sur une base de fiches"
description: "Deux cents fiches fabriquées, une recherche, et la mesure de ce qu'elle retrouve. Données fabriquées, critères de réussite et correction commentée."
url: https://meridal.fr/apprendre/projet-rag-sur-une-base-de-fiches/
language: fr
written: 2026-10-09
verified: 2026-10-09
license: CC BY 4.0
---

[Apprendre](https://meridal.fr/apprendre/) › [Projets d'entraînement](https://meridal.fr/apprendre/dossier/projets/)

S'exercer · Niveau 3, Construire · 120 minutes

# Projet : un RAG mesuré sur une base de fiches

Deux cents fiches produit fabriquées, celles d'un négoce de matériaux, et quarante questions dont vous connaissez la bonne fiche. Vous construisez la recherche d'un RAG, vous la mesurez, vous l'améliorez. Point de départ exécuté par Meridal : une recherche par les mots qui trouve chaque référence exacte, mais ne met la bonne fiche en tête que pour 8 questions sur 20 posées en langage courant. Données fabriquées, critères de réussite et correction commentée.

Écrit le 9 octobre 2026 · Vérifié le 9 octobre 2026

## Vous saurez

- Construire la recherche d'un RAG sur une vraie base de fiches
- La mesurer sur des questions dont on connaît la réponse
- Comparer la recherche par les mots, par le sens, et leur fusion
- Savoir quand répondre « pas dans le catalogue »

## Dans le parcours

[Progression par projets](https://meridal.fr/apprendre/parcours/projets/)

Étape 7 sur 12

- [← Avant Un RAG minimal en Python](https://meridal.fr/apprendre/rag-minimal-python/)
- [↓ Plus concret Projet : un assistant de projet pour une équipe](https://meridal.fr/apprendre/projet-assistant-de-projet/)
- [→ Ensuite Projet : un agent de réponses clients avec file de validation](https://meridal.fr/apprendre/projet-agent-de-reponses-clients/)

L'essentiel

1. Dans un RAG, la réponse ne vaut que ce que la recherche a trouvé : on mesure la recherche à part, avant de juger les réponses.
2. La recherche par les mots trouve une référence citée telle quelle ; elle rate une question posée avec d'autres mots : ici, la bonne fiche en tête pour 8 questions courantes sur 20.
3. La recherche par le sens rapproche « ne brûle pas » d'« incombustible » ; elle peut manquer une référence exacte. La fusion des deux garde le meilleur de chacune.
4. Anthropic a mesuré, sur ses propres données, qu'ajouter la recherche par les mots à celle par le sens, avec du contexte, réduit les échecs de recherche de 49 %, et de 67 % avec un reclassement.
5. Un seuil de score ne suffit pas à reconnaître une question sans réponse : c'est au modèle de lire les fiches trouvées et de dire qu'aucune ne répond.

## Objectif

Construire la recherche de l'assistant qui répondra aux clients du Comptoir Arcelle à partir de ses 200 fiches produit ; la mesurer sur 40 questions ; l'améliorer jusqu'aux critères de réussite. Le RAG complet, recherche puis réponse rédigée avec ses sources, suit la méthode de [un RAG minimal en Python](https://meridal.fr/apprendre/rag-minimal-python/).

Fiches et questions fabriquées pour l'exercice : les produits, leurs usages et leurs conseils sont inventés ; ils ne remplacent pas une fiche technique de fabricant.

Python 3.12 et uv. Une clé d'API Mistral pour la recherche par le sens, à 0,1 $ le million de tokens : les 200 fiches font 35 673 octets, donc au plus autant de tokens : moins d'un demi-centime de dollar. Deux heures environ.

## Données fournies

Un programme fabrique les deux fichiers de l'exercice, toujours les mêmes : `fiches.jsonl`, une fiche par ligne, et `questions.csv`, chaque question avec sa bonne fiche. Dix familles de produits, des variantes proches les unes des autres, comme dans un vrai catalogue : la plaque de plâtre existe en six longueurs, le joint en six coloris.

La sortie`uv run fabriquer.py`

```
200 fiches, 40 questions
```

Exécuté le 9 octobre 2026 avec Python 3.12.7.

`fiches.jsonl (trois lignes sur 200)`

```
{"id": "AR-0001", "famille": "Ciments et mortiers", "nom": "Ciment gris CEM II/B-LL 32,5 R, sac de 25 kg", "texte": "Ciment gris CEM II/B-LL 32,5 R, sac de 25 kg. Famille : ciments et mortiers. Usage : ciment pour la maçonnerie courante, les chapes et le béton non armé. Dosage indicatif : 350 kg par m³ pour un béton courant."}
{"id": "AR-0048", "famille": "Plâtre et plaques", "nom": "Enduit à joint, sac de 5 kg", "texte": "Enduit à joint, sac de 5 kg. Famille : plâtre et plaques. Usage : enduit pour les bandes et les joints entre plaques de plâtre. À poser avec une bande papier ou une bande à joint."}
{"id": "AR-0072", "famille": "Isolation", "nom": "Polystyrène expansé PSE, épaisseur 120 mm", "texte": "Polystyrène expansé PSE, épaisseur 120 mm. Famille : isolation. Usage : isolant pour les murs par l'intérieur et les sols sous chape. Ne pas exposer longtemps au soleil."}
```

| Type de question | Nombre | Exemple | Ce qu'elle mesure |
|---|---|---|---|
| Exacte | 15 | « Plaque BA13 H1 hydrofuge en 1,20 x 2,60 m » | retrouver une référence parmi ses variantes |
| Courante | 20 | « Un isolant qui ne brûle pas » | comprendre une question posée avec d'autres mots que la fiche |
| Sans réponse | 5 | « Avez-vous des panneaux solaires ? » | reconnaître qu'aucune fiche ne répond |

**Voir le programme qui fabrique les fiches**

`fabriquer.py`

```
"""Fabrique les 200 fiches produit et les 40 questions de l'exercice, toujours les mêmes."""
import csv
import json

FAMILLES = {
 "Ciments et mortiers": [
  ("Ciment gris CEM II/B-LL 32,5 R", "ciment pour la maçonnerie courante, les chapes et le béton non armé", ["sac de 25 kg", "sac de 35 kg"], "Dosage indicatif : 350 kg par m³ pour un béton courant."),
  ("Ciment blanc CEM I 52,5 N", "ciment blanc pour les joints apparents, les enduits décoratifs et le béton blanc", ["sac de 25 kg"], "À conserver au sec, à l'abri de l'humidité."),
  ("Mortier bâtard prêt à gâcher", "mortier de chaux et de ciment pour monter des parpaings ou des briques", ["sac de 25 kg", "sac de 35 kg"], "Ajouter environ 4 litres d'eau par sac de 25 kg."),
  ("Béton prêt à gâcher", "béton dosé pour les petits travaux : poteaux, socles, fondations de clôture", ["sac de 25 kg", "sac de 35 kg"], "Ne pas couler par température inférieure à 5 °C."),
  ("Chaux hydraulique naturelle NHL 3,5", "chaux pour les enduits respirants sur murs anciens en pierre", ["sac de 25 kg", "sac de 35 kg"], "Ne pas utiliser sur un support en plâtre."),
  ("Chaux hydraulique naturelle NHL 5", "chaux pour les maçonneries de pierre et les sols extérieurs", ["sac de 25 kg", "sac de 35 kg"], "Temps de prise plus court que la NHL 3,5."),
  ("Mortier de réparation rapide", "mortier à prise rapide pour reboucher, sceller et réparer les arêtes de béton", ["seau de 5 kg", "sac de 25 kg"], "Prise en 15 minutes environ à 20 °C."),
  ("Ciment prompt", "ciment naturel à prise très rapide pour arrêter une venue d'eau ou sceller en quelques minutes", ["seau de 5 kg"], "Gâcher par petites quantités."),
  ("Enduit de façade monocouche", "enduit coloré pour protéger et décorer les murs extérieurs en une seule passe", ["blanc cassé, sac de 25 kg", "ton pierre, sac de 25 kg", "gris clair, sac de 25 kg"], "Application à la machine ou à la main."),
  ("Mortier de scellement", "mortier sans retrait pour sceller des gonds, des tiges filetées ou des regards", ["seau de 5 kg"], "Remplir le trou en une seule fois."),
 ],
 "Colles à carrelage": [
  ("Mortier-colle C1 standard", "colle pour carrelage de petit format au mur et au sol intérieur", ["sac de 25 kg"], "Pour des carreaux jusqu'à 30 x 30 cm."),
  ("Mortier-colle C2 amélioré", "colle pour grès cérame et grands carreaux au sol et au mur intérieur", ["sac de 25 kg"], "Double encollage au-delà de 60 x 60 cm."),
  ("Mortier-colle C2S1 déformable", "colle souple pour plancher chauffant, terrasse et support qui bouge", ["sac de 25 kg"], "Indispensable sur un sol chauffant ou sur un ancien carrelage."),
  ("Mortier-colle C2S2 très déformable", "colle très souple pour les grands formats en extérieur et les supports bois", ["sac de 15 kg"], "Pour les dalles de plus de 120 x 60 cm."),
  ("Colle en pâte D2 prête à l'emploi", "colle prête à l'emploi pour la faïence murale en pièce sèche", ["seau de 5 kg", "seau de 15 kg"], "Ne pas utiliser au sol ni dans une douche."),
  ("Mortier-colle blanc C2 pour pierre", "colle blanche pour la pierre naturelle claire et le marbre, sans tache", ["sac de 25 kg"], "Évite les auréoles sur les pierres claires."),
  ("Mortier de jointoiement CG2", "joint pour carrelage, hydrofuge, en largeur de 2 à 10 mm", ["gris, sac de 5 kg", "blanc, sac de 5 kg", "anthracite, sac de 5 kg", "beige, sac de 5 kg", "ivoire, sac de 5 kg", "ardoise, sac de 5 kg"], "Pour les joints de carrelage au sol et au mur."),
  ("Joint époxy", "joint époxy résistant aux taches et aux produits chimiques, pour cuisines et sols de laboratoire", ["seau de 3 kg"], "Temps d'utilisation de 45 minutes."),
 ],
 "Plâtre et plaques": [
  ("Plaque de plâtre standard BA13", "plaque pour cloisons et doublages en pièce sèche", ["1,20 x 2,40 m", "1,20 x 2,50 m", "1,20 x 2,60 m", "1,20 x 2,70 m", "1,20 x 2,80 m", "1,20 x 3,00 m"], "Épaisseur 12,5 mm ; bords amincis."),
  ("Plaque de plâtre hydrofuge BA13 H1", "plaque verte pour salle de bains, cuisine et pièce humide", ["1,20 x 2,50 m", "1,20 x 2,60 m"], "Ne remplace pas une étanchéité sous carrelage de douche."),
  ("Plaque de plâtre phonique", "plaque pour améliorer l'isolation contre les bruits entre deux pièces", ["1,20 x 2,50 m", "1,20 x 2,60 m"], "Gain annoncé par le fabricant selon la cloison montée."),
  ("Plaque de plâtre feu BA13 F", "plaque rose pour la protection contre le feu des gaines et des locaux techniques", ["1,20 x 2,50 m"], "Classement à vérifier selon le procès-verbal de la cloison."),
  ("Rail métallique R48", "rail pour l'ossature des cloisons en plaques de plâtre", ["longueur 3 m"], "Fixer tous les 60 cm au sol et au plafond."),
  ("Montant métallique M48", "montant pour l'ossature des cloisons en plaques de plâtre", ["longueur 2,50 m", "longueur 2,70 m", "longueur 3 m"], "Entraxe de 60 cm pour une cloison courante."),
  ("Enduit à joint", "enduit pour les bandes et les joints entre plaques de plâtre", ["sac de 5 kg", "sac de 25 kg"], "À poser avec une bande papier ou une bande à joint."),
  ("Bande à joint papier", "bande à noyer dans l'enduit à joint entre deux plaques de plâtre", ["rouleau de 23 m", "rouleau de 75 m"], "Microperforée pour chasser l'air."),
  ("Plâtre de construction", "plâtre pour les enduits intérieurs, les scellements et les rebouchages", ["sac de 25 kg", "sac de 40 kg"], "Ne pas utiliser en pièce humide."),
 ],
 "Isolation": [
  ("Laine de verre en rouleau", "isolant pour les combles perdus et les rampants de toiture", ["épaisseur 45 mm", "épaisseur 75 mm", "épaisseur 100 mm", "épaisseur 140 mm", "épaisseur 200 mm", "épaisseur 240 mm", "épaisseur 300 mm"], "Résistance thermique indiquée sur l'étiquette de chaque épaisseur."),
  ("Laine de roche en panneau", "isolant incombustible pour les murs, les cloisons et les combles", ["épaisseur 45 mm", "épaisseur 60 mm", "épaisseur 100 mm", "épaisseur 120 mm"], "Aussi utilisée pour l'isolation contre le bruit."),
  ("Laine de bois", "isolant biosourcé pour les combles et les murs à ossature bois", ["épaisseur 100 mm", "épaisseur 145 mm"], "Bon confort d'été, selon le fabricant."),
  ("Polystyrène expansé PSE", "isolant pour les murs par l'intérieur et les sols sous chape", ["épaisseur 20 mm", "épaisseur 40 mm", "épaisseur 60 mm", "épaisseur 80 mm", "épaisseur 100 mm", "épaisseur 120 mm"], "Ne pas exposer longtemps au soleil."),
  ("Polyuréthane PIR", "isolant mince et performant pour les sols, les toitures-terrasses et les murs", ["épaisseur 40 mm", "épaisseur 60 mm", "épaisseur 80 mm", "épaisseur 100 mm", "épaisseur 120 mm"], "Parement aluminium sur les deux faces."),
  ("Laine soufflée en vrac", "isolant à souffler dans les combles perdus difficiles d'accès", ["sac de 16 kg"], "Se pose avec une machine à souffler, disponible en location."),
  ("Pare-vapeur", "membrane à poser côté chaud de l'isolant pour éviter la condensation", ["rouleau de 75 m²"], "Raccorder les lés avec l'adhésif du même fabricant."),
  ("Adhésif pour pare-vapeur", "adhésif pour raccorder les lés de pare-vapeur et les traversées", ["rouleau de 25 m"], "Sur support propre et sec."),
 ],
 "Bois et panneaux": [
  ("Panneau OSB 3", "panneau de bois orienté pour planchers, murs et toitures en milieu humide", ["épaisseur 9 mm", "épaisseur 12 mm", "épaisseur 15 mm", "épaisseur 18 mm", "épaisseur 22 mm"], "Format 2,50 x 1,25 m ; rainuré-bouveté en 18 mm."),
  ("Contreplaqué peuplier", "panneau léger pour l'agencement intérieur et le mobilier", ["épaisseur 3 mm", "épaisseur 5 mm", "épaisseur 10 mm", "épaisseur 15 mm", "épaisseur 18 mm"], "Usage intérieur seulement."),
  ("Contreplaqué marine", "panneau pour l'extérieur et les milieux très humides", ["épaisseur 10 mm", "épaisseur 18 mm"], "Collage résistant à l'eau bouillante."),
  ("Panneau MDF", "panneau de fibres à surface lisse, à peindre, pour le mobilier et les habillages", ["épaisseur 3 mm", "épaisseur 6 mm", "épaisseur 10 mm", "épaisseur 16 mm", "épaisseur 19 mm", "épaisseur 22 mm"], "Usage intérieur ; existe en version hydrofuge sur commande."),
  ("Bastaing sapin", "bois de charpente pour solives et ossatures", ["section 63 x 175 mm, 4 m", "section 63 x 175 mm, 5 m"], "Traitement classe 2 contre les insectes."),
  ("Chevron sapin", "bois pour les toitures, les ossatures et les petites charpentes", ["section 63 x 75 mm, 4 m"], "Traitement classe 2."),
  ("Lame de terrasse pin traité", "lame pour terrasse extérieure en bois", ["27 x 145 mm, 4,20 m"], "Traitement classe 4 ; prévoir des lambourdes du même traitement."),
 ],
 "Couverture": [
  ("Tuile terre cuite à emboîtement", "tuile pour toiture à pente moyenne", ["coloris rouge", "coloris brun", "coloris ardoisé", "coloris vieilli", "coloris sablé"], "Environ 13 tuiles par m² selon le modèle."),
  ("Tuile canal", "tuile ronde pour les toitures du sud à faible pente", ["coloris paille", "coloris vieilli"], "Pose sur support ou à crochets selon la pente."),
  ("Ardoise naturelle", "ardoise pour couverture et bardage", ["format 32 x 22 cm", "format 40 x 24 cm"], "Fixation au crochet inox ou au clou cuivre."),
  ("Faîtière à emboîtement", "tuile de faîtage pour fermer le haut de la toiture", ["coloris rouge", "coloris brun", "coloris ardoisé", "coloris vieilli", "coloris sablé"], "Environ 2,5 faîtières par mètre de faîtage."),
  ("Écran de sous-toiture HPV", "membrane hautement perméable à la vapeur sous les tuiles", ["rouleau de 75 m²"], "Peut être posée au contact de l'isolant."),
  ("Closoir ventilé", "bande souple pour fermer le faîtage tout en laissant ventiler la toiture", ["rouleau de 5 m"], "Se pose sous les faîtières."),
  ("Crochet d'ardoise inox", "crochet pour fixer les ardoises sur les liteaux", ["longueur 80 mm, boîte de 250", "longueur 100 mm, boîte de 250"], "Acier inoxydable."),
  ("Gouttière zinc demi-ronde", "gouttière pour l'évacuation des eaux de pluie", ["développé 25 cm, 4 m", "développé 33 cm, 4 m"], "Pente d'au moins 5 mm par mètre."),
 ],
 "Étanchéité": [
  ("Membrane d'étanchéité sous carrelage", "système liquide à poser sous le carrelage d'une douche à l'italienne", ["kit de 5 m²", "kit de 10 m²"], "Deux couches croisées avec la bande d'angle fournie."),
  ("Bande d'étanchéité d'angle", "bande pour les angles et les raccords mur-sol des pièces humides", ["rouleau de 10 m"], "Noyée dans la première couche de membrane."),
  ("Résine d'étanchéité pour toit-terrasse", "résine à appliquer au rouleau sur toiture-terrasse accessible", ["seau de 5 kg", "seau de 20 kg"], "Ne pas appliquer par temps de pluie annoncée."),
  ("Mastic polyuréthane", "mastic pour les joints de dilatation et les raccords de façade", ["blanc, cartouche de 300 ml", "gris, cartouche de 300 ml", "beige, cartouche de 300 ml"], "Peut être peint après séchage."),
  ("Mastic silicone sanitaire", "mastic pour les joints de baignoire, de douche et d'évier", ["blanc, cartouche de 300 ml", "transparent, cartouche de 300 ml", "gris, cartouche de 300 ml"], "Ne se peint pas."),
  ("Hydrofuge de surface", "traitement incolore qui protège les façades et les sols poreux de l'eau", ["bidon de 5 L", "bidon de 20 L"], "Ne bouche pas les pores : le mur continue de respirer."),
  ("Primaire d'accrochage", "primaire à passer avant une colle ou un enduit sur un support lisse ou poreux", ["bidon de 1 L", "bidon de 5 L"], "Laisser sécher avant de coller."),
  ("Natte d'étanchéité et de désolidarisation", "natte à coller sous le carrelage pour l'étanchéité et contre les fissures du support", ["rouleau de 5 m²", "rouleau de 30 m²"], "Joints des lés recouverts de bande."),
  ("Revêtement bitumineux", "enduit noir pour protéger les fondations enterrées de l'humidité", ["seau de 25 kg"], "Deux couches sur un support propre."),
 ],
 "Outillage": [
  ("Truelle de maçon", "truelle pour monter des parpaings et travailler le mortier", ["lame de 16 cm", "lame de 18 cm", "lame de 20 cm", "lame de 22 cm"], "Manche bois ou bimatière."),
  ("Peigne à colle", "spatule crantée pour étaler la colle à carrelage", ["dents de 4 mm", "dents de 6 mm", "dents de 9 mm", "dents de 12 mm", "dents de 15 mm"], "La taille des dents dépend du format des carreaux."),
  ("Niveau à bulle", "niveau pour contrôler l'horizontale et la verticale", ["longueur 40 cm", "longueur 60 cm", "longueur 80 cm", "longueur 120 cm", "longueur 180 cm"], "Deux fioles, dont une verticale."),
  ("Malaxeur électrique", "malaxeur pour mélanger colles, enduits et mortiers", ["puissance 1 200 W"], "Fourni avec une hélice de 120 mm."),
  ("Coupe-carreaux manuel", "outil pour couper les carreaux en ligne droite", ["longueur de coupe 63 cm", "longueur de coupe 93 cm"], "Pour les carreaux jusqu'à 12 mm d'épaisseur."),
  ("Auge de maçon", "auge pour gâcher le mortier et le plâtre", ["contenance 40 L", "contenance 90 L"], "Plastique renforcé."),
  ("Cordeau à tracer", "cordeau poudré pour tracer des lignes droites au sol et au mur", ["longueur 30 m"], "Recharge de poudre bleue."),
  ("Spatule à enduire", "spatule pour étaler et lisser les enduits et les rebouchages", ["largeur 10 cm", "largeur 20 cm", "largeur 30 cm"], "Lame inox."),
  ("Taloche éponge", "taloche pour lisser les enduits et nettoyer les joints", ["format 14 x 28 cm"], "Éponge remplaçable."),
 ],
 "Fixations": [
  ("Cheville à expansion métal", "cheville pour fixer des charges lourdes dans le béton plein", ["diamètre 6 mm", "diamètre 8 mm", "diamètre 10 mm", "diamètre 12 mm"], "Charge indicative par le fabricant selon le support."),
  ("Cheville pour plaque de plâtre", "cheville à expansion pour fixer dans une cloison creuse", ["diamètre 6 mm", "diamètre 8 mm"], "Pour des charges légères à moyennes."),
  ("Vis à bois tête fraisée", "vis pour l'assemblage du bois et des panneaux", ["3 x 30 mm", "4 x 40 mm", "4 x 50 mm", "5 x 50 mm", "5 x 60 mm", "5 x 70 mm", "6 x 80 mm", "6 x 100 mm"], "Empreinte étoile ; boîte de 200."),
  ("Vis pour plaque de plâtre", "vis autoperceuse pour fixer les plaques sur ossature métallique", ["3,5 x 25 mm", "3,5 x 35 mm"], "Boîte de 1 000."),
  ("Tige filetée zinguée", "tige filetée pour les suspentes et les scellements chimiques", ["M6, 1 m", "M8, 1 m", "M10, 1 m", "M12, 1 m"], "À couper à la longueur voulue."),
  ("Scellement chimique", "résine pour fixer des tiges filetées dans le béton et la maçonnerie creuse", ["cartouche de 300 ml"], "Utiliser un tamis dans la maçonnerie creuse."),
 ],
 "Aménagement extérieur": [
  ("Pavé béton autobloquant", "pavé pour allées et cours carrossables", ["épaisseur 6 cm", "épaisseur 8 cm"], "L'épaisseur de 8 cm pour le passage des véhicules."),
  ("Bordure de jardin béton", "bordure pour délimiter allées et massifs", ["longueur 1 m"], "Pose sur un lit de béton maigre."),
  ("Gravier concassé 6/10", "gravier pour les allées, le drainage et les fonds de forme", ["sac de 35 kg", "big bag de 1 tonne"], "Couleur grise."),
  ("Sable 0/4", "sable pour le béton et les mortiers", ["sac de 35 kg", "big bag de 1 tonne"], "Sable lavé de rivière."),
  ("Gravier concassé 10/20", "gravier plus gros pour le drainage et les fonds de forme", ["sac de 35 kg", "big bag de 1 tonne"], "Couleur grise."),
  ("Sable 0/2", "sable fin pour les joints de pavés et les mortiers fins", ["sac de 35 kg", "big bag de 1 tonne"], "Sable sec, tamisé."),
  ("Galet décoratif blanc", "galet pour massifs et décoration extérieure", ["sac de 20 kg", "big bag de 500 kg"], "Calibre 16/25 mm."),
  ("Feutre géotextile", "feutre à poser sous les graviers pour empêcher les mauvaises herbes et séparer les couches", ["rouleau de 25 m²"], "Laisse passer l'eau."),
  ("Dalle gravillonnée", "dalle extérieure antidérapante pour terrasses et abords de piscine", ["format 50 x 50 cm"], "Pose sur plots ou sur mortier."),
 ],
}

fiches = []
for famille, produits in FAMILLES.items():
    for nom, usage, variantes, note in produits:
        for v in variantes:
            n = len(fiches) + 1
            fiches.append({"id": f"AR-{n:04d}", "famille": famille, "nom": f"{nom}, {v}",
                           "texte": f"{nom}, {v}. Famille : {famille.lower()}. Usage : {usage}. {note}"})

def trouver(*mots):
    """La fiche unique dont le nom contient tous les mots : sans ambiguïté, sinon erreur."""
    r = [f["id"] for f in fiches if all(m.lower() in f["nom"].lower() for m in mots)]
    assert len(r) == 1, (mots, r)
    return r[0]

QUESTIONS = [
 ("exacte", "Ciment blanc CEM I 52,5 en sac de 25 kg", trouver("Ciment blanc")),
 ("exacte", "Plaque BA13 H1 hydrofuge en 1,20 x 2,60 m", trouver("hydrofuge", "2,60")),
 ("exacte", "Mortier-colle C2S2 en sac de 15 kg", trouver("C2S2")),
 ("exacte", "Montant M48 en longueur 2,70 m", trouver("Montant", "2,70")),
 ("exacte", "Laine de verre en rouleau, épaisseur 300 mm", trouver("Laine de verre", "300")),
 ("exacte", "Panneau OSB 3 en 18 mm", trouver("OSB", "18 mm")),
 ("exacte", "Ardoise naturelle format 40 x 24 cm", trouver("Ardoise", "40 x 24")),
 ("exacte", "Mastic silicone sanitaire transparent", trouver("silicone", "transparent")),
 ("exacte", "Peigne à colle dents de 9 mm", trouver("Peigne", "9 mm")),
 ("exacte", "Vis pour plaque de plâtre 3,5 x 35 mm", trouver("Vis pour plaque", "35 mm")),
 ("exacte", "Gravier concassé 6/10 en big bag", trouver("6/10", "big bag")),
 ("exacte", "Chaux NHL 5 en sac de 35 kg", trouver("NHL 5", "35 kg")),
 ("exacte", "Gouttière zinc développé 33", trouver("Gouttière", "33 cm")),
 ("exacte", "Polystyrène expansé 80 mm", trouver("Polystyrène", "80 mm")),
 ("exacte", "Joint époxy", trouver("Joint époxy")),
 ("courante", "Quelle colle pour poser du carrelage sur un plancher chauffant ?", trouver("C2S1")),
 ("courante", "Il me faut une plaque pour la salle de bains", trouver("hydrofuge", "2,50")),
 ("courante", "Comment empêcher les mauvaises herbes sous le gravier ?", trouver("géotextile")),
 ("courante", "De quoi protéger le mur enterré d'une cave contre l'humidité ?", trouver("bitumineux")),
 ("courante", "De quoi isoler des combles où on ne peut pas marcher, en soufflant ?", trouver("soufflée")),
 ("courante", "Que mettre sous le carrelage d'une douche sans receveur ?", trouver("Membrane", "5 m²")),
 ("courante", "Pour un enduit sur un vieux mur en pierre qui doit respirer", trouver("NHL 3,5", "25 kg")),
 ("courante", "Une colle qui ne tache pas le marbre clair", trouver("pour pierre")),
 ("courante", "Fixer une étagère lourde dans un mur en béton", trouver("expansion métal", "10 mm")),
 ("courante", "Accrocher un cadre sur une cloison creuse", trouver("Cheville pour plaque", "6 mm")),
 ("courante", "Un isolant qui ne brûle pas", trouver("Laine de roche", "100 mm")),
 ("courante", "Allée où passent les voitures : quels pavés ?", trouver("Pavé", "8 cm")),
 ("courante", "Reboucher vite un trou dans une marche en béton", trouver("réparation rapide", "seau de 5 kg")),
 ("courante", "Mélanger de la colle sans s'épuiser au bras", trouver("Malaxeur")),
 ("courante", "Quel bois pour une terrasse dehors ?", trouver("Lame de terrasse")),
 ("courante", "Empêcher la condensation derrière l'isolant", trouver("Pare-vapeur", "75 m²")),
 ("courante", "Coller de la faïence dans une chambre, sans préparer de mortier", trouver("en pâte", "seau de 5 kg")),
 ("courante", "Cloison contre le bruit du voisin de bureau", trouver("phonique", "2,50")),
 ("courante", "Dalles qui ne glissent pas autour de la piscine", trouver("gravillonnée")),
 ("courante", "Fermer le haut du toit en tuiles brunes", trouver("Faîtière", "brun")),
 ("absente", "Avez-vous des panneaux solaires ?", ""),
 ("absente", "Quel est le prix d'une pompe à chaleur ?", ""),
 ("absente", "Vendez-vous de la peinture de façade ?", ""),
 ("absente", "Un radiateur électrique, vous en avez ?", ""),
 ("absente", "Louez-vous des échafaudages ?", ""),
]

if __name__ == "__main__":
    with open("fiches.jsonl", "w", encoding="utf-8") as f:
        for x in fiches:
            f.write(json.dumps(x, ensure_ascii=False) + "\n")
    with open("questions.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f, delimiter=";")
        w.writerow(["type", "question", "fiche_attendue"])
        w.writerows(QUESTIONS)
    print(len(fiches), "fiches,", len(QUESTIONS), "questions")
```

## Consignes

1. Préparez le projet : `uv init rag-fiches`, puis `uv add mistralai numpy python-dotenv` ; copiez les quatre programmes de la page ; lancez `uv run fabriquer.py`.
2. Mesurez la recherche par les mots : `uv run mesurer.py`. Vous devez retrouver la sortie de la correction, au caractère près.
3. Avant d'aller plus loin, lisez les cinq questions ratées : pour chacune, quel mot de la question manque à la fiche ?
4. Mettez votre clé Mistral dans un fichier `.env`, et mesurez la recherche par le sens : `uv run mesurer.py sens`. Les vecteurs des fiches sont gardés dans `vecteurs.npy` : on ne les paie qu'une fois.
5. Mesurez la fusion des deux : `uv run mesurer.py hybride`.
6. Choisissez la méthode, et dites comment l'assistant reconnaîtra une question sans réponse.
7. Branchez la méthode retenue sur la génération de [la page du RAG minimal](https://meridal.fr/apprendre/rag-minimal-python/), avec la consigne de citer les fiches, et de dire quand aucune ne répond.

`bm25.py`

```
"""Une recherche par les mots (BM25), en Python seul : le point de départ, sans modèle ni clé."""
import json
import math
import re
from collections import Counter

VIDES = set("le la les un une des de du d l à au aux et ou en pour par sur sous avec sans dans "
            "il elle on me mon ma mes vous nous quel quelle quels quelles que qui quoi comment "
            "est sont faut faire mettre avez avoir y a ne pas plus".split())

def mots(texte):
    return [m for m in re.findall(r"[a-zà-öø-ÿ0-9]+", texte.lower()) if m not in VIDES]

FICHES = [json.loads(l) for l in open("fiches.jsonl", encoding="utf-8")]
DOCS = [mots(f["texte"]) for f in FICHES]
N = len(DOCS)
MOYENNE = sum(len(d) for d in DOCS) / N
DF = Counter(m for d in DOCS for m in set(d))
TF = [Counter(d) for d in DOCS]

def chercher(question, k=5, k1=1.5, b=0.75):
    """Les k fiches les mieux notées, avec leur score."""
    scores = []
    for i, d in enumerate(DOCS):
        s = 0.0
        for m in mots(question):
            if m in TF[i]:
                idf = math.log(1 + (N - DF[m] + 0.5) / (DF[m] + 0.5))
                f = TF[i][m]
                s += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * len(d) / MOYENNE))
        scores.append((s, FICHES[i]["id"]))
    scores.sort(reverse=True)
    return [(fid, round(s, 2)) for s, fid in scores[:k]]
```

`mesurer.py`

```
"""Mesure une recherche sur les 40 questions dont on connaît la bonne fiche."""
import csv
import sys

methode = sys.argv[1] if len(sys.argv) > 1 else "mots"     # mots, sens ou hybride
if methode == "mots":
    from bm25 import chercher
else:
    import plongements
    chercher = {"sens": plongements.chercher_sens, "hybride": plongements.chercher_hybride}[methode]
print(f"Méthode : {methode}")

questions = list(csv.DictReader(open("questions.csv", encoding="utf-8"), delimiter=";"))
for genre in ("exacte", "courante"):
    lot = [q for q in questions if q["type"] == genre]
    en_tete = dans_5 = 0
    ratees = []
    for q in lot:
        trouvees = [fid for fid, _ in chercher(q["question"])]
        en_tete += trouvees[0] == q["fiche_attendue"]
        dans_5 += q["fiche_attendue"] in trouvees
        if q["fiche_attendue"] not in trouvees:
            ratees.append(q["question"])
    print(f"Questions {genre}s : {len(lot)} ; bonne fiche en tête : {en_tete} ; dans les 5 premières : {dans_5}")
    for r in ratees:
        print(f"  ratée : {r}")
print()
present = [chercher(q["question"], 1)[0][1] for q in questions if q["type"] != "absente"]
absent = [(q["question"], chercher(q["question"], 1)[0][1]) for q in questions if q["type"] == "absente"]
print(f"Meilleur score, questions qui ont une réponse : de {min(present)} à {max(present)}")
for q, s in absent:
    print(f"Meilleur score, sans réponse : {s:>5}  {q}")
```

`plongements.py`

```
"""La recherche par le sens : un vecteur par fiche et par question (Mistral, mistral-embed),
puis la fusion avec la recherche par les mots."""
import os

import numpy as np
from dotenv import load_dotenv
from mistralai.client import Mistral

from bm25 import FICHES, chercher as chercher_mots

load_dotenv()
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])

def vecteurs(textes):
    out = []
    for i in range(0, len(textes), 50):                     # par lots de 50 textes
        r = client.embeddings.create(model="mistral-embed", inputs=textes[i:i + 50])
        out += [d.embedding for d in r.data]
    v = np.array(out)
    return v / np.linalg.norm(v, axis=1, keepdims=True)    # longueur 1 : le produit donne le cosinus

if os.path.exists("vecteurs.npy"):                          # payés une fois, gardés sur le disque
    FICHES_V = np.load("vecteurs.npy")
else:
    FICHES_V = vecteurs([f["texte"] for f in FICHES])
    np.save("vecteurs.npy", FICHES_V)

def chercher_sens(question, k=5):
    s = FICHES_V @ vecteurs([question])[0]
    return [(FICHES[i]["id"], round(float(s[i]), 3)) for i in np.argsort(-s)[:k]]

def chercher_hybride(question, k=5, c=60):
    """Fusion par les rangs : une fiche bien classée par l'une ou l'autre méthode remonte."""
    points = {}
    for liste in (chercher_mots(question, 50), chercher_sens(question, 50)):
        for rang, (fid, _) in enumerate(liste, 1):
            points[fid] = points.get(fid, 0) + 1 / (c + rang)
    return sorted(points.items(), key=lambda x: -x[1])[:k]
```

La recherche par les mots suit la formule BM25, la plus courante ; la fusion, la méthode des rangs réciproques, avec la constante 60 de ses auteurs. La recherche par le sens appelle le modèle de plongements de Mistral.

## Critères de réussite

- Trois mesures sur les mêmes 40 questions : mots, sens, fusion sans en changer une seule entre deux mesures
- La méthode retenue garde les 15 questions exactes, bonne fiche en tête la recherche par les mots y arrive ; la fusion ne doit rien perdre
- Au moins 18 questions courantes sur 20 avec la bonne fiche dans les 5 premières la recherche par les mots en a 15
- Les 5 questions sans réponse reçoivent « pas dans le catalogue » par la génération, qui lit les fiches trouvées ; pas par un seuil seul
- Chaque réponse rédigée cite les numéros de ses fiches
- Le coût est noté : les vecteurs des 200 fiches, une fois ; une question, à chaque fois

Seuils proposés par Meridal pour un assistant ouvert aux clients ; en interne, une bonne fiche dans les 5 premières peut suffire, si la personne lit les sources.

## Indications

**« Un isolant qui ne brûle pas » ne trouve pas la laine de roche**

**Cause**La fiche dit « incombustible » : aucun mot en commun.

**À faire**La recherche par le sens, ou un synonyme ajouté à la fiche ; c'est la fiche qu'on enrichit, pas la question.

**« En soufflant » ne trouve pas la laine soufflée**

**Cause**« soufflant » et « souffler » sont deux mots différents pour BM25 : il ne ramène pas les mots à leur racine ; et « peut » fait remonter le mastic, qui « peut être peint ».

**À faire**Compléter la liste des mots vides ; ramener les mots à leur racine ; ou la recherche par le sens.

**La bonne fiche est trouvée, mais pas la bonne variante**

**Cause**Les variantes ne diffèrent que par un nombre : 2,50 ou 2,60 m.

**À faire**Garder la recherche par les mots dans la fusion : elle tient aux nombres exacts.

**« Radiateur électrique » obtient un meilleur score que des questions valides**

**Cause**Le mot « électrique » apparaît dans une fiche, celle du malaxeur.

**À faire**Ne pas décider par un seuil seul : le modèle lit les fiches trouvées et répond qu'aucune ne parle de radiateur.

**La recherche par le sens coûte à chaque essai**

**Cause**Les vecteurs des fiches sont recalculés.

**À faire**Ils sont gardés dans vecteurs.npy ; à supprimer seulement quand les fiches changent.

**Une mesure change d'un passage à l'autre**

**Cause**Une question ou une fiche a été modifiée entre deux mesures.

**À faire**Les fichiers de l'exercice sont figés : relancez fabriquer.py, et ne corrigez qu'une chose à la fois.

## Correction commentée

**Ouvrir la correction**

La sortie`uv run mesurer.py`

```
Méthode : mots
Questions exactes : 15 ; bonne fiche en tête : 15 ; dans les 5 premières : 15
Questions courantes : 20 ; bonne fiche en tête : 8 ; dans les 5 premières : 15
  ratée : De quoi isoler des combles où on ne peut pas marcher, en soufflant ?
  ratée : Pour un enduit sur un vieux mur en pierre qui doit respirer
  ratée : Un isolant qui ne brûle pas
  ratée : Allée où passent les voitures : quels pavés ?
  ratée : Cloison contre le bruit du voisin de bureau
 
Meilleur score, questions qui ont une réponse : de 2.14 à 27.41
Meilleur score, sans réponse :  2.03  Avez-vous des panneaux solaires ?
Meilleur score, sans réponse :   0.0  Quel est le prix d'une pompe à chaleur ?
Meilleur score, sans réponse :  3.52  Vendez-vous de la peinture de façade ?
Meilleur score, sans réponse :  5.14  Un radiateur électrique, vous en avez ?
Meilleur score, sans réponse :   0.0  Louez-vous des échafaudages ?
```

Exécuté le 9 octobre 2026 avec Python 3.12.7, sans modèle ni réseau.

**Les cinq questions ratées.** Ce que la recherche met en tête, et pourquoi :

| Question | En tête | Pourquoi |
|---|---|---|
| De quoi isoler des combles où on ne peut pas marcher, en soufflant ? | le mastic polyuréthane | « peut », un mot sans importance, est dans sa fiche (« peut être peint ») ; « soufflant » n'est pas « souffler », ni « isoler » « isolant » |
| Pour un enduit sur un vieux mur en pierre qui doit respirer | l'hydrofuge de surface | sa fiche dit « le mur continue de respirer » ; celle de la chaux dit « enduits respirants sur murs anciens » : aucun mot identique |
| Un isolant qui ne brûle pas | le polystyrène expansé | « isolant » est dans une vingtaine de fiches, « brûle » dans aucune : la laine de roche est « incombustible » |
| Allée où passent les voitures : quels pavés ? | le sable 0/2 | sa fiche parle des « joints de pavés » ; celle du pavé dit « allées » et « véhicules » |
| Cloison contre le bruit du voisin de bureau | la laine de roche | sa fiche dit « contre le bruit » ; la plaque phonique dit « contre les bruits », au pluriel |

**Les questions sans réponse.** Trois des cinq obtiennent un score qui n'est pas nul, et « radiateur électrique » dépasse le plus faible score d'une question qui a une réponse. Aucun seuil ne les sépare : c'est la lecture des fiches par le modèle qui tranche.

La sortie`uv run mesurer.py sens`

**Pas encore exécuté par Meridal.** Ce qu'on attend : les mêmes lignes, pour la recherche par le sens. Elle devrait remonter les cinq questions ratées, puisque leur sens est dans la fiche ; elle peut perdre des variantes exactes. Mesurez-le.

La sortie`uv run mesurer.py hybride`

**Pas encore exécuté par Meridal.** Ce qu'on attend : la fusion, qui devrait garder les 15 questions exactes et la plupart des courantes. C'est la mesure qui décide de la méthode, pas cette attente.

**Ce qui compte le plus.** La mesure, avant tout réglage. Sans elle, on choisit la recherche par le sens parce qu'elle est plus récente, et on perd les références exactes sans le voir ; avec elle, on voit ce que chaque méthode gagne et perd, question par question.

## Extensions

- **Le reclassement** : relire les 20 premières fiches avec un modèle qui note leur pertinence, puis garder les 5 meilleures ; c'est ce qui mène Anthropic de 49 % à 67 % d'échecs en moins.
- **Le contexte de chaque fiche** : ajouter à chaque fiche, avant de la ranger, une phrase sur sa famille et ses usages ; la méthode d'Anthropic, mesurée sur ses données.
- **Des fiches mieux écrites** : les mots des clients dans les fiches, sans attendre la technique ( [une base de fiches pour l'assistant](https://meridal.fr/apprendre/base-de-fiches/) ).
- **En production** : la mise à jour des fiches, les droits, le suivi des questions sans réponse ( [un RAG en production](https://meridal.fr/apprendre/rag-en-production/) ).

## Lectures suivantes

↑ Plus général

Rien avant : c'est un point de départ.

← Avant [Un RAG minimal en Python S'exercer · Niveau 3 · 25 minutes](https://meridal.fr/apprendre/rag-minimal-python/)

Vous êtes ici

Projet : un RAG mesuré sur une base de fiches

→ Ensuite [Projet : un agent de réponses clients avec file de validation S'exercer · Niveau 3 · 120 minutes](https://meridal.fr/apprendre/projet-agent-de-reponses-clients/)

Progression par projets · étape 8 sur 12

↓ Plus concret [Projet : un assistant de projet pour une équipe S'exercer · Niveau 1 · 40 minutes](https://meridal.fr/apprendre/projet-assistant-de-projet/)

[Tout le dossier « Projets d'entraînement »](https://meridal.fr/apprendre/dossier/projets/)

Les pages qui renvoient ici

- [Projet : extraction de factures vers un fichier vérifié](https://meridal.fr/apprendre/projet-extraction-de-factures/)

**Limites et sources**

Les fiches, les questions et leurs bonnes réponses sont fabriquées par Meridal ; la recherche par les mots et sa mesure ont été exécutées, la recherche par le sens et la fusion n'ont pas encore été exécutées.

Les chiffres d'Anthropic portent sur ses propres jeux de données ; ils donnent un ordre de grandeur, pas un résultat à attendre sur ces fiches.

1. [Anthropic, Contextual Retrieval](https://www.anthropic.com/news/contextual-retrieval) ; [Cormack et al., la fusion par les rangs réciproques](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) ; [Mistral, plongements](https://docs.mistral.ai/studio/knowledge-rag/embeddings/text_embeddings).
2. Le code et les données : `apprendre/pages/projets/projet-rag-fiches/code/`.

Écrit par Meridal, avec l'aide de l'IA ; chaque fait vérifié à sa source et relu avant publication. Texte et schémas sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.fr) : reprenez-les en citant Meridal. Le code sous licence MIT.

Service correspondant

Un assistant qui connaît votre entreprise

3 300 € à 10 200 €, puis sur devis

[Voir le service](https://meridal.fr/offre/#assistant)

## Un premier échange de 30 minutes

En visio et sans frais : vous décrivez votre situation, nous vous indiquons ce qui est réalisable, avec quels outils et pour quel ordre de prix. Vous pouvez aussi nous écrire ; chaque message reçoit une réponse écrite.

[Réserver 30 minutes](https://meridal.fr/contact/#reserver) [Écrire un message](https://meridal.fr/contact/)
