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>
14 KiB
Design — RAG grain post (Depiedencap)
📘 This page is also available in English 🇬🇧
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·PIPELINE.md. Résultats :EVAL.md· Sécurité :SAFETY.md· Références :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_QUERIEScodé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_embeddingsn'a pas été créée — décision changée en cours de route. La table nativeai_posts_embeddingsavec 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_embeddingsnatif : le backfill natif embeddepost.rawnu — 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_embeddingsreste 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 nativeTruncation#post_truncationembedde déjà « titre + catégorie + tags + contenu du post » — l'enrichissement partiel est gratuit en activantai_embeddings_per_post_enabled. Ce qui reste custom : parent/OP excerpt et nos métadonnées. Unstrategy_idcustom dans la table native permettrait nos vecteurs enrichis complets en réutilisant index + purge — option sérieuse avant de créerdpec_post_embeddings— cf. D2.
Schéma proposé :
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 seulementTopic.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_hashchange 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é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
→ tranché : mesuré et rejeté (D5/D2).OP excerptdans l'enrichi- Fenêtre voisinage (config D) : non mesurée isolément — le rerank +
extrait 320c suffit en pratique sur l'éval bout-en-bout (
SAFETY.mdrun 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
(
< -4abstention,[-4,0)prudence,≥0normal) + gate d'entrée pour l'axe sécurité (indépendant de la pertinence). Voir SAFETY.md. INTENT_QUERIEScodé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.rbappelle 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 ».