commit 39cffbc1bb957452118187a1e85fcb2bd979db62 Author: grgmro Date: Thu Sep 17 09:26:45 2026 +0200 depiedencap-rag — export public initial Plugin Discourse RAG (grain post, rerank cross-encoder, citations whitelistées, gate déterministe, réécriture de requête multi-tour) + documentation du chantier (design, éval, sécurité, pipeline). Export sanitisé depuis le monorepo privé via scripts/export_rag_public.py (aucun endpoint interne, IP, compte démo ou chemin d'infrastructure). WIP : agent en test sur groupe restreint, pas encore public sur le forum. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d783471 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Depiedencap + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5442d1 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# depiedencap-rag — Agent RAG ancré dans Discourse + +> ⚠️ **Work in progress.** L'agent est en cours de test sur un groupe +> restreint de démonstration et **n'est pas encore déployé publiquement** +> sur le forum. Ce dépôt documente le chantier — aucune URL de démo n'est +> publiée. + +Un plugin **Discourse** + un pipeline RAG auto-hébergé, conçu pour un forum +associatif francophone (Depiedencap — souliers pour homme, ~428k messages). +Principe produit : **le bot oriente vers les fils existants, il ne répond +pas à la place des membres.** + +## Ce que fait le plugin + +- Retrieval au **grain post** (et non au grain topic) : embeddings `bge-m3` + dans `ai_posts_embeddings` (pgvector dans la Postgres du forum), top-25 + par cosine puis **rerank cross-encoder** (TEI), déduplication par fil. +- **Réécriture de requête multi-tour** : le dernier message + les tours + précédents sont condensés en une requête autonome par un petit modèle + dédié avant retrieval (pattern *condense question* / history-aware + retriever). +- **Citations vérifiées côté serveur** : le LLM n'écrit que des marqueurs + `[[n]]` ; le plugin les transforme en liens `/t/slug/id/post_number` et + jette tout lien qui n'existe pas réellement en base (whitelist Postgres, + le LLM n'a jamais le dernier mot sur une URL). +- **Zones d'evidence graduées** (abstention / prudent / normal) pilotées + par le score du reranker + **gate d'entrée déterministe** (regex + d'intention) pour les demandes dangereuses ou hors-rôle — zéro appel LLM. +- Dégradation gracieuse : endpoint embedding ou reranker indisponible → + repli legacy/abstention honnête, jamais d'invention. + +## Architecture + +``` +Discourse (plugin Ruby, dans le process du forum) + │ question → gate → réécriture (modèle léger) → embed → pgvector → rerank → LLM → sanitizer + ▼ +Mini PC auto-hébergé (k3s) VPS + Ollama : bge-m3 (embeddings) Discourse + Postgres/pgvector + qwen3 30B (génération) (~428k posts embeddés) + llama3.1 8B (réécriture requête) + TEI : cross-encoder mmarco-mMiniLMv2 (rerank) +``` + +Le choix délibéré : **pas de framework RAG** (LangChain & co.) — pipeline +linéaire en code direct, instrumenté pour l'évaluation. Le raisonnement +complet : [`docs/rag/DESIGN.md`](docs/rag/DESIGN.md) §9. + +## Résultats mesurés (corpus réel, 812 questions d'éval) + +| Métrique | Valeur | +|---|---| +| Hit@5 retrieval | **0.97** | +| MRR | 0.89 | +| Sécurité (red-team, gate, refus, injection-evidence) | 20/20 | +| Surcoût rerank | ~100 ms | + +Détail : [`docs/rag/EVAL.md`](docs/rag/EVAL.md) · +[`docs/rag/SAFETY.md`](docs/rag/SAFETY.md) · +[`docs/rag/PIPELINE.md`](docs/rag/PIPELINE.md) · +[`docs/rag/REFERENCES.md`](docs/rag/REFERENCES.md) + +## Contenu du dépôt + +| Chemin | Contenu | +|---|---| +| racine (`plugin.rb`, `lib/`, `app/`, `config/`) | Le plugin Discourse, installable par `git clone` dans `plugins/` | +| `docs/rag/` | Design, éval, sécurité, références, recette dev local | +| `prompts/` | System prompt de production (v3.1) | +| `tools/` | Smoke test live (API Discourse) | + +## Sécurité & confidentialité + +- Aucun endpoint interne, IP ou compte n'est publié (export sanitisé + depuis le dépôt privé de travail + denylist-check automatique). +- L'agent reste limité à un groupe de démonstration pendant la phase + d'observation ; rollback < 5 min documenté. +- Le bot refuse les demandes dangereuses, illégales ou généralistes et + ignore les instructions hostiles trouvées dans les extraits du forum. + +## Licence + +MIT — voir `LICENSE`. diff --git a/app/controllers/depiedencap_ai_demo_purge_controller.rb b/app/controllers/depiedencap_ai_demo_purge_controller.rb new file mode 100644 index 0000000..172013a --- /dev/null +++ b/app/controllers/depiedencap_ai_demo_purge_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DepiedencapAiDemoPurgeController < ApplicationController + requires_login + skip_before_action :check_xhr + + def purge + raise Discourse::InvalidAccess unless SiteSetting.depiedencap_ai_citations_enabled + raise Discourse::InvalidAccess unless DepiedencapAiCitations::DemoPurge.demo_user?(current_user) + + deleted = DepiedencapAiCitations::DemoPurge.wipe!(current_user) + render json: { success: true, deleted: deleted } + end +end diff --git a/app/controllers/depiedencap_ai_llm_warmup_controller.rb b/app/controllers/depiedencap_ai_llm_warmup_controller.rb new file mode 100644 index 0000000..cf142a4 --- /dev/null +++ b/app/controllers/depiedencap_ai_llm_warmup_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class DepiedencapAiLlmWarmupController < ApplicationController + requires_login + skip_before_action :check_xhr + + def show + raise Discourse::InvalidAccess unless SiteSetting.depiedencap_ai_citations_enabled + # Évite le polling global : warmup réservé au compte démo chatbot. + raise Discourse::InvalidAccess unless current_user&.username == "demo" + + render json: DepiedencapAiCitations::WarmupStatus.fetch(use_cache: false) + end +end diff --git a/app/jobs/regular/depiedencap_sanitize_ai_citations.rb b/app/jobs/regular/depiedencap_sanitize_ai_citations.rb new file mode 100644 index 0000000..8378f30 --- /dev/null +++ b/app/jobs/regular/depiedencap_sanitize_ai_citations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Jobs + class DepiedencapSanitizeAiCitations < ::Jobs::Base + def execute(args) + post = Post.find_by(id: args[:post_id]) + DepiedencapAiCitations::Sanitizer.sanitize!(post) if post + end + end +end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml new file mode 100644 index 0000000..2c955da --- /dev/null +++ b/config/locales/server.en.yml @@ -0,0 +1,13 @@ +en: + site_settings: + depiedencap_ai_citations_posts_pipeline: "Post-grain retrieval channel (cosine + CE rerank). Requires populated ai_posts_embeddings." + depiedencap_ai_citations_rerank_url: "Cross-encoder /rerank service URL (mini PC, Tailscale)." + depiedencap_ai_citations_input_gate: "Deterministic input gate: fixed reply without LLM when out-of-role/harmful intent detected." + discourse_ai: + ai_bot: + reply_error: | + 🔧 Oups, on dirait que l'administrateur est en train de travailler très très fort sur le serveur... 🛠️ + + Le chatbot est donc indisponible pour le moment 🤭 + + N'hésitez pas à réessayer plus tard ! ⏳ diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml new file mode 100644 index 0000000..a3bad10 --- /dev/null +++ b/config/locales/server.fr.yml @@ -0,0 +1,14 @@ +fr: + site_settings: + depiedencap_ai_citations_posts_pipeline: "Canal retrieval grain post (cosine + rerank CE). Nécessite ai_posts_embeddings remplie." + depiedencap_ai_citations_rerank_url: "URL du service /rerank cross-encoder (mini PC, Tailscale)." + depiedencap_ai_citations_input_gate: "Gate d'entrée déterministe : réponse fixe sans LLM si intention hors-rôle/dangereuse détectée." + discourse_ai: + ai_bot: + # Filet si le LLM est quand même appelé (pod down). Pas de détails TCP. + reply_error: | + 🔧 Oups, on dirait que l'administrateur est en train de travailler très très fort sur le serveur... 🛠️ + + Le chatbot est donc indisponible pour le moment 🤭 + + N'hésitez pas à réessayer plus tard ! ⏳ diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000..3435b14 --- /dev/null +++ b/config/settings.yml @@ -0,0 +1,23 @@ +plugins: + depiedencap_ai_citations_enabled: + default: true + client: false + depiedencap_ai_citations_posts_pipeline: + default: false + client: false + depiedencap_ai_citations_input_gate: + default: false + client: false + depiedencap_ai_citations_rerank_url: + # URL du endpoint TEI /rerank (ex. http://:/rerank). Vide = rerank + # désactivé, repli sur l'ordre cosine. Prod : valeur en site_settings DB. + default: "" + client: false + depiedencap_ai_citations_condense_llm_url: + # URL OpenAI-compatible /v1/chat/completions du modèle de réécriture de + # requête (multi-tour). Vide = pas de condensation, dernier message brut. + default: "" + client: false + depiedencap_ai_citations_condense_model: + default: "llama3.1:8b" + client: false diff --git a/docs/rag/DESIGN.md b/docs/rag/DESIGN.md new file mode 100644 index 0000000..b435837 --- /dev/null +++ b/docs/rag/DESIGN.md @@ -0,0 +1,236 @@ +# Design — RAG grain post (Depiedencap) + +> **Dernière màj** : 14/09/2026 · **Statut** : toutes les configs A-G +> mesurées, config gagnante identifiée (E : posts natifs + rerank), +> sécurité testée à l'échelle. **Rien déployé en prod.** +> Résultats : [`EVAL.md`](EVAL.md) · Sécurité : [`SAFETY.md`](SAFETY.md) · +> Architecture : [`PIPELINE.md`](PIPELINE.md) +> Références techniques : [`REFERENCES.md`](REFERENCES.md) + +## 0. État actuel (rappel) + +- **Index** : `ai_topics_embeddings` — 1 vecteur/topic (bge-m3, 1024 dims, + entrée 4096 tokens), 22 340 lignes. Stockage pgvector dans la DB du forum. +- **Retrieval** (`retrieval.rb`) : `Search.execute` (keyword) + + `SemanticSearch` (pgvector, HyDE off), fusion custom (base rank + bonus + titre + bonus hybride +12), filtre titre obligatoire (mot distinctif ≥ 6 + chars / stem), stopwords FR, `INTENT_QUERIES` codées en dur, cache 2 h, + max 6 sources. +- **Evidence** : titres + extraits 320 chars injectés serveur, le LLM émet + des `[[n]]` nus, sanitizer → URLs whitelist (`Topic.exists?`), footer + Sources + CTA. + +### Faiblesses identifiées + +| Faiblesse | Cause | +|---|---| +| Megathreads invisibles au détail | 1 vecteur / 300 posts = dilution + troncature 4096 | +| Citation au fil entier | `[[n]]` → `/t/slug/id`, pas au message précis | +| Evidence sans contexte | Extrait 320 chars isolé, anaphores non résolues | +| Fusion ad hoc | Score custom, pas de méthode standard (RRF) | + +## 1. Schéma de stockage (décidé) + +**Vecteurs dans la Postgres du forum** (pgvector), pas de store externe. +Justification : atomicité avec `posts`, backup gratuit dans le `.tar.gz`, +le forum local de dev a sa copie automatiquement. + +| Table | Contenu | Statut | +|---|---|---| +| `ai_topics_embeddings` | 1 vecteur/topic (natif discourse-ai) | existant, **conservé** (related topics + fallback) | +| `ai_posts_embeddings` | 1 vecteur/post, `strategy_id=1` natif + `strategy_id=2` enrichi (test) | **implémenté** — voir note ci-dessous, remplace `dpec_post_embeddings` | + +> **Révision D2 (14/09)** : la table custom `dpec_post_embeddings` n'a +> **pas** été créée — décision changée en cours de route. La table native +> `ai_posts_embeddings` avec sa clé `(model_id, strategy_id, post_id)` +> permet exactement la même chose (plusieurs représentations du même post +> qui cohabitent) sans dupliquer l'infra (index, purge, backfill job). +> `strategy_id=1` = natif discourse-ai (titre+cat+tags+contenu, gratuit, +> **c'est celui qui gagne**), `strategy_id=2` = enrichi OP/parent (mesuré, +> rejeté — voir EVAL.md §4.2). Le custom serait resté nécessaire seulement +> pour C4 (blurb LLM), jamais implémenté (voir §8). + +> Pourquoi une table **custom** et pas `ai_posts_embeddings` natif : +> le backfill natif embedde `post.raw` nu — impossible d'y injecter le +> contexte (titre/OP/parent). Table custom = contrôle total du texte +> embarqué + métadonnées (post_number, topic_id, hash du texte, modèle). +> `ai_posts_embeddings` reste désactivé (`ai_embeddings_per_post_enabled=false`). +> +> Constats du restore local (12/09) : la table native existe déjà avec +> `embeddings halfvec` + index **HNSW binary-quantized** par +> `(model_id, strategy_id)` — et la clé unique est +> `(model_id, strategy_id, post_id)`. **Et** la stratégie native +> `Truncation#post_truncation` embedde déjà « titre + catégorie + tags + +> contenu du post » — l'enrichissement partiel est **gratuit** en activant +> `ai_embeddings_per_post_enabled`. Ce qui reste custom : parent/OP excerpt +> et nos métadonnées. Un `strategy_id` custom dans la table native +> permettrait nos vecteurs enrichis complets en réutilisant index + purge — +> option sérieuse avant de créer `dpec_post_embeddings` — cf. D2. + +Schéma proposé : + +```sql +post_id bigint PK FK → posts.id +topic_id bigint (dénormalisé, filtre/agrégation rapide) +post_number int (ancre de citation) +embedding vector(1024) +source_hash char(40) -- sha1 du texte enrichi embarqué (détecte l'édit) +model text -- 'bge-m3' (un changement de modèle = vecteurs incompatibles) +embedded_at timestamptz +``` + +Index : **à mesurer** — brute force `ORDER BY embedding <=> $q` (cosine) +sur ~300-400k lignes ≈ 200-500 ms, peut suffire dans un chemin où le LLM +prend des secondes. Sinon HNSW (`vector_cosine_ops`, `m`/`ef` à régler) — +rappel ~95-99 %, build coûteux en RAM sur le VPS 8 Go. + +## 2. Le texte embarqué (enrichissement) — l'axe décisif + +Le post nu échoue sur les anaphores (« c'était pourri » → pourrie **quoi** ?). +Le contexte résout la majorité des références. Texte candidat à embedder : + +``` +[titre du topic] › [extrait OP si post_number > 1] › [extrait post parent +si réponse formelle] › [post.raw nettoyé] +``` + +Variantes à tester (matrice §4) : + +| Code | Texte embeddé | Coût index | +|---|---|---| +| `B` | `post.raw` nu | minimal | +| `C1` | `title + post.raw` | minimal | +| `C2` | `title + OP excerpt + post.raw` | minimal | +| `C3` | `C2 + parent excerpt` (reply_to_post_number / quote) | minimal | +| `C4` | `C3 + blurb LLM` (« dans ce fil sur X, ce message dit… ») | 1 appel LLM/post — cher, borne à un sous-ensemble | + +> C4 = le « Contextual Retrieval » d'Anthropic littéral (−35 % d'échecs +> top-20 à lui seul). C1-C3 = version gratuite. La littérature suggère que +> le gain majeur vient de *situer* le chunk, pas forcément du LLM. + +Note : le post orphelin (réponse sans citation à un post voisin) n'a **pas** +besoin d'être retrouvé lui-même — il sera lu via l'expansion de voisinage +du post voisin qui, lui, porte le contexte (§3). + +## 3. Retrieval en deux temps (small-to-big) + +``` +question + → embed requête (bge-m3, même instance) + → top-K posts (pgvector) + top-M topics (keyword Search existant) + → fusion RRF (k=60) ← remplace le scoring custom ? + → agrégation par topic : 1 entrée/fil, score = max(posts du fil) + → evidence block : par entrée, + titre + extrait post ciblé + voisinage ±N posts + → LLM : citations [[n]] → sanitizer → /t/slug/id/post_number +``` + +- **Agrégation par topic** : évite qu'un megathread monopolise les 6 slots + d'evidence. Score du topic = meilleur post (ou moyenne top-2, à tester). +- **Voisinage** (`window` ±2 ou ±3 posts, paramètre) : porte le contexte, + résout les anaphores à la **lecture** — pas à l'indexation. C'est le + « sentence-window retrieval » appliqué aux posts. +- **Citation post-level** : `[[n]]` → `/t/slug/topic_id/post_number`. + Whitelist sanitizer : `Post.exists?` (pas seulement `Topic.exists?`). + +## 4. Matrice de configs — résultats (14/09/2026) + +| Config | Description | Hit@5 | MRR | Verdict | +|---|---|---|---|---| +| `A` | topics (prod actuelle) | 0.68 | 0.63 | baseline live | +| `B` | posts natifs (strategy_id=1) | **0.94** | 0.84 | **retenu** — le gain principal | +| `C` | posts enrichis OP/parent (strategy_id=2) | 0.85 | 0.80 | **mesuré, rejeté** — dilue le vecteur | +| `D` | voisinage ±N au moment de la génération | — | — | non testé isolément (le rerank + evidence 320c couvre le besoin en pratique) | +| `E` | B + rerank cross-encoder mmarco | **0.97** | **0.89** | **retenu — config gagnante** | +| `F` | fusion RRF posts×hybrid | 0.95 | 0.88 | mesuré, marginal sur E (+0.01 MRR), optionnel | +| `G` | routing par taille de topic | 0.93-0.95 | 0.77-0.86 | **mesuré, rejeté** — dégrade MRR vs fusion libre | + +Détail complet, méthodologie et n mesurés : `EVAL.md` §1-4. +Chaque config comparée en ne changeant **qu'un** facteur à la fois. +Sécurité de la config E (refus, gate, injection) : `SAFETY.md`. + +Variables secondaires à explorer ensuite : top-K (10/20/50), longueur +d'entrée embed (1024 vs 4096), seuil de filtre titre, taille fenêtre. + +## 5. Corpus à indexer + +| Choix | Position | +|---|---| +| Catégories | **Toutes les publiques** (les questions « vie du forum » sont légitimes) | +| MP | **Jamais** (décision produit existante) | +| Longueur min. | À calibrer — posts < ~30-40 chars probablement exclus (« +1 », « merci ! ») | +| Posts supprimés / staff | exclus (`deleted_at`, catégories non publiques) | +| Volume estimé | ~300-350k posts indexables sur ~425k | + +## 6. Coûts / charge + +- **Backfill** : ~300k embeddings bge-m3. Sur laptop CPU ~10-20/s → 4-8 h + (plusieurs nuits OK). Prod : même modèle côté mini PC iGPU, plus rapide. +- **Requête** : 1 embed (~50-200 ms) + 1 similarité SQL + expansion = + négligeable devant la génération LLM. +- **Stockage VPS** : ~300-400k × (1024 dims × 4 o + overhead ligne) + ≈ **2-3 Go** — tenable vs ~13 Go libres, à surveiller. +- **Incrémental** : 1 embed par nouveau post (job Sidekiq async), + re-embed + si `source_hash` change après édit. + +## 7. Décisions + +| # | Date | Décision | Motif | +|---|---|---|---| +| D1 | 12/09/2026 | Vecteurs dans la Postgres du forum, pas de store externe | Atomicité, backup dans le `.tar.gz`, réplique locale gratuite | +| D2 | 12/09/2026, **révisée 14/09** | `ai_posts_embeddings` natif (`strategy_id`), pas de table custom | Même besoin (cohabitation multi-représentation) sans dupliquer index/purge/backfill | +| D3 | 12/09/2026 | Dev : bge-m3 **local CPU/GPU laptop**, LLM mini PC | Isolation dev/prod ; NUM_PARALLEL=2 risqué pour le 30B | +| D4 | 13/09/2026 | RRF mesuré vs fusion custom — retenu comme option (F), pas obligatoire | Gain marginal (+0.01 MRR) sur la config gagnante E | +| D5 | 13/09/2026 | Config B (posts nus) retenue comme socle, C (enrichi) rejeté | Mesuré : 0.94 vs 0.85 Hit@5 — le contexte OP/parent dilue le vecteur | +| D6 | 14/09/2026 | Config E (B + rerank cross-encoder) = solution finale retrieval | Mesuré : 0.97 Hit@5 / 0.89 MRR, coût ~100ms négligeable | +| D7 | 14/09/2026 | G (routing par taille) rejeté | Mesuré à l'échelle (796) : dégrade MRR vs fusion libre | +| D8 | 14/09/2026 | Abstention/scope : zone graduée sur score CE (pas de seuil dur) + gate d'entrée déterministe + clause de refus dans le prompt | Aucun seuil ne sépare proprement in-domain/hors-sujet (distributions qui se chevauchent) — voir SAFETY.md | +| D9 | 17/09/2026 | **Pas de LangChain** — patterns repris sans la dépendance | Voir §9 | +| D10 | 17/09/2026 | Réécriture de requête multi-tour via modèle dédié léger (`condense_query`, llama3.1:8b) — repli sur le message brut si indispo | Retrieval ne voyait que le dernier message ; bug réel observé en usage (17/09). Modèle non-thinking choisi exprès (qwen3:4b engloutit son budget dans le champ `reasoning`) | + +## 9. Stack : pourquoi pas LangChain (et ce qu'on en retient) + +LangChain est une **bibliothèque** (Python/JS — pas un LLM, pas un service à +déployer) qui fournit des briques d'orchestration LLM : retrieval, mémoire, +réécriture de requête, outils. C'est le framework le plus répandu pour +*prototyper* un RAG — d'où sa présence dans les offres d'emploi. + +**Pourquoi on ne l'utilise pas ici** — choix d'architecture, pas de rejet du +concept : + +- Notre pipeline vit dans un **plugin Discourse Ruby**, dans le process du + forum. LangChain impliquerait un service Python séparé à déployer, + maintenir et sécuriser — un nouveau point de défaillance entre le forum + et le mini PC, pour ré-implémenter une logique déjà linéaire. +- On fait déjà les mêmes étapes en code direct (~600 lignes) : + embed → pgvector → rerank cross-encoder → LLM → sanitizer. Pas besoin + d'abstraction pour un pipeline linéaire et instrumenté (l'éval + `eval_full.rb` appelle directement les fonctions). +- Si un jour on visait des capacités **agentiques** (le bot choisit ses + outils, enchaîne des recherches), un service dédié deviendrait défendable + — LangChain/LangGraph seraient alors candidats. Pas la roadmap actuelle. + +**Ce qu'on a quand même repris de ce monde** : la **réécriture de requête +conversationnelle** (pattern *condense question* / *history-aware retriever* +de LangChain, et littérature QReCC/CANARD) — implémentée dans +`Retrieval.condense_query` avec un petit modèle dédié : le dernier message + +les 3 tours précédents → une requête autonome avant embedding. + +## 8. Questions ouvertes — état + +- ~~`OP excerpt` dans l'enrichi~~ → **tranché** : mesuré et rejeté (D5/D2). +- Fenêtre voisinage (config D) : **non mesurée isolément** — le rerank + + extrait 320c suffit en pratique sur l'éval bout-en-bout (`SAFETY.md` + run 812) ; à revisiter seulement si des cas d'anaphore non résolue + sont observés en usage réel. +- Reranker : **tranché** — oui, +0.03 Hit@5 / +0.05 MRR pour ~100ms. + Tourne sur CPU, mini PC ou VPS EPYC Rome tous deux suffisants + (mesuré : `PIPELINE.md`). +- Abstention : **tranché** — pas un seuil unique, zone graduée + (`< -4` abstention, `[-4,0)` prudence, `≥0` normal) + gate d'entrée + pour l'axe sécurité (indépendant de la pertinence). Voir SAFETY.md. +- `INTENT_QUERIES` codées main (prod actuelle) : **non repris** dans le + nouveau pipeline — le rerank cross-encoder couvre ce besoin sans règles + ad hoc à maintenir ; à confirmer si un cas de régression apparaît. +- Config D (voisinage génération), config F (RRF) : optionnelles, + gain marginal mesuré — pas nécessaires pour le MVP. diff --git a/docs/rag/EVAL.md b/docs/rag/EVAL.md new file mode 100644 index 0000000..8e0f71a --- /dev/null +++ b/docs/rag/EVAL.md @@ -0,0 +1,353 @@ +# É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: + question: "" + expected_topics: [] + expected_posts: [] # 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 + +### Retrieval (automatique) + +| Métrique | Définition | +|---|---| +| **Hit@k topic** | au moins un `expected_topic` dans le top-k (k ∈ {3, 6}) | +| **Hit@k post** | le `expected_post` dans le top-k posts (grain post uniquement) | +| MRR | rang du premier hit pertinent | +| 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/-