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>
9.9 KiB
RAG pipeline — from Postgres to the bot reply
📘 Cette page est également disponible en français 🇫🇷
Architecture document: the full path of a user question, from raw forum
data to the cited answer. See DESIGN.en.md for the
structural choices and EVAL.en.md for measured metrics.
Overview
POSTGRES (posts, topics) ← Discourse replica/prod
│
│ [offline, once per piece of content]
▼
bge-m3 → 1024-d vector ← embedding model (Ollama)
│ ai_posts_embeddings (pgvector)
│
USER QUESTION ← at query time
│
├─► STAGE 0 — input gate (optional, deterministic)
│ regex on INTENT (not topics) → immediate decline
│ if danger/illegal/misuse detected — see SAFETY.en.md
│
├─► STAGE 1 — recall
│ question → bge-m3 → cosine over 425k post vectors → top-25
│ (+ BM25 keyword channel / semantic topic in RRF fusion)
│
├─► STAGE 2 — precision (rerank)
│ mmarco cross-encoder: score (question, candidate) ×25 → re-sort
│
├─► aggregation: dedup by topic → top-5/6 sources
│ + graduated zone on top-1 CE score:
│ < −4 → empty-evidence block (abstention)
│ [−4, 0) → WEAK block (sources + "assume the weakness")
│ ≥ 0 → normal "STABLE FORUM EVIDENCE" block
│ (config D: ± neighbouring posts for context)
│
▼
System prompt = persona + refusal clause (SAFETY.en.md)
│ + evidence block: titles + excerpts + citation rules
│ strict [[1]]..[[6]] + "evidence does not widen your scope"
▼
LLM (mini PC) → drafted answer + [[n]] markers
│
▼
Server sanitizer: [[n]] → /t/slug/id URLs ← links built server-side,
never by the LLM
Models in play
| Model | Type | Role | Where it runs |
|---|---|---|---|
| bge-m3 | bi-encoder (embedding) | text → 1024-dim vector. Encodes each post once (backfill) and each question at query time | Ollama — RTX 2060 laptop in dev; Ollama mini PC :32134 in prod |
| cross-encoder mmarco-mMiniLMv2-L12-H384 | cross-encoder | relevance score of a pair (question, candidate) — reads both together | dev: sentence-transformers laptop CPU; prod: TEI /rerank on the mini PC (k3s pod reranker, NodePort 32136, Tailscale only) |
chat LLM qwen3:30b-a3b-q6k |
generative (MoE ~3B active) | writes the answer from the 6 provided sources + multi-turn query rewrite (condense_query, think:false) |
mini PC (Ollama :32134) — one resident model for both roles |
| Postgres/pgvector | — | storage + cosine/hamming distance | Discourse container (VPS in prod) |
Why a 30B MoE on a modest iGPU — the structural choice
The mini PC 780M iGPU has no dedicated VRAM: weights live in unified RAM (GTT, tens of GiB). Two consequences:
- Capacity: a quantized 30B (~20+ GiB) fits where a same-form-factor dGPU would cap at 8-16 GB VRAM — the limiter is system RAM, of which we have 48 GB.
- Speed: iGPU inference is memory-bandwidth bound (each token rereads the active weights). A MoE activates only ~3B parameters per token → throughput stays high despite total model size. Measured: query rewrite in ~1.0 s hot vs 1.6 s for a dense llama3.1:8B — the "big" model is faster than the small one because it is MoE.
That is why query rewriting moved from llama3.1:8b to the main LLM
(D10, 17/09): faster, equal or better quality, and one fewer resident
model to keep warm in RAM.
Bi-encoder vs cross-encoder — why both
They are not competitors but two complementary stages:
- bge-m3 (bi-encoder): question and post are encoded separately. The post vector is precomputed → comparing 425k vectors = a few ms. But the model never "sees" the pair together: relevance is indirect (geometric proximity).
- cross-encoder: takes the concatenated pair
(question, candidate)as input → direct, token-level relevance judgment. More precise, but impossible to precompute: using it alone would cost 425k scorings per question. So it only re-sorts the top-25 from pgvector.
Hence: cosine for recall (miss nothing), cross-encoder for fine ranking (order well). Cosine is never dropped.
Measured latency (i5-10300H laptop)
| Step | Cost |
|---|---|
| question embedding (bge-m3) | ~ms (one vector) |
| pgvector cosine top-25 on 425k rows | ~ms (HNSW/scan index) |
| cross-encoder rerank, 25 pairs | ~75-100 ms (~3-4 ms/pair) |
| model load (once, at boot) | ~5 s |
| LLM generation | several seconds (the real bottleneck) |
→ Rerank adds ~100 ms per query: negligible next to LLM generation.
In prod it runs on the mini PC (k3s pod rag-depiedencap/reranker,
TEI cpu-1.6, NodePort 32136 — not on the VPS, whose RAM is tight
~300-400 MiB free). Checked: TEI/ONNX logits are identical to
sentence-transformers PyTorch → thresholds measured in eval (-4 / 0)
stay valid in prod; the plugin converts the TEI sigmoid score back to
a logit.
Detailed steps
1. Indexing (offline)
Each eligible post → ai_posts_embeddings (one row per post):
- embedded text =
thread title + category + tags + post body(nativepost_truncationstrategy,strategy_id=1, truncated atmax_sequence_length - 2) - post grain is the structural decision: a 2 700-post thread has a
diluted, invisible topic vector, while its individual posts stay sharp
(measured: Hit@5 0.68 → 0.94, see
EVAL.en.md) - rejected after measurement: OP/parent-enriched text (
strategy_id=2, -9 Hit@5 points — dilutes the post, EVAL.en.md §4quater)
2. Retrieval (per question)
- posts: direct cosine on
ai_posts_embeddings→ top-25 posts - keyword:
Search.execute(Discourse BM25) on distinctive words - semantic: native
SemanticSearchon topic embeddings - hybrid (prod fallback): custom fusion
Retrieval.retrieve+ title gate + boosts — prod state before this work, kept as fallback (depiedencap_ai_citations_posts_pipeline=falseor empty posts table or error) - RRF fusions:
Σ 1/(60+rank)over the lists — posts×hybrid is the best measured combination (+0.01 MRR vs posts alone) - rejected: routing by thread size (RRF already routes implicitly)
3. Rerank (config E)
Top-25 posts → cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 score
(question, title + 300c excerpt) → re-sort → dedup by topic.
Measured: Hit@5 0.94 → 0.97, MRR 0.84 → 0.89 (with hybrid fusion).
4. Generation (evaluated — run_full_eval.py, real mini-PC LLM)
- the 6 best sources →
STABLE FORUM EVIDENCEprompt with strict rules (cite[[n]], never write a URL, abstain if empty) - three evidence zones from top-1 CE score (EVAL.en.md §4.4):
abstention
< −4, cautious framing[−4, 0), normal≥ 0 - refusal clause in the system prompt + role-limit reminder in the evidence template (SAFETY.en.md — 20/20 probes)
- the LLM writes; the sanitizer resolves
[[n]]→/t/slug/idserver-side — the LLM cannot invent a link - measured: valid citations, OOD abstention, refusal/abuse, evidence injection, quality regression (A/B n=133 — no degradation outside the weak zone)
File map
| What | Where |
|---|---|
| Prod retrieval (fusion, gate, prompt) | discourse_plugin/depiedencap-ai-citations/lib/retrieval.rb |
| Enriched backfill (config C, rejected) | rag/eval/backfill_enriched.rb |
| Retrieval eval (inside the container) | rag/eval/eval_retrieval.rb |
| Metrics + fusions + rerank harness | rag/eval/run_eval.py |
| End-to-end generation eval | rag/eval/eval_full.rb + run_full_eval.py |
| Question set + goldens | rag/eval/questions.yml |
| Safety probes (refusal/injection) | rag/eval/safety.yml |
| Input gate (regex rules) | rag/eval/input_gate.yml |
| System-prompt variants | rag/eval/prompt_clauses/ |
| Timestamped results | rag/eval/results/*.json |
| Measurements and decisions | rag/EVAL.md + rag/SAFETY.md (FR) / EVAL.en.md + SAFETY.en.md |
| Local forum environment | rag/LOCAL_DEV.md (French working doc) |
| Pre-deploy prod snapshots | rag/prod_snapshots/ |
Production port (15/09/2026)
The v2 channel lives in Retrieval behind two site settings
(depiedencap_ai_citations_posts_pipeline, depiedencap_ai_citations_input_gate,
both false by default → deploying the code changes nothing until the
flags are flipped):
retrieve_posts_pack:vector_from(question)(bge-m3 mini PC) → cosineai_posts_embeddingstop-25 →POST /rerank(mini PC :32136) → topic dedup → 6 sources with post-grain URL/t/slug/id/post_number- exclusions added vs eval (prod only):
post_type=regular, non-deleted posts/topics,archetype='regular'(no PMs),read_restrictedcategories excluded — the legacy channel hadGuardian/Search; direct SQL must filter itself - input gate:
Retrieval.gate_matchinPlaygroundHook(reply_to + schedule_bot_reply) →publish_canned_reply!posts the fixed reply with no LLM; taggeddepiedencap_ai_citations=t→ the sanitizer skips it - measured fallbacks: flag off → legacy; empty
ai_posts_embeddings→ legacy (posts_table_ready?guard, 60 s cache); embeddings endpoint down → legacy; reranker down → cosine order only, zone:ok(config B measured 0.94) - deploy:
docker cpplugin → container +/shared/…+sv restart unicorn(Ruby only, no precompile)
Native posts backfill (Jobs::EmbeddingsBackfill, 5 min, low queue)
was enabled with ai_embeddings_per_post_enabled=true +
ai_embeddings_backfill_batch_size raised temporarily — it produces
(model_id=1, strategy_id=1, strategy_version=1), exactly the winning
evaluated strategy. Flags flip only once backfill is done.