depiedencap-rag/docs/rag/PIPELINE.en.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

207 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RAG pipeline — from Postgres to the bot reply
📘 Cette page est également disponible en [français 🇫🇷](./PIPELINE.md)
Architecture document: the full path of a user question, from raw forum
data to the cited answer. See [`DESIGN.en.md`](DESIGN.en.md) for the
structural choices and [`EVAL.en.md`](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`
(native `post_truncation` strategy, `strategy_id=1`, truncated at
`max_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 `SemanticSearch` on 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=false` or 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 EVIDENCE` prompt 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/id`
**server-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) → cosine
`ai_posts_embeddings` top-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_restricted` categories excluded — the legacy channel had
`Guardian`/`Search`; direct SQL must filter itself
- **input gate**: `Retrieval.gate_match` in `PlaygroundHook`
(reply_to + schedule_bot_reply) → `publish_canned_reply!` posts the
fixed reply with no LLM; tagged `depiedencap_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 cp` plugin → 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.