# 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 ».