depiedencap-rag/docs/rag/DESIGN.md
grgmro 2659759d0d Docs: this repo uses .md for French and .en.md for English
Root README.md is now French; README.en.md is the English landing. Internal links follow the same rule.

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

240 lines
14 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.

# Design — RAG grain post (Depiedencap)
📘 This page is also available in [English 🇬🇧](./DESIGN.en.md)
> **Dernière màj** : 17/09/2026 · **Statut** : config gagnante (E : posts
> natifs + rerank) mesurée ; code en prod sur le VPS, flags membre encore
> **OFF**, agent limité à un groupe démo. Le §0 ci-dessous est un
> **instantané historique** (grain topic) — l'architecture actuelle :
> [`README.md`](../../README.md) · [`PIPELINE.md`](PIPELINE.md).
> Résultats : [`EVAL.md`](EVAL.md) · Sécurité : [`SAFETY.md`](SAFETY.md) ·
> Références : [`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](#user-content-9-pourquoi-pas-langchain) |
| D10 | 17/09/2026, **révisée même jour** | Réécriture de requête multi-tour (`condense_query`) via **`qwen3:30b-a3b-q6k`** — le LLM principal, pas un modèle dédié | Retrieval ne voyait que le dernier message ; bug réel observé en usage. Première mouture `llama3.1:8b` (qwen3:4b engloutissait son budget dans `reasoning`) ; test comparatif → le MoE (~3B actifs) condense en ~1.0s vs 1.6s, qualité égale, **un modèle résident de moins** en RAM. `think:false` forcé dans la requête (garde-fou thinking, ignoré sans erreur par les non-thinking) |
## 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.
## 9. Pourquoi pas LangChain
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` : le dernier message + les 3 tours précédents →
une requête autonome avant embedding. Le modèle qui réécrit est **le même
Qwen3-30B que la génération** (D10) — voir PIPELINE.md « pourquoi un MoE ».