GitHub and Forgejo only i18n the root README. Public rag pages now have *.en.md siblings so English links stay in English; French files keep the unprefixed names. Co-authored-by: Cursor <cursoragent@cursor.com>
395 lines
19 KiB
Markdown
395 lines
19 KiB
Markdown
# RAG evaluation — protocol and question set
|
||
|
||
📘 Cette page est également disponible en [français 🇫🇷](./EVAL.md)
|
||
|
||
> **Status**: active (13/09/2026). Set expanded to **101 questions** sourced
|
||
> from the local DB (goldens verified as existing in the DB; relevance of
|
||
> canonical threads still to be validated by a forum human).
|
||
> Principle: **measure by layer** (retrieval ≠ generation), see
|
||
> [`REFERENCES.en.md`](REFERENCES.en.md) §5.
|
||
>
|
||
> **Public-facing intent**: this `rag/` folder (protocol, timestamped
|
||
> results, comparison table) is a deliverable meant to be **shown publicly
|
||
> later** (Forgejo). Consequences for how we work:
|
||
> - each run produces a timestamped JSON committed in `results/`;
|
||
> - the §4 table is filled for every tested config, with qualitative notes;
|
||
> - commits tell the reasoning (why a config, what the measurement showed)
|
||
> — not just the mechanics;
|
||
> - one factor at a time (§3), so the table reads as a demonstration.
|
||
|
||
## 1. Eval-set structure
|
||
|
||
101 questions (initial 15–20 target exceeded — a larger corpus gives less
|
||
noisy signals), each with **golden documents** (threads/messages that
|
||
*should* come back). Format:
|
||
|
||
```yaml
|
||
- id: calceophilie
|
||
question: "C'est quoi la calcéophilie ?"
|
||
expected_topic_ids: [34091] # /t/du-terme-calceophile/34091
|
||
expected_post_ids: [] # empty = topic grain is enough
|
||
difficulty: facile
|
||
tags: [définition]
|
||
|
||
- id: <ex-megathread>
|
||
question: "<question whose answer is a precise message in a long thread>"
|
||
expected_topics: [<topic_id>]
|
||
expected_posts: [<post_id>] # the precise message — the real post-grain test
|
||
difficulty: megathread
|
||
tags: [contexte, anaphore]
|
||
```
|
||
|
||
Actual coverage (101 questions, `questions.yml` sections A–J):
|
||
|
||
| Question type | n | What it tests |
|
||
|---|---|---|
|
||
| Definition / manufacturing technique | 7 | semantic retrieval on concepts (constructions, gemming…) |
|
||
| Brand opinions | 24 | brand→canonical thread, megathreads up to 2.7k posts |
|
||
| Care / repair | 21 | fine grain (wax, glazing, fading…) |
|
||
| Buying / second-hand / budget | 8 | transactional intents |
|
||
| Sizing / width | 7 | sizing — answers scattered across posts |
|
||
| Shoe types | 12 | product taxonomy + colour megathreads |
|
||
| Forum life / rules | 10 | non-"shoes" categories, sensitive topics |
|
||
| Answer inside a megathread (post grain) | 2 | **the core of the workstream** — `expected_post_ids` |
|
||
| Off-topic | 6 | correct abstention |
|
||
| Anaphora / conversational context | — | out of scope of the single-turn harness — manual bot test |
|
||
| LLM unavailable | — | manual test (humorous message) |
|
||
|
||
## 2. Metrics
|
||
|
||
The two README numbers (**Hit@5 = 0.97**, **MRR = 0.89**) measure
|
||
**retrieval only** (does the right thread/post come back?), not the quality
|
||
of the generated sentence. They are computed on the golden set
|
||
(`questions.yml`): each question has one or more `expected_topic_ids`
|
||
(and sometimes `expected_post_ids`).
|
||
|
||
### Hit@5 (recall in the top-5)
|
||
|
||
For one question, retrieval **succeeds** if **at least one** golden
|
||
document appears in the first 5 results. Hit@5 = that success averaged
|
||
over the set (0 or 1 per question).
|
||
|
||
- **0.97** (config E, n=796): 97 questions out of 100 see the right
|
||
thread in the top-5.
|
||
- A Hit@5 of 1.0 with the right thread always in 5th place would still
|
||
be "perfect" as recall, and bad as ranking → hence MRR beside it.
|
||
- Variants in the tables: **Hit@k topic** (the golden thread is in the
|
||
top-k); **Hit@k post** (the golden *message* is in the top-k, post grain
|
||
only). The headline figure is scale Hit@5 **topic** (batch K). Early
|
||
passes also used k ∈ {3, 6} on curated batch A–J.
|
||
|
||
### MRR — Mean Reciprocal Rank (ranking of the first hit)
|
||
|
||
For each question: `1 / rank` of the **first** relevant result
|
||
(1st → 1, 2nd → 0.5, 3rd → ≈ 0.33, 5th → 0.2). If the golden is missing
|
||
from the returned list: **0**. MRR = mean of those scores.
|
||
|
||
- **0.89** (config E): the first relevant hit is in practice 1st or 2nd,
|
||
rarely 4th/5th.
|
||
- Unlike Hit@5, a golden moving from #1 to #5 **lowers** MRR (1 → 0.2)
|
||
even if it stays "in the top-5".
|
||
|
||
In one line: **Hit@5 = did we miss it?** · **MRR = did we put it first?**
|
||
The topic → post jump (0.68 → 0.94 Hit@5) is the main gain; rerank
|
||
(0.94 → 0.97 Hit@5, 0.84 → 0.89 MRR) mainly improves order.
|
||
|
||
### Retrieval (automatic) — recap
|
||
|
||
| Metric | Definition |
|
||
|---|---|
|
||
| **Hit@5 topic** | at least one `expected_topic` in the top-5 (README figure) |
|
||
| **Hit@k topic** | same for a given k (batches A–J: also k ∈ {3, 6}) |
|
||
| **Hit@k post** | the `expected_post` in the top-k posts (post grain only) |
|
||
| **MRR** | mean of `1/rank` of the first relevant hit (0 if missing) |
|
||
| Pollution | evidence slots occupied by the same topic (measures aggregation) |
|
||
|
||
### Generation (semi-auto)
|
||
|
||
| Metric | How |
|
||
|---|---|
|
||
| Real cited links | % `[[n]]` → `Post.exists?` — should be 100 % (sanitizer) |
|
||
| Relevant citation | does the cited message carry the answer? (human, 0/1) |
|
||
| Faithfulness | claims supported by the evidence (LLM-judge or review) |
|
||
| Correct abstention | off-topic → abstain, no invention |
|
||
| CTA present | "To go further" footer with an invitation to post |
|
||
|
||
## 3. Protocol
|
||
|
||
1. **Baseline `A`** (live config, topic grain): run the set on the local
|
||
forum → fill the table. That is the bar to beat.
|
||
2. Each config (`B`, `C`, `D`, `E`…): **one factor changed** vs the
|
||
previous config, same set, same metrics.
|
||
3. Results → table below + qualitative notes (striking examples).
|
||
4. A question that regresses = we understand it before concluding.
|
||
|
||
## 4. Results table (to fill)
|
||
|
||
| Config | Hit@5 topic | Hit@k post | Real links | Abstention OK | Notes |
|
||
|---|---|---|---|---|---|
|
||
| A (live, topics) | **39/95** (kw 17/95, sem 32/95) | n/a | | | 13/09 v2, goldens corrected — hybrid=41% |
|
||
| B (native posts) | **88/95** | exact post `#1` ×2 (carmina-sizing, glaçage-sèche-cheveux) | | | 13/09 v2 — 425k posts embedded |
|
||
| C (enriched posts) | **0.85** (n=796) | — | | | 14/09 — **measured, rejected**: enriching the embedded text (OP + parent excerpt) degrades vs B (0.94) — dilution of post content, §4quater |
|
||
| D (+ neighbourhood) | | | | | generation side (LLM context), not Hit@k |
|
||
| E (+ rerank) | **97%** (n=796) | `p✓` kept | | | 13/09 — CPU cross-encoder, Hit@5=0.97/MRR=0.89 (rrf rerank×hybrid) — detail §4ter |
|
||
| F (RRF fusions) | **87-88/95** | `p✓` ×2 kept at #1 | | | 13/09 v2 — posts×hybrid MRR **0.80** |
|
||
| G (size routing) | 84-88/95 | `p✓` ×2 | | | 13/09 — **rejected**: degrades vs F, RRF already routes implicitly |
|
||
|
||
Size-routing findings (13/09, thresholds 25/50/100/250 posts): filtering
|
||
topics > N posts out of the topic channel before fusion **degrades
|
||
monotonically** (MRR 0.68@25 → 0.76@250 < 0.80 with no filter). Long
|
||
threads almost never get a "topic" vote anyway (diluted embedding =
|
||
invisible), so there is no noise to remove; cutting medium threads removes
|
||
useful votes. **RRF already implements the spirit of routing**: a
|
||
megathread only comes back via its posts, a short thread benefits from
|
||
both channels. Variant documented then dropped — negative result, kept
|
||
for the demonstration.
|
||
|
||
Baseline A→B findings (13/09, n=95, goldens v2): the native posts channel
|
||
alone does **Hit@5 0.93 / MRR 0.77** vs topic hybrid 0.41. Golden
|
||
correction by pooling (13/09): the initial "failures" were bringing back
|
||
**valid** threads missing from the expected list — widened for
|
||
`corthay-avis`, `noeuds-lacets`, `presentation-nouveau-membre`,
|
||
`bienvenue-nouveau`; `richelieu-bout-rapporte` rephrased (a member names
|
||
the brand). **Method lesson: the initial 0.87→0.93 gap comes from golden
|
||
completeness, not retrieval** — keeping goldens current is the most
|
||
fragile part of the benchmark.
|
||
|
||
Fusion findings (13/09): RRF posts×{semantic, hybrid} **does not gain
|
||
recall** (posts is enough) but **improves MRR** (0.77→0.80) and rescues
|
||
posts failures (`loding-pointure` #23→**#1**, `grande-pointure` #11→#1 —
|
||
sizing diluted in posts benefits from the semantic topic signal). RRF
|
||
posts×keyword **degrades** (BM25 noise: MRR 0.74). Posts-channel
|
||
pollution: top-5 = 2.8 distinct topics on average, dominant topic 63 % of
|
||
slots.
|
||
|
||
Baseline A findings (12/09): the Carmina megathread (2690 posts) absent
|
||
from semantic top-20 **and** keyword top-25 → topic-embedding dilution is
|
||
real and measured. The title gate (`relevance_score` requires a
|
||
distinctive word in the title) also rejects relevant threads whose title
|
||
does not contain the keyword — keep that in mind when reading post-grain
|
||
metrics.
|
||
|
||
## 4bis. Scale confirmation (812 questions, 13/09)
|
||
|
||
Batch A-J (101 curated questions, goldens verified by pooling) was
|
||
completed by a **scale-generated batch** (section K of `questions.yml`,
|
||
+701 questions + 10 off-topic, sourced by mining all categories/tags in
|
||
the DB — different methodology, documented at the top of the section,
|
||
golden = single source topic without multi-thread union). Goal: check
|
||
that curated-batch results are not a small-sample artefact.
|
||
|
||
| Channel | Hit@5 (n=796) | MRR |
|
||
|---|---|---|
|
||
| semantic (topic) | 0.55 | 0.43 |
|
||
| keyword (topic) | 0.41 | 0.38 |
|
||
| native posts | **0.94** | **0.84** |
|
||
| hybrid (then-current prod) | 0.68 | 0.63 |
|
||
| rrf posts×hybrid | **0.95** | **0.88** |
|
||
| routed_250 | 0.95 | 0.86 |
|
||
| enriched posts (C) | 0.85 | 0.80 |
|
||
| rrf enriched×hybrid | 0.92 | 0.85 |
|
||
| rerank enriched | 0.91 | 0.86 |
|
||
|
||
**Confirmed**: the native-posts / RRF-fusion advantage holds at scale,
|
||
slightly improving (less statistical noise). **Notable gap vs the curated
|
||
batch**: prod `hybrid` scores 0.68 here vs 0.41 on A-J — the generated
|
||
batch contains many highly distinctive titles (named brands/products)
|
||
that BM25 matches easily, while A-J deliberately tests rephrasings and
|
||
harder megathreads. The two measurements are complementary: A-J documents
|
||
fine cases, K measures general robustness. Size routing (rejected on A-J)
|
||
is confirmed rejected here too (routed_250 < rrf_post_hyb with no filter).
|
||
|
||
## 4ter. Config E — cross-encoder rerank (13/09, n=796)
|
||
|
||
Model: `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` (multilingual, light,
|
||
CPU — loaded once via `sentence-transformers`, `--rerank` in
|
||
`run_eval.py`). Re-sorts the posts-channel top-25 (`{title}. {first 300
|
||
characters of the post}` vs question) before topic dedup.
|
||
|
||
| Channel | Hit@5 | MRR |
|
||
|---|---|---|
|
||
| native posts | 0.94 | 0.84 |
|
||
| rrf posts×hybrid | 0.95 | 0.88 |
|
||
| **rerank** | **0.97** | 0.88 |
|
||
| **rrf rerank×hybrid** | **0.97** | **0.89** |
|
||
|
||
Rerank mainly gains **recall** (0.94→0.97 Hit@5): it promotes relevant
|
||
posts poorly ranked by cosine distance alone, already in the top-25 but
|
||
not in the top-5. Net but incremental gain versus the initial topic→post
|
||
jump (still the dominant factor of the workstream). `eval_retrieval.rb`
|
||
now exposes `text` (title + 300c excerpt) per post so the host can rerank
|
||
without an extra step inside the container.
|
||
|
||
## 4quater. Config C (enriched posts) — measured, rejected (14/09, n=796)
|
||
|
||
Implemented for the vitrine (comparable metrics on the whole dataset):
|
||
full `strategy_id=2` backfill on 425 505 posts (`backfill_enriched.rb`,
|
||
~3h GPU). Embedded text = native context (title+cat+tags) **+ 500c
|
||
excerpt of the thread's first message + 400c excerpt of the parent post**
|
||
(`reply_to_post_number`), truncated at max_seq−2 like the native strategy.
|
||
Hypothesis: disambiguate short replies ("same in 8.5") that the bare post
|
||
does not contextualise.
|
||
|
||
| Channel | Hit@5 | MRR |
|
||
|---|---|---|
|
||
| native posts (s1) | **0.94** | **0.84** |
|
||
| **enriched posts (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 |
|
||
|
||
**Negative result, uniform across all variants**: enrichment costs ~9
|
||
Hit@5 points and ~4 MRR points on the channel alone, and the degradation
|
||
propagates to fusions and rerank. Interpretation: the OP/parent excerpt
|
||
consumes token budget and **dilutes the post's own content** in the
|
||
vector — contextual noise hurts more than disambiguation helps. Typical
|
||
case: `scaled-faq-les-diff-rents-montages` — the golden (36778) is #1 in
|
||
s1 but **drops out of the top-25 in s2**: the shared OP context
|
||
homogenises vectors of posts in the same thread and flips toward a
|
||
neighbouring thread (36856) that occupies the first 5 slots at ~equal
|
||
distance.
|
||
|
||
**Decision: rejected on measurement.** The bare post (title+cat+tags+body)
|
||
is the right granularity. Context belongs **at generation time**
|
||
(config D — neighbourhood injected into the LLM prompt, without touching
|
||
embeddings), not in the indexed vector.
|
||
|
||
Dataset note: residual failures (~3 % after rerank) remain dominated by
|
||
generic titles in batch K ("Présentation": 1671 close threads in the DB,
|
||
"Question": 211, "ceinture": 123) whose single-topic golden is
|
||
structurally ambiguous — a measurement limit, not a retrieval one.
|
||
|
||
## 4quinquies. FULL eval — real generation (14/09, n=17→20)
|
||
|
||
First end-to-end test with the **real prod LLM** (`qwen3:30b-a3b-q6k`,
|
||
vLLM mini PC `:32134`). `eval_full.rb` emits per question the prod
|
||
evidence (`instructions_block`/`for_question` as-is) + the native-posts
|
||
top-25; `run_full_eval.py` rebuilds the "new" block (posts+rerank, same
|
||
template), calls the LLM with `system = persona + block`, and collects
|
||
the answer + `[[n]]` markers for validation. Readable digest in
|
||
`results/*-full-*.yml`.
|
||
|
||
### Lessons (17 questions: 14 in-domain, 3 off-topic)
|
||
|
||
| | prod (topic grain) | new (posts+rerank) |
|
||
|---|---|---|
|
||
| in-domain | answers 12/14; **wrong abstention ×2** (`montage-norvegien-def`, `carmina-sizing-megathread` — empty evidence) | answers 14/14 |
|
||
| off-topic | abstains ×3 cleanly | **hallucinates ×3** with citations (invented recipe, "42 °C in Paris", "DPEC investment committee") |
|
||
| invalid citations | 2 (`scaled-aston-martin` cites [[2]][[3]] with only 1 source) | 0 |
|
||
| LLM latency | ~3-10 s/answer (mini PC) | same |
|
||
|
||
**Central finding**: the prod title gate was serving as abstention *by
|
||
accident* — off-topic → no distinctive word in titles → empty evidence →
|
||
abstention. Removing it gains in-domain recall (the 2 wrong abstentions
|
||
are fixed) but **loses out-of-domain protection**: the posts channel
|
||
always returns 25 candidates and the LLM uses them, even off-brief.
|
||
|
||
**CE score alone does not split cleanly** (calibration 14/09):
|
||
top-1 CE on 796 in-domain questions: min −4.74, p25 +0.42, 4/796 < −4;
|
||
on 16 off-topic: −5.9 → **+2.77**. The overlap comes from Le Comptoir —
|
||
the forum talks food, wine, cinema: "bœuf bourguignon" has legitimate
|
||
tangential threads (off-topic cinema = +2.77). A −4 threshold cuts
|
||
extreme cases with no measured in-domain false negative, but lets the
|
||
grey zone through; that is as much a **product** decision (should the
|
||
bot answer Comptoir questions?) as a technical knob.
|
||
|
||
**To decide for the final abstention design** (options, not yet settled):
|
||
calibrated CE threshold, minimum number of sources above a score, second
|
||
lexical signal, or categories to exclude from retrieval. The full eval
|
||
did its job: reveal the gap Hit@k does not measure — 0.97 recall ≠ safe
|
||
answers.
|
||
|
||
### 4.4 Measured solution: graduated zone (weak evidence) — 14/09 run `weak-zone`
|
||
|
||
The binary gate (full evidence / empty) is replaced by **three zones**
|
||
on top-1 CE score: `< −4` → empty evidence (abstention);
|
||
`[−4, +1[` → `WEAK_EVIDENCE` block: sources are provided but with the
|
||
instruction to own the weak relevance ("honestly say no excerpt really
|
||
answers, point to the closest thread with caution, invite to post");
|
||
`≥ +1` → normal evidence.
|
||
|
||
Result on the 17 representative cases (`--variants new`,
|
||
`--weak-threshold 1.0`):
|
||
|
||
- **the 3 off-topic hallucinations disappear**: cooking → "no excerpt
|
||
provides a recipe"; weather → "no precise information"; finance → "no
|
||
recommendation available". No more asserted "42 °C" or "DPEC
|
||
investment committee".
|
||
- **no in-domain regression**: `calceophilie` (CE −2.32, weak zone!),
|
||
`bannissement` (−1.87), `carmina-megathread` (−0.91),
|
||
`presentation` (−0.21) all answer correctly — graduated framing does
|
||
not turn legitimate low-CE questions into abstentions.
|
||
- confident cases (CE ≥ +1, including Comptoir: aston-martin +3.8,
|
||
finsbury +3.4) answer normally.
|
||
|
||
Key lesson: overlapping CE distributions (in-domain min −4.7 vs
|
||
off-topic max +2.8) make any hard threshold imperfect — **but the
|
||
graduated zone does not need a clean split**: ambiguous cases are
|
||
handled honestly rather than cut. That is the measured answer to the
|
||
"post grain without OOD protection" problem in §4.3.
|
||
|
||
**Watch the quality/safety trade-off** — top-1 CE distribution recomputed
|
||
on the 796 in-domain questions (offline, no LLM):
|
||
|
||
| Weak threshold | % legitimate questions in the "cautious" zone |
|
||
|---|---|
|
||
| +2.0 | 33 % |
|
||
| +1.0 | **25 %** (first-test value — too high) |
|
||
| **0.0** | **17 %** |
|
||
| −1.0 | 10 % |
|
||
| −4.0 (abstention) | 0 % |
|
||
|
||
The 3 measured off-topic cases (−0.74, −0.46, −3.60) all fall in
|
||
`[−4, 0)` → threshold **0.0** captures the same cases while almost
|
||
halving exposure of legitimate questions (25 % → 17 %). The cost of the
|
||
WEAK block is stylistic (more cautious answer), not an abstention —
|
||
legitimate low-CE questions still answer correctly (calceophilie −2.3).
|
||
Trade-off to confirm with a scale A/B run.
|
||
|
||
File: `results/20260914-115544-weak-zone.*`. See also
|
||
[`SAFETY.en.md`](SAFETY.en.md) (refusal/abuse axis — complementary: the
|
||
weak block handles weak relevance, the refusal clause handles out-of-role
|
||
requests).
|
||
|
||
## 5. Harness
|
||
|
||
`rag/eval/`:
|
||
|
||
- `questions.yml` — question set + `expected_topic_ids` (golden). The
|
||
initial seed is **to validate/enrich** (some expected items are
|
||
guesses — check that the listed thread is the canonical one).
|
||
- `safety.yml` — refusal/injection probes (safety axis, see
|
||
[`SAFETY.en.md`](SAFETY.en.md)).
|
||
- `input_gate.yml` — input-gate regex rules (intent, not topic).
|
||
- `prompt_clauses/` — system-prompt variants testable without touching
|
||
the DB (`--system-append`).
|
||
- `eval_retrieval.rb` — run **inside the container** (`rails runner`):
|
||
replays `Retrieval` without cache, splits semantic / keyword / hybrid.
|
||
- `run_eval.py` — host side: copies the ruby, runs, computes Hit@k/MRR
|
||
per channel, saves `results/<date>-<label>.json`.
|
||
- `eval_full.rb` + `run_full_eval.py` — **end-to-end** eval: prod
|
||
retrieval vs posts+rerank, real LLM (mini PC), citation validation,
|
||
gate, weak zone, evidence injection.
|
||
|
||
```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
|
||
|
||
# for --rerank (config E, CPU cross-encoder, ~1.5 GB RAM at load):
|
||
rag/eval/.venv/bin/pip install sentence-transformers
|
||
rag/eval/.venv/bin/python rag/eval/run_eval.py --label with-rerank --rerank
|
||
|
||
# end-to-end generation eval (real mini-PC LLM, ~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
|
||
```
|
||
|
||
> Retrieval metrics run **without an LLM** → fast iteration loop.
|
||
> Generation eval calls the real mini-PC `qwen3:30b` via Tailscale —
|
||
> budget ~10 s per question and per variant.
|