depiedencap-rag/docs/rag/DESIGN.md
grgmro b05eea24d5 Docs: English pages for design/eval, linked from the English README
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>
2026-09-17 23:23:03 +02:00

14 KiB
Raw Blame History

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.fr.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_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é :

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