depiedencap-rag/docs/rag/EVAL.md
grgmro 0549beb156 Docs: define Hit@5 and MRR next to the reported scores
The README table was a pair of unexplained numbers. Spell out recall vs ranking here, and expand EVAL.md §2 with the formulas used on this corpus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 23:16:22 +02:00

392 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Évaluation RAG — protocole et jeu de questions
> **Statut** : actif (13/09/2026). Jeu étendu à **101 questions** sourcées
> depuis la base locale (goldens vérifiés existants en base ; pertinence des
> fils canoniques à valider par un humain du forum).
> Principe : **on mesure par couche** (retrieval ≠ génération), cf. `REFERENCES.md` §5.
>
> **Intention d'exposition** : ce dossier `rag/` (protocole, résultats
> horodatés, tableau comparatif) est un livrable destiné à être **présenté
> publiquement à terme** (Forgejo). Conséquence sur la façon de travailler :
> - chaque run produit un JSON horodaté commité dans `results/` ;
> - le tableau §4 est rempli à chaque config testée, avec notes qualitatives ;
> - les commits racontent le raisonnement (pourquoi une config, ce que la
> mesure a montré) — pas juste la mécanique ;
> - un facteur à la fois (§3), pour que le tableau soit lisible comme une
> démonstration.
## 1. Structure du jeu d'éval
101 questions (visé initial 15–20 dépassé — un corpus plus large donne des
signaux moins bruités), chacune avec ses **golden documents** (fils/messages
qui *devraient* remonter). Format :
```yaml
- id: calceophilie
question: "C'est quoi la calcéophilie ?"
expected_topic_ids: [34091] # /t/du-terme-calceophile/34091
expected_post_ids: [] # vide = grain topic suffit
difficulty: facile
tags: [définition]
- id: <ex-megathread>
question: "<question dont la réponse est un message précis dans un long fil>"
expected_topics: [<topic_id>]
expected_posts: [<post_id>] # le message précis — vrai test grain post
difficulty: megathread
tags: [contexte, anaphore]
```
Couverture réelle (101 questions, `questions.yml` sections A–J) :
| Type de question | n | Ce que ça teste |
|---|---|---|
| Définition / technique fabrication | 7 | retrieval sémantique sur concepts (montages, trépointe…) |
| Avis marques | 24 | naviguer marque→fil canonique, megathreads jusqu'à 2.7k posts |
| Entretien / réparation | 21 | grain fin (cirage, glaçage, décoloration…) |
| Achat / occasion / budget | 8 | intentions transactionnelles |
| Pointure / largeur | 7 | sizing — réponses dispersées en posts |
| Types de chaussures | 12 | taxonomie produit + megathreads couleur |
| Vie du forum / règles | 10 | catégories non « souliers », sujets sensibles |
| Réponse dans un megathread (grain post) | 2 | **le cœur du chantier** — `expected_post_ids` |
| Hors-sujet | 6 | abstention correcte |
| Anaphore / contexte conversationnel | — | hors scope du harness single-turn — test manuel du bot |
| LLM indisponible | — | test manuel (message humoristique) |
## 2. Métriques
Les deux chiffres du README (**Hit@5 = 0.97**, **MRR = 0.89**) mesurent
**uniquement le retrieval** (le bon fil/post remonte-t-il ?), pas la
qualité de la phrase générée. Ils sont calculés sur le jeu golden
(`questions.yml`) : chaque question a un ou plusieurs `expected_topic_ids`
(et parfois `expected_post_ids`).
### Hit@5 (rappel dans le top-5)
Pour une question, le retrieval **réussit** si **au moins un** document
golden apparaît dans les 5 premiers résultats. Hit@5 = cette réussite
moyennée sur le jeu (0 ou 1 par question).
- **0.97** (config E, n=796) : 97 questions sur 100 voient le bon fil dans
le top-5.
- Un Hit@5 à 1.0 avec le bon fil toujours en 5e place resterait « parfait »
au sens du rappel, et mauvais au classement → d'où le MRR à côté.
- Variantes dans les tableaux : **Hit@k topic** (le fil golden est dans le
top-k) ; **Hit@k post** (le *message* golden est dans le top-k, grain
post uniquement). Le chiffre vitrine est le Hit@5 **topic** à l'échelle
(lot K). Les premières passes utilisaient aussi k ∈ {3, 6} sur le lot
curé A–J.
### MRR — Mean Reciprocal Rank (classement du premier hit)
Pour chaque question : `1 / rang` du **premier** résultat pertinent
(1er → 1, 2e → 0.5, 3e → ≈ 0.33, 5e → 0.2). Si le golden est hors de la
liste renvoyée : **0**. MRR = moyenne de ces scores.
- **0.89** (config E) : le premier hit pertinent est en pratique 1er ou
2e, rarement 4e/5e.
- Contrairement au Hit@5, un golden passé de #1 à #5 **fait baisser** le
MRR (1 → 0.2) même s'il reste « dans le top-5 ».
En une ligne : **Hit@5 = est-ce qu'on a raté ?** · **MRR = est-ce qu'on
l'a mis en haut ?** Le saut topic → post (0.68 → 0.94 Hit@5) est le gain
principal ; le rerank (0.94 → 0.97 Hit@5, 0.84 → 0.89 MRR) améliore
surtout l'ordre.
### Retrieval (automatique) — récap
| Métrique | Définition |
|---|---|
| **Hit@5 topic** | au moins un `expected_topic` dans le top-5 (chiffre README) |
| **Hit@k topic** | idem pour un k donné (lots A–J : aussi k ∈ {3, 6}) |
| **Hit@k post** | le `expected_post` dans le top-k posts (grain post uniquement) |
| **MRR** | moyenne de `1/rang` du premier hit pertinent (0 si absent) |
| Pollution | slots evidence occupés par un même topic (mesure l'agrégation) |
### Génération (semi-auto)
| Métrique | Comment |
|---|---|
| Liens cités réels | % `[[n]]` → `Post.exists?` — devrait être 100 % (sanitizer) |
| Citation pertinente | le message cité porte-t-il la réponse ? (humain, 0/1) |
| Fidélité | claims supportées par l'evidence (LLM-juge ou relecture) |
| Abstention correcte | hors-sujet → abstention, pas d'invention |
| CTA présent | footer « Pour aller plus loin » avec invitation à poster |
## 3. Protocole
1. **Baseline `A`** (config live, grain topic) : faire tourner le jeu sur le
forum local → remplir le tableau. C'est la référence à battre.
2. Chaque config (`B`, `C`, `D`, `E`…) : **un seul facteur changé** vs la
config précédente, même jeu, mêmes métriques.
3. Résultats → tableau ci-dessous + notes qualitatives (exemples marquants).
4. Une question qui régresse = on la comprend avant de conclure.
## 4. Tableau de résultats (à remplir)
| Config | Hit@5 topic | Hit@k post | Liens réels | Abstention OK | Notes |
|---|---|---|---|---|---|
| A (live, topics) | **39/95** (kw 17/95, sem 32/95) | n/a | | | 13/09 v2, goldens corrigés — hybrid=41% |
| B (posts natifs) | **88/95** | post exact `#1` ×2 (carmina-sizing, glaçage-sèche-cheveux) | | | 13/09 v2 — 425k posts embeddés |
| C (posts enrichis) | **0.85** (n=796) | — | | | 14/09 — **mesuré, rejeté** : enrichir le texte embeddé (extrait OP + parent) dégrade vs B (0.94) — dilution du contenu du post, §4quater |
| D (+ voisinage) | | | | | côté génération (contexte LLM), pas Hit@k |
| E (+ rerank) | **97%** (n=796) | `p✓` conservés | | | 13/09 — cross-encoder CPU, Hit@5=0.97/MRR=0.89 (rrf rerank×hybrid) — détail §4ter |
| F (fusions RRF) | **87-88/95** | `p✓` ×2 conservés en #1 | | | 13/09 v2 — posts×hybrid MRR **0.80** |
| G (routing taille) | 84-88/95 | `p✓` ×2 | | | 13/09 — **rejeté** : dégrade vs F, le RRF route déjà implicitement |
Constats routing par taille (13/09, seuils 25/50/100/250 posts) : filtrer les
topics > N posts du canal topic avant fusion **dégrade monotonement** (MRR
0.68@25 → 0.76@250 < 0.80 sans filtre). Les longs fils n'obtiennent de toute
façon quasiment jamais de vote « topic » (embedding dilué = invisible), donc
il n'y a pas de bruit à retirer ; couper les fils moyens retire des votes
utiles. **Le RRF implémente déjà l'esprit du routing** : un megathread ne
remonte que via ses posts, un fil court profite des deux canaux. Variante
documentée puis abandonnée — résultat négatif, gardé pour la démonstration.
Constats baseline A→B (13/09, n=95, goldens v2) : le canal posts natif seul
fait **Hit@5 0.93 / MRR 0.77** vs hybrid topic 0.41. Correction de goldens
par pooling (13/09) : les « échecs » initiaux remontaient des fils **valides**
absents de la liste attendue — élargis pour `corthay-avis`, `noeuds-lacets`,
`presentation-nouveau-membre`, `bienvenue-nouveau` ; `richelieu-bout-rapporte`
reformulé (un membre nomme la marque). **Leçon méthodo : l'écart initial
0.87→0.93 vient de la complétude des goldens, pas du retrieval** — tenir les
goldens à jour est la partie la plus fragile du benchmark.
Constats fusions (13/09) : RRF posts×{semantic, hybrid} **ne gagne pas de
rappel** (posts suffit) mais **améliore le MRR** (0.77→0.80) et rescute des
échecs posts (`loding-pointure` #23→**#1**, `grande-pointure` #11→#1 — le
sizing dilué en posts profite du signal topic sémantique). RRF posts×keyword
**dégrade** (bruit BM25 : MRR 0.74). Pollution canal posts : top-5 = 2.8
topics distincts en moyenne, topic dominant 63 % des slots.
Constats baseline A (12/09) : le megathread Carmina (2690 posts) absent des
top-20 sémantiques **et** top-25 keyword → la dilution topic-embedding est
réelle et mesurée. La gate titre (`relevance_score` exige un mot distinctif
dans le titre) rejette aussi des fils pertinents dont le titre ne contient
pas le mot-clé — à garder en tête quand on lira les métriques post-grain.
## 4bis. Confirmation à grande échelle (812 questions, 13/09)
Le lot A-J (101 questions curées, goldens vérifiés par pooling) a été
complété par un **lot généré à l'échelle** (section K de `questions.yml`,
+701 questions + 10 hors-sujet, sourcées par mining large de toutes les
catégories/tags de la base — méthodologie différente, documentée en tête
de section, golden = topic source unique sans union multi-fils). Objectif :
vérifier que les résultats du lot curé ne sont pas un artefact de petit
échantillon.
| Canal | Hit@5 (n=796) | MRR |
|---|---|---|
| semantic (topic) | 0.55 | 0.43 |
| keyword (topic) | 0.41 | 0.38 |
| posts natifs | **0.94** | **0.84** |
| hybrid (prod actuelle) | 0.68 | 0.63 |
| rrf posts×hybrid | **0.95** | **0.88** |
| routed_250 | 0.95 | 0.86 |
| posts enrichis (C) | 0.85 | 0.80 |
| rrf enriched×hybrid | 0.92 | 0.85 |
| rerank enriched | 0.91 | 0.86 |
**Confirmé** : l'avantage posts natifs / fusion RRF tient à l'échelle, en
s'améliorant légèrement (bruit statistique réduit). **Écart notable vs le
lot curé** : `hybrid` prod fait 0.68 ici contre 0.41 sur A-J — le lot généré
contient beaucoup de titres très distinctifs (marques/produits nommés) que
le BM25 matche facilement, alors que A-J teste délibérément des
reformulations et megathreads plus durs. Les deux mesures sont
complémentaires : A-J documente les cas fins, K mesure la robustesse
générale. Le résultat du routing par taille (rejeté sur A-J) est confirmé
rejeté ici aussi (routed_250 < rrf_post_hyb sans filtre).
## 4ter. Config E — rerank cross-encoder (13/09, n=796)
Modèle : `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` (multilingue, léger,
CPU — chargé une fois via `sentence-transformers`, `--rerank` dans
`run_eval.py`). Re-trie le top-25 du canal posts (`{titre}. {300 premiers
caractères du post}` vs question) avant dédup par topic.
| Canal | Hit@5 | MRR |
|---|---|---|
| posts natifs | 0.94 | 0.84 |
| rrf posts×hybrid | 0.95 | 0.88 |
| **rerank** | **0.97** | 0.88 |
| **rrf rerank×hybrid** | **0.97** | **0.89** |
Le rerank gagne surtout en **rappel** (0.94→0.97 Hit@5) : il remonte des
posts pertinents mal classés par la seule distance cosine, déjà présents
dans le top-25 mais pas dans le top-5. Gain net mais incrémental par
rapport au saut initial topic→post (qui reste le facteur dominant du
chantier). `eval_retrieval.rb` expose désormais `text` (titre + extrait
300c) par post pour permettre le rerank côté hôte sans étape supplémentaire
côté conteneur.
## 4quater. Config C (posts enrichis) — mesuré, rejeté (14/09, n=796)
Implémentée pour la vitrine (métriques comparables sur tout le dataset) :
backfill complet `strategy_id=2` sur les 425 505 posts
(`backfill_enriched.rb`, ~3h GPU). Texte embeddé = contexte natif
(titre+cat+tags) **+ extrait 500c du message initial du fil + extrait 400c
du post parent** (`reply_to_post_number`), tronqué à max_seq−2 comme le
natif. Hypothèse : désambiguïser les réponses courtes (« pareil en 8.5 »)
que le post nu ne contextualise pas.
| Canal | Hit@5 | MRR |
|---|---|---|
| posts natifs (s1) | **0.94** | **0.84** |
| **posts enrichis (s2)** | 0.85 | 0.80 |
| rrf posts×hybrid | **0.95** | **0.88** |
| rrf enriched×hybrid | 0.92 | 0.85 |
| rerank (s1) | **0.97** | 0.88 |
| rerank enriched (s2) | 0.91 | 0.86 |
**Résultat négatif, uniforme sur toutes les variantes** : l'enrichissement
coûte ~9 pts de Hit@5 et ~4 pts de MRR au canal seul, et la dégradation se
propage aux fusions et au rerank. Interprétation : l'extrait OP/parent
consomme du budget de tokens et **dilue le contenu propre du post** dans
le vecteur — le bruit contextuel nuit plus que la désambiguïsation n'aide.
Cas typique : `scaled-faq-les-diff-rents-montages` — le golden (36778)
est #1 en s1 mais **sort du top-25 en s2** : le contexte OP mutualisé
uniformise les vecteurs des posts d'un même fil et fait basculer vers un
fil voisin (36856) qui squatte les 5 premiers slots à distance ~égale.
**Décision : rejetée sur mesure.** Le post nu (titre+cat+tags+contenu) est
la bonne granularité. Le contexte a sa place **au moment de la génération**
(config D — voisinage injecté dans le prompt LLM, sans toucher aux
embeddings), pas dans le vecteur indexé.
Note dataset : les échecs résiduels (~3 % après rerank) restent dominés par
les titres génériques du lot K (« Présentation » : 1671 fils proches en
base, « Question » : 211, « ceinture » : 123) dont le golden single-topic
est structurellement ambigu — limite de mesure, pas du retrieval.
## 4quinquies. Éval FULL — génération réelle (14/09, n=17→20)
Premier test bout-en-bout avec le **vrai LLM de prod** (`qwen3:30b-a3b-q6k`,
vLLM mini PC `:32134`). `eval_full.rb` émet par question l'evidence prod
(`instructions_block`/`for_question` tels quels) + le top-25 posts natifs ;
`run_full_eval.py` reconstruit le bloc « new » (posts+rerank, même gabarit),
appelle le LLM avec `system = persona + block`, et recueille la réponse +
les marqueurs `[[n]]` pour validation. Digest lisible dans
`results/*-full-*.yml`.
### Enseignements (17 questions : 14 in-domain, 3 hors-sujet)
| | prod (grain topic) | new (posts+rerank) |
|---|---|---|
| in-domain | répond sur 12/14 ; **abstention à tort ×2** (`montage-norvegien-def`, `carmina-sizing-megathread` — evidence vide) | répond sur 14/14 |
| hors-sujet | s'abstient ×3 proprement | **hallucine ×3** avec citations (recette inventée, « 42 °C à Paris », « comité d'investissement DPEC ») |
| citations invalides | 2 (`scaled-aston-martin` cite [[2]][[3]] avec 1 seule source) | 0 |
| latence LLM | ~3-10 s/réponse (mini PC) | idem |
**Constat central** : la gate titre de prod servait d'abstention *par
accident* — hors-sujet → aucun mot distinctif dans les titres → evidence
vide → abstention. En la supprimant, on gagne le rappel in-domain (les 2
abstentions à tort sont corrigées) mais on **perd la protection
out-of-domain** : le canal posts retourne toujours 25 candidats et le LLM
les exploite, même hors propos.
**Le score CE seul ne tranche pas proprement** (calibration 14/09) :
top-1 CE sur 796 questions in-domain : min −4.74, p25 +0.42, 4/796 < −4 ;
sur 16 hors-sujet : −5.9 → **+2.77**. L'overlap vient du Comptoir —
le forum parle cuisine, vin, cinéma : « bœuf bourguignon » a des fils
tangentiels légitimes (cinema hors-sujet = +2.77). Un seuil −4 coupe les
cas extrêmes sans faux négatif mesuré in-domain, mais laisse passer la
zone grise ; c'est autant une décision **produit** (le bot doit-il répondre
aux questions Comptoir ?) qu'un réglage technique.
**À décider pour le design final de l'abstention** (options, pas encore
tranché) : seuil CE calibré, nombre minimal de sources au-dessus d'un
score, deuxième signal lexical, ou catégories à exclure du retrieval.
L'éval full a rempli son rôle : révéler ce déficit que Hit@k ne mesure
pas — 0.97 de rappel ≠ réponses sûres.
### 4.4 Solution mesurée : zone graduée (weak evidence) — 14/09 run `weak-zone`
Le gate binaire (evidence pleine / vide) est remplacé par **trois zones**
sur le score CE top-1 : `< −4` → evidence vide (abstention) ;
`[−4, +1[` → bloc `WEAK_EVIDENCE` : les sources sont fournies mais avec
l'instruction d'assumer la pertinence faible (« dis honnêtement qu'aucun
extrait ne répond vraiment, pointe le fil le plus proche avec prudence,
invite à poster ») ; `≥ +1` → evidence normale.
Résultat sur les 17 cas représentatifs (`--variants new`,
`--weak-threshold 1.0`) :
- **les 3 hallucinations hors-sujet disparaissent** : cuisine → « aucun
extrait ne fournit une recette » ; meteo → « aucune information
précise » ; finance → « aucune recommandation disponible ». Plus de
« 42 °C » ni de « comité d'investissement DPEC » affirmés.
- **aucune régression in-domain** : `calceophilie` (CE −2.32, zone
faible !), `bannissement` (−1.87), `carmina-megathread` (−0.91),
`presentation` (−0.21) répondent tous correctement — le cadrage gradué
ne transforme pas les questions légitimes à CE bas en abstentions.
- les cas confiants (CE ≥ +1, incluant Comptoir : aston-martin +3.8,
finsbury +3.4) répondent normalement.
Enseignement clé : l'overlap des distributions CE (in-domain min −4.7 vs
hors-sujet max +2.8) rend tout seuil dur imparfait — **mais la zone
graduée n'a pas besoin de séparer proprement** : les cas ambigus sont
traités avec honnêteté plutôt que tranchés. C'est la réponse mesurée au
problème « grain post sans protection OOD » relevé en §4.3.
**Attention à l'arbitrage qualité/sécurité** — distribution CE top-1
recomputée sur les 796 questions in-domain (offline, sans LLM) :
| Seuil weak | % questions légitimes en zone « prudente » |
|---|---|
| +2.0 | 33 % |
| +1.0 | **25 %** (valeur du premier test — trop haut) |
| **0.0** | **17 %** |
| −1.0 | 10 % |
| −4.0 (abstention) | 0 % |
Les 3 hors-sujet mesurés (−0.74, −0.46, −3.60) tombent tous dans
`[−4, 0)` → seuil **0.0** capture les mêmes cas en divisant presque par
deux l'exposition des questions légitimes (25 % → 17 %). Le coût du
bloc WEAK est stylistique (réponse plus prudente), pas une abstention —
les questions légitimes à CE bas y répondent correctement (calceophilie
−2.3). Arbitrage à confirmer par run A/B à l'échelle.
Fichier : `results/20260914-115544-weak-zone.*`. Voir aussi `SAFETY.md`
(axe refus/abus — complémentaire : le weak-block gère la pertinence
faible, la clause de refus gère les demandes hors-rôle).
## 5. Harness
`rag/eval/` :
- `questions.yml` — jeu de questions + `expected_topic_ids` (golden). Le
seed initial est **à valider/enrichir** (certaines attendues sont des
suppositions — vérifier que le fil listé est bien le canonique).
- `safety.yml` — sondes de refus/injection (axe sécurité, cf. `SAFETY.md`).
- `input_gate.yml` — règles regex du gate d'entrée (intention, pas sujet).
- `prompt_clauses/` — variantes de system prompt testables sans toucher
la DB (`--system-append`).
- `eval_retrieval.rb` — tourné **dans le conteneur** (`rails runner`) :
rejoue `Retrieval` sans cache, décompose semantic / keyword / hybrid.
- `run_eval.py` — côté hôte : copie le ruby, exécute, calcule Hit@k/MRR
par canal, sauvegarde `results/<date>-<label>.json`.
- `eval_full.rb` + `run_full_eval.py` — éval **bout-en-bout** : retrieval
prod vs posts+rerank, vrai LLM (mini PC), validation des citations,
gate, zone faible, injection d'evidence.
```bash
python3 -m venv rag/eval/.venv && rag/eval/.venv/bin/pip install pyyaml
rag/eval/.venv/bin/python rag/eval/run_eval.py --label baseline-topic
# options : --k 6 --container app --questions rag/eval/questions.yml
# pour --rerank (config E, cross-encoder CPU, ~1.5 Go RAM au chargement) :
rag/eval/.venv/bin/pip install sentence-transformers
rag/eval/.venv/bin/python rag/eval/run_eval.py --label with-rerank --rerank
# éval génération bout-en-bout (vrai LLM mini PC, ~10 s/question) :
rag/eval/.venv/bin/python rag/eval/run_full_eval.py \
--questions rag/eval/safety.yml --label safety-v3 \
--input-gate rag/eval/input_gate.yml \
--system-append rag/eval/prompt_clauses/refusal.txt \
--weak-threshold 0.0
# options : --n 20 --ids a,b,c --variants prod,new --abstain-threshold -4
```
> Les métriques retrieval tournent **sans LLM** → boucle d'itération rapide.
> L'éval génération appelle le vrai `qwen3:30b` du mini PC via Tailscale —
> compter ~10 s par question et par variante.