# Design β€” post-grain RAG (Depiedencap) πŸ“˜ Cette page est Γ©galement disponible en [franΓ§ais πŸ‡«πŸ‡·](./DESIGN.md) > **Last update**: 17/09/2026 Β· **Status**: winning config (E: native posts > + rerank) measured; code in prod on the VPS, member flags still **OFF**, > agent limited to a demo group. Β§0 below is a **historical snapshot** > (topic grain) β€” current architecture: > [`README.md`](../../README.md) Β· [`PIPELINE.en.md`](PIPELINE.en.md). > Results: [`EVAL.en.md`](EVAL.en.md) Β· Safety: [`SAFETY.en.md`](SAFETY.en.md) Β· > References: [`REFERENCES.en.md`](REFERENCES.en.md) ## 0. Current state (snapshot) - **Index**: `ai_topics_embeddings` β€” 1 vector/topic (bge-m3, 1024 dims, 4096-token input), 22 340 rows. pgvector storage in the forum DB. - **Retrieval** (`retrieval.rb`): `Search.execute` (keyword) + `SemanticSearch` (pgvector, HyDE off), custom fusion (base rank + title bonus + hybrid bonus +12), mandatory title filter (distinctive word β‰₯ 6 chars / stem), FR stopwords, hard-coded `INTENT_QUERIES`, 2 h cache, max 6 sources. - **Evidence**: titles + 320-char excerpts injected server-side; the LLM emits bare `[[n]]`; sanitizer β†’ URL whitelist (`Topic.exists?`), Sources footer + CTA. ### Known weaknesses | Weakness | Cause | |---|---| | Megathreads invisible in detail | 1 vector / 300 posts = dilution + 4096 truncation | | Citation to the whole thread | `[[n]]` β†’ `/t/slug/id`, not the precise message | | Evidence without context | Isolated 320-char excerpt, unresolved anaphora | | Ad-hoc fusion | Custom score, not a standard method (RRF) | ## 1. Storage schema (decided) **Vectors in the forum Postgres** (pgvector), no external store. Rationale: atomicity with `posts`, free backup inside the `.tar.gz`, the local dev forum gets its copy automatically. | Table | Content | Status | |---|---|---| | `ai_topics_embeddings` | 1 vector/topic (native discourse-ai) | existing, **kept** (related topics + fallback) | | `ai_posts_embeddings` | 1 vector/post, native `strategy_id=1` + enriched `strategy_id=2` (test) | **implemented** β€” see note below, replaces `dpec_post_embeddings` | > **Revision D2 (14/09)**: the custom table `dpec_post_embeddings` was > **not** created β€” decision changed mid-course. Native > `ai_posts_embeddings` with key `(model_id, strategy_id, post_id)` does > the same job (several representations of the same post coexist) without > duplicating infra (index, purge, backfill job). `strategy_id=1` = native > discourse-ai (title+cat+tags+body, free, **this is the winner**), > `strategy_id=2` = OP/parent enriched (measured, rejected β€” see > EVAL.en.md Β§4.2). Custom would have remained necessary only for C4 > (LLM blurb), never implemented (see Β§8). > Why a **custom** table rather than native `ai_posts_embeddings`: > native backfill embeds bare `post.raw` β€” no way to inject context > (title/OP/parent). Custom table = full control of the embedded text + > metadata (post_number, topic_id, text hash, model). > `ai_posts_embeddings` stays disabled (`ai_embeddings_per_post_enabled=false`). > > Findings from the local restore (12/09): the native table already exists > with `embeddings halfvec` + **HNSW binary-quantized** index per > `(model_id, strategy_id)` β€” unique key `(model_id, strategy_id, post_id)`. > **And** native strategy `Truncation#post_truncation` already embeds > "title + category + tags + post body" β€” partial enrichment is **free** > by turning on `ai_embeddings_per_post_enabled`. What remains custom: > parent/OP excerpt and our metadata. A custom `strategy_id` in the native > table would give fully enriched vectors while reusing index + purge β€” > a serious option before creating `dpec_post_embeddings` β€” see D2. Proposed schema: ```sql post_id bigint PK FK β†’ posts.id topic_id bigint (denormalised, fast filter/aggregation) post_number int (citation anchor) embedding vector(1024) source_hash char(40) -- sha1 of the embedded enriched text (detects edits) model text -- 'bge-m3' (a model change = incompatible vectors) embedded_at timestamptz ``` Index: **to be measured** β€” brute force `ORDER BY embedding <=> $q` (cosine) on ~300-400k rows β‰ˆ 200-500 ms, may be enough on a path where the LLM takes seconds. Otherwise HNSW (`vector_cosine_ops`, tune `m`/`ef`) β€” recall ~95-99 %, RAM-heavy build on the 8 GB VPS. ## 2. Embedded text (enrichment) A bare post fails on anaphora ("it was rubbish" β†’ rubbish **what**?). Context resolves most references. Candidate text to embed: ``` [topic title] β€Ί [OP excerpt if post_number > 1] β€Ί [parent-post excerpt if a formal reply] β€Ί [cleaned post.raw] ``` Variants to test (matrix Β§4): | Code | Embedded text | Index cost | |---|---|---| | `B` | bare `post.raw` | 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 + LLM blurb` ("in this thread about X, this message says…") | 1 LLM call/post β€” expensive, bound to a subset | > C4 = Anthropic "Contextual Retrieval" literally (βˆ’35 % top-20 failures > on its own). C1-C3 = the free version. The literature suggests the main > gain is *situating* the chunk, not necessarily the LLM. Note: an orphan post (a reply that does not quote a neighbour) does **not** need to be retrieved itself β€” it will be read via neighbourhood expansion of the neighbouring post that *does* carry the context (Β§3). ## 3. Two-stage retrieval (small-to-big) ``` question β†’ embed query (bge-m3, same instance) β†’ top-K posts (pgvector) + top-M topics (existing keyword Search) β†’ RRF fusion (k=60) ← replace custom scoring? β†’ aggregate by topic: 1 entry/thread, score = max(posts in thread) β†’ evidence block: per entry, title + targeted post excerpt + Β±N neighbouring posts β†’ LLM: citations [[n]] β†’ sanitizer β†’ /t/slug/id/post_number ``` - **Aggregate by topic**: stops a megathread monopolising the 6 evidence slots. Topic score = best post (or mean of top-2, to test). - **Neighbourhood** (`window` Β±2 or Β±3 posts, parameter): carries context, resolves anaphora at **read** time β€” not at index time. Sentence-window retrieval applied to posts. - **Post-level citation**: `[[n]]` β†’ `/t/slug/topic_id/post_number`. Sanitizer whitelist: `Post.exists?` (not only `Topic.exists?`). ## 4. Config matrix β€” results (14/09/2026) | Config | Description | Hit@5 | MRR | Verdict | |---|---|---|---|---| | `A` | topics (then-current prod) | 0.68 | 0.63 | live baseline | | `B` | native posts (strategy_id=1) | **0.94** | 0.84 | **kept** β€” the main gain | | `C` | OP/parent-enriched posts (strategy_id=2) | 0.85 | 0.80 | **measured, rejected** β€” dilutes the vector | | `D` | Β±N neighbourhood at generation time | β€” | β€” | not tested in isolation (rerank + 320c evidence covers the need in practice) | | `E` | B + mmarco cross-encoder rerank | **0.97** | **0.89** | **kept β€” winning config** | | `F` | RRF postsΓ—hybrid fusion | 0.95 | 0.88 | measured, marginal on E (+0.01 MRR), optional | | `G` | routing by topic size | 0.93-0.95 | 0.77-0.86 | **measured, rejected** β€” degrades MRR vs free fusion | Full detail, methodology and measured n: `EVAL.en.md` Β§1-4. Each config compared by changing **one** factor at a time. Safety of config E (refusal, gate, injection): `SAFETY.en.md`. Secondary variables to explore later: top-K (10/20/50), embed input length (1024 vs 4096), title-filter threshold, window size. ## 5. Corpus to index | Choice | Position | |---|---| | Categories | **All public ones** ("forum life" questions are legitimate) | | PMs | **Never** (existing product decision) | | Min length | To calibrate β€” posts < ~30-40 chars likely excluded ("+1", "thanks!") | | Deleted / staff posts | excluded (`deleted_at`, non-public categories) | | Estimated volume | ~300-350k indexable posts out of ~425k | ## 6. Cost and load - **Backfill**: ~300k bge-m3 embeddings. Laptop CPU ~10-20/s β†’ 4-8 h (several nights OK). Prod: same model on the mini-PC iGPU, faster. - **Query**: 1 embed (~50-200 ms) + 1 SQL similarity + expansion = negligible next to LLM generation. - **VPS storage**: ~300-400k Γ— (1024 dims Γ— 4 B + row overhead) β‰ˆ **2-3 GB** β€” tenable vs ~13 GB free, to watch. - **Incremental**: 1 embed per new post (async Sidekiq job), + re-embed if `source_hash` changes after an edit. ## 7. Decisions | # | Date | Decision | Why | |---|---|---|---| | D1 | 12/09/2026 | Vectors in the forum Postgres, no external store | Atomicity, backup in the `.tar.gz`, free local replica | | D2 | 12/09/2026, **revised 14/09** | Native `ai_posts_embeddings` (`strategy_id`), no custom table | Same need (multi-representation coexistence) without duplicating index/purge/backfill | | D3 | 12/09/2026 | Dev: bge-m3 **local laptop CPU/GPU**, LLM on mini PC | Dev/prod isolation; NUM_PARALLEL=2 is risky for the 30B | | D4 | 13/09/2026 | RRF measured vs custom fusion β€” kept as option (F), not mandatory | Marginal gain (+0.01 MRR) on winning config E | | D5 | 13/09/2026 | Config B (bare posts) kept as base, C (enriched) rejected | Measured: 0.94 vs 0.85 Hit@5 β€” OP/parent context dilutes the vector | | D6 | 14/09/2026 | Config E (B + cross-encoder rerank) = final retrieval | Measured: 0.97 Hit@5 / 0.89 MRR, ~100 ms cost negligible | | D7 | 14/09/2026 | G (routing by size) rejected | Measured at scale (796): degrades MRR vs free fusion | | D8 | 14/09/2026 | Abstention/scope: graduated zone on CE score (no hard threshold) + deterministic input gate + refusal clause in the prompt | No threshold cleanly separates in-domain/off-topic (overlapping distributions) β€” see SAFETY.en.md | | D9 | 17/09/2026 | **No LangChain** β€” patterns reused without the dependency | See [Β§9](#user-content-9-why-not-langchain) | | D10 | 17/09/2026, **revised same day** | Multi-turn query rewrite (`condense_query`) via **`qwen3:30b-a3b-q6k`** β€” the main LLM, not a dedicated model | Retrieval only saw the last message; real bug in usage. First cut `llama3.1:8b` (qwen3:4b swallowed its budget in `reasoning`); comparative test β†’ the MoE (~3B active) condenses in ~1.0s vs 1.6s, equal quality, **one fewer resident model** in RAM. `think:false` forced in the request (thinking guardrail, ignored without error by non-thinking models) | ## 8. Open questions β€” status - ~~`OP excerpt` in the enriched text~~ β†’ **settled**: measured and rejected (D5/D2). - Neighbourhood window (config D): **not measured in isolation** β€” rerank + 320c excerpt is enough in practice on the end-to-end eval (`SAFETY.en.md` run 812); revisit only if unresolved anaphora shows up in real use. - Reranker: **settled** β€” yes, +0.03 Hit@5 / +0.05 MRR for ~100 ms. Runs on CPU; mini PC or VPS EPYC Rome both sufficient (measured: `PIPELINE.en.md`). - Abstention: **settled** β€” not a single threshold, graduated zone (`< -4` abstain, `[-4,0)` cautious, `β‰₯0` normal) + input gate for the safety axis (independent of relevance). See SAFETY.en.md. - Hand-coded `INTENT_QUERIES` (then-current prod): **not carried** into the new pipeline β€” the cross-encoder rerank covers that need without ad-hoc rules to maintain; confirm if a regression case appears. - Config D (generation neighbourhood), config F (RRF): optional, measured marginal gain β€” not needed for the MVP. ## 9. Why not LangChain LangChain is a **library** (Python/JS β€” not an LLM, not a service to deploy) that provides LLM orchestration bricks: retrieval, memory, query rewriting, tools. It is the most common framework for *prototyping* a RAG β€” hence its presence in job ads. **Why we do not use it here** β€” an architecture choice, not a rejection of the idea: - Our pipeline lives in a **Discourse Ruby plugin**, inside the forum process. LangChain would mean a separate Python service to deploy, maintain and secure β€” a new failure point between the forum and the mini PC, to re-implement logic that is already linear. - We already do the same steps in direct code (~600 lines): embed β†’ pgvector β†’ cross-encoder rerank β†’ LLM β†’ sanitizer. No need for an abstraction on a linear, instrumented pipeline (the `eval_full.rb` eval calls the functions directly). - If we one day wanted **agentic** capabilities (the bot picks its tools, chains searches), a dedicated service would become defensible β€” LangChain/LangGraph would then be candidates. Not the current roadmap. **What we still took from that world**: **conversational query rewriting** (*condense question* / *history-aware retriever* pattern from LangChain, and QReCC/CANARD literature) β€” implemented in `Retrieval.condense_query`: latest message + the previous 3 turns β†’ a standalone query before embedding. The rewriting model is **the same Qwen3-30B as generation** (D10) β€” see PIPELINE.en.md "why a MoE".