11 KiB
11 KiB
ADR-033: RAG 治理三護欄 — Promotion Gate / Firecrawl 資源 / BGE-M3 一致性
- Status: Accepted
- Date: 2026-05-03
- Amended: 2026-07-17 (V10.814 digest gate, degraded GCP quorum and cold-start SLO)
- Decision Maker: 統帥
- Author: Operation Ollama-First v5.0(Owen 三點專業洞察 → v5.0 強化)
- Related: ADR-032(RAG 自主學習迴圈)、ADR-031(MCP 自建)、ADR-002(pgvector)、ADR-027(Primary Ollama on GCP)
Context
戰役 v4.0 階段 Owen 提出三點專業洞察,被升級為 v5.0 護欄級鐵律:
- 學習污染風險:LLM 幻覺自動進 RAG → 正反饋錯誤循環
- Firecrawl 資源消耗:自建 Playwright 池吃 188 主機記憶體
- BGE-M3 Embedding 一致性:floating tag → RAG 召回率悄悄退化
這三點不是普通建議,而是 RAG 系統能否安全長期運轉的命脈。本 ADR 鎖定三護欄的設計決策與驗收條件。
Decision — 三護欄架構
護欄 #1:Promotion Gate(學習污染防護)
核心原則:反饋按鈕從「選配」升級為「強制晉升門檻」。learning_episodes → ai_insights 必經 4 階段嚴格門檻。
4 階段晉升閘
learning_episodes (pending)
↓ Stage 1: quality_score >= 0.7(蒸餾器自動評分)
↓ Stage 2: 無幻覺檢測(規則引擎,零 LLM)
↓ Stage 3: 與既有 insight 相似度 < 0.95(去重)
↓ Stage 4: weight >= 0.8 必經 Telegram 👍/👎 人工驗收
ai_insights (approved)
Stage 2 幻覺檢測規則
HALLUCINATION_PATTERNS = [
# 規則 1:含「可能 / 也許 / 我猜測」+ 缺具體數字
lambda txt: any(p in txt for p in ['可能', '也許', '我猜', '推測'])
and not any(c.isdigit() for c in txt),
# 規則 2:自相矛盾(同段含 'A=X' 又含 'A=Y')
detect_contradiction,
# 規則 3:引用不存在 SKU/品牌(查 DB)
lambda txt: not _verify_skus_exist(extract_skus(txt)),
]
Stage 4 強制門檻(Owen 鐵律)
- weight >= 0.8 → 推 Telegram + 等 24h 👍/👎
- 24h 無回應 →
expired(weight 降 0.5,不晉升) - 用戶 👎 →
rejected_human(永不晉升) - 用戶 👍 →
approved寫 ai_insights
無條件規則:高權重 episode 不能跳 Stage 4,即使 Stage 1-3 都過。
護欄 #2:Firecrawl 資源護欄(188 主機保護)
Docker 限制
# docker-compose.mcp.yml(Phase 10 將部署)
services:
firecrawl-self:
image: firecrawl/firecrawl:latest
deploy:
resources:
limits:
memory: 2g # ⭐ Owen 要求硬上限
cpus: '1.5'
environment:
- PLAYWRIGHT_BROWSER_POOL_MAX=3 # 瀏覽器池上限
- SCRAPE_TIMEOUT_MS=30000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
interval: 30s
Chrome 殘留清理 sidecar
chrome-reaper:
image: alpine:3
command: |
sh -c "while true; do
docker exec firecrawl-self pkill -f 'chrome.*--type=zygote' 2>/dev/null;
docker exec firecrawl-self pkill -f 'chrome.*--type=renderer' 2>/dev/null;
sleep 3600;
done"
Telegram 告警
- 每小時檢查 firecrawl 容器 RSS
-
1.8GB → 🟠 P2 告警(記憶體即將達上限)
護欄 #3:BGE-M3 Embedding 一致性(RAG 命脈)
風險來源
bge-m3:latestfloating tag → Ollama upgrade 跳版本- normalize / pooling 參數未顯式傳遞 → server-side 預設改變無感知
- 跨主機(GCP-A / GCP-B)模型版本可能不一致;111 不得承接背景 embedding quorum
/api/tags成功但模型尚未載入時,冷啟動延遲可能遠高於 warm-call latency
簽名鎖定機制
# services/rag_service.py
def get_embedding_signature(
model: str = 'bge-m3:latest',
dim: int = 1024,
normalize: bool = True,
) -> str:
"""SHA1({model}|{normalize}|{dim})[:12]"""
raw = f"{model}|{str(normalize).lower()}|{dim}"
return hashlib.sha1(raw.encode()).hexdigest()[:12]
embedding_signature 保留資料相容性判定;供應鏈身分另以
RAG_EMBED_EXPECTED_DIGEST 固定 manifest digest。每次 canary 必須先由
Ollama /api/tags 讀回 runtime digest,匹配後才允許產生 embedding。只設定
bge-m3:latest 而沒有 expected digest 不構成 immutable contract。
Schema 強制(migration 026)
ALTER TABLE ai_insights
ADD COLUMN IF NOT EXISTS embedding_signature VARCHAR(64);
CREATE INDEX CONCURRENTLY idx_ai_insights_embedding_signature
ON ai_insights (embedding_signature)
WHERE embedding IS NOT NULL;
啟動 / canary 驗證(Phase 11.0 + V10.814 護欄)
def verify_embedding_consistency():
"""先驗證 GCP-A/GCP-B manifest digest,再產生測試 embedding。
兩台可達時驗證 cosine 距離 < 1e-4;只有一台 digest-verified GCP
可達時,只允許 degraded read-only shadow canary,並保留 redundancy
activation blocker。零台 GCP 或只有 111 時 fail closed。
"""
test_text = "momo電商競品分析測試向量一致性檢查"
embeddings = {}
for host in [GCP_PRIMARY, GCP_SECONDARY]:
assert read_manifest_digest(host, 'bge-m3:latest') == EXPECTED_DIGEST
embeddings[host] = call_ollama(
host,
'bge-m3:latest',
test_text,
canary_timeout_cap=150,
)
assert len(embeddings) >= 1
diffs = [cosine_distance(embeddings[a], embeddings[b])
for a, b in itertools.combinations(embeddings, 2)]
if diffs and max(diffs) > 1e-4:
raise EmbeddingInconsistencyError(...)
150 秒只適用於已通過 exact digest 的 bounded canary cold start。Windows 99
run 534c6208-2088-428c-ba6f-f7a63a67f422 實測 GCP-B BGE-M3 冷啟動
116.475s、1024 維成功;一般查詢與 background worker 不得因此全面放大
timeout。GCP-A control-plane identity 未 reconciliation 時,不得用 blind start
或新增付費 VM 取代 redundancy blocker。
RAG 查詢時保護
# rag_service.py:_select_hits
for hit in candidates:
if hit.embedding_signature != current_signature:
logger.warning(f"Signature mismatch: hit={hit.id}, "
f"expected={current_signature}, got={hit.embedding_signature}")
continue # 不採用該筆
Alternatives Considered
| 方案 | 否決理由 |
|---|---|
| A. RAG 不要反饋按鈕(純自動晉升) | LLM 幻覺進 RAG 後正反饋錯誤循環,是 RAG 系統最危險的失敗模式 |
| B. Firecrawl 不限資源(讓它跑) | 188 主機跑 5+ project(reference_188_multi_project),OOM 會拖垮其他容器 |
| C. BGE-M3 用 :latest 接受漂移 | 模型升級時無告警,RAG 召回率悄悄退化,問題暴露時難回溯 |
| D. 三護欄都用 LLM 做(如 LLM 蒸餾、LLM 幻覺檢測) | 循環燒錢 + 引入新幻覺風險(LLM 檢測 LLM 幻覺) |
| E. Stage 4 改為非強制(高 weight 直接 approved) | 違反 Owen 鐵律 — 統帥反饋是 RAG 系統不被污染的最後一道防線 |
Consequences
正面(5)
- 學習污染防火牆:4 階段閘 + 強制人工驗收,幻覺進 RAG 機率 < 5%
- 資源預測性:Firecrawl mem_limit 2g + chrome-reaper,188 主機絕對安全
- 模型升級可控:embedding_signature 不變才 RAG 採用,模型漂移立即可見
- PII 安全:human_approver SHA1[:8],反饋紀錄不暴露 Telegram username
- 成本可控:純規則引擎(Stage 1-3)+ 24h auto-expire(Stage 4),零 LLM 成本
負面(3)
- Stage 4 統帥疲勞:高權重 episode 都要看 Telegram → mitigate by
expired自動降級 - Firecrawl mem 2g 上限可能太小:複雜 SPA 爬蟲可能超 → 監控告警 + 可調 env
- Embedding signature 變更需全表回填:PG14 ADD COLUMN metadata-only 不鎖表,但回填 14k+ 筆需 worker 跑數小時
風險(4)
- Stage 2 規則漏判:規則引擎可能誤放幻覺進 → mitigate by Stage 4 人工最後關
- Firecrawl OOM 連鎖:mem_limit 觸發 OOM kill → mitigate by healthcheck + 重啟策略
- Embedding 模型升級時 RAG 完全失效:所有 hit signature 不符 → 安全降級為「LLM-only」直到回填完成
- 24h expired 太久:用戶可能來不及反饋 → 可調
HUMAN_REVIEW_TIMEOUT_HOURS
Verification
V1:Promotion Gate 阻擋率(部署 1 週後)
SELECT promotion_status, COUNT(*)
FROM learning_episodes
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY promotion_status;
-- 期望: rejected_hallucination >= 1(證明 Stage 2 真的擋下幻覺)
-- 期望: approved + awaiting_review > 50%
V2:Stage 4 反饋率
SELECT
COUNT(*) FILTER (WHERE promotion_status = 'awaiting_review') AS pending,
COUNT(*) FILTER (WHERE promotion_status = 'approved' AND human_approver IS NOT NULL) AS human_approved,
COUNT(*) FILTER (WHERE promotion_status = 'rejected_human') AS human_rejected,
COUNT(*) FILTER (WHERE promotion_status = 'expired') AS expired
FROM learning_episodes;
-- 期望: human_approved + human_rejected > expired(統帥真的有看 Telegram)
V3:Firecrawl 資源(部署後)
ssh ollama@192.168.0.188 'docker stats firecrawl-self --no-stream --format "{{.MemUsage}}"'
# 期望 < 1.8GB(mem_limit 2GB 的 90%)
V4:Embedding 一致性
SELECT embedding_signature, COUNT(*), MIN(created_at), MAX(created_at)
FROM ai_insights
WHERE embedding IS NOT NULL
GROUP BY embedding_signature
ORDER BY MAX(created_at) DESC;
-- 期望: 單一簽名(多個 = 模型漂移)
Migration Plan
| 護欄 | 部分 | 狀態 |
|---|---|---|
| #1 PromotionGate Schema | learning_episodes 8 狀態機 | ✅ migration 028 commit 2f20d8d |
| #1 PromotionGate Service | 4 階段邏輯 + reject/promote | ✅ services/learning_pipeline.py commit c7d6db3 |
| #1 反饋按鈕 | rag_feedback + promotion_review | ✅ telegram_templates + bot routes commit c7d6db3 |
| #1 awaiting_review 推播 | Telegram 推 episode 給統帥看 | ⏳ Phase 12+ |
| #2 Firecrawl mem_limit | docker-compose.mcp.yml | ⏳ Phase 10 部署 |
| #2 chrome-reaper sidecar | 同上 | ⏳ Phase 10 |
| #2 RSS 監控告警 | scheduler 加每小時 task | ⏳ Phase 10 |
| #3 embedding_signature 欄位 | ai_insights 加欄位 | ✅ migration 026 commit 4648673 |
| #3 簽名計算 | rag_service.get_embedding_signature() | ✅ commit c7d6db3 |
| #3 啟動驗證 verify_consistency | 跨主機 cosine 比對 | ⏳ Phase 11+ 補(Phase 11.0 規格) |
| #3 既有 14k 筆回填 | UPDATE ai_insights SET embedding_signature = ... | ⏳ Phase 11+ 補 |
References
migrations/026_add_embedding_signature.sql(含 pgcrypto extension)migrations/028_create_learning_episodes.sql(8 狀態機 CHECK)services/rag_service.py:get_embedding_signature()services/learning_pipeline.py(PromotionGate 4 階段)tests/test_promotion_gate.py(23 unit tests)- ADR-002(pgvector 唯一)
- ADR-027(三主機架構)
- ADR-032(RAG 自主學習迴圈)
- ADR-031(MCP 自建 — Phase 10 將補)