# ADR-033: RAG 治理三護欄 — Promotion Gate / Firecrawl 資源 / BGE-M3 一致性

- **Status**: Accepted
- **Date**: 2026-05-03
- **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 護欄級鐵律：

1. **學習污染風險**：LLM 幻覺自動進 RAG → 正反饋錯誤循環
2. **Firecrawl 資源消耗**：自建 Playwright 池吃 188 主機記憶體
3. **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 幻覺檢測規則
```python
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 限制
```yaml
# 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
```yaml
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:latest` floating tag → Ollama upgrade 跳版本
- normalize / pooling 參數未顯式傳遞 → server-side 預設改變無感知
- 跨主機（GCP / Secondary / 111）模型版本可能不一致

#### 簽名鎖定機制
```python
# 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]
```

#### Schema 強制（migration 026）
```sql
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;
```

#### 啟動時驗證（Phase 11.0 護欄）
```python
def verify_embedding_consistency():
    """RAG service 啟動時跑：
    用同一段測試文字呼叫 GCP / Secondary / 111 三主機，
    驗證 cosine 距離 < 1e-4（浮點誤差），否則拒絕啟動。
    """
    test_text = "momo電商競品分析測試向量一致性檢查"
    embeddings = {
        host: call_ollama(host, 'bge-m3:latest', test_text)
        for host in [GCP_PRIMARY, GCP_SECONDARY, OLLAMA_111]
    }
    diffs = [cosine_distance(embeddings[a], embeddings[b])
             for a, b in itertools.combinations(embeddings, 2)]
    if max(diffs) > 1e-4:
        raise EmbeddingInconsistencyError(...)
```

#### RAG 查詢時保護
```python
# 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）
1. **學習污染防火牆**：4 階段閘 + 強制人工驗收，幻覺進 RAG 機率 < 5%
2. **資源預測性**：Firecrawl mem_limit 2g + chrome-reaper，188 主機絕對安全
3. **模型升級可控**：embedding_signature 不變才 RAG 採用，模型漂移立即可見
4. **PII 安全**：human_approver SHA1[:8]，反饋紀錄不暴露 Telegram username
5. **成本可控**：純規則引擎（Stage 1-3）+ 24h auto-expire（Stage 4），零 LLM 成本

### 負面（3）
1. **Stage 4 統帥疲勞**：高權重 episode 都要看 Telegram → mitigate by `expired` 自動降級
2. **Firecrawl mem 2g 上限可能太小**：複雜 SPA 爬蟲可能超 → 監控告警 + 可調 env
3. **Embedding signature 變更需全表回填**：PG14 ADD COLUMN metadata-only 不鎖表，但回填 14k+ 筆需 worker 跑數小時

### 風險（4）
1. **Stage 2 規則漏判**：規則引擎可能誤放幻覺進 → mitigate by Stage 4 人工最後關
2. **Firecrawl OOM 連鎖**：mem_limit 觸發 OOM kill → mitigate by healthcheck + 重啟策略
3. **Embedding 模型升級時 RAG 完全失效**：所有 hit signature 不符 → 安全降級為「LLM-only」直到回填完成
4. **24h expired 太久**：用戶可能來不及反饋 → 可調 `HUMAN_REVIEW_TIMEOUT_HOURS`

---

## Verification

### V1：Promotion Gate 阻擋率（部署 1 週後）
```sql
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 反饋率
```sql
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 資源（部署後）
```bash
ssh ollama@192.168.0.188 'docker stats firecrawl-self --no-stream --format "{{.MemUsage}}"'
# 期望 < 1.8GB（mem_limit 2GB 的 90%）
```

### V4：Embedding 一致性
```sql
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 將補）
