# Phase 11 DB 設計：RAG + 自主學習迴圈

- **戰役**: Operation Ollama-First v5.0 — Phase 11
- **作者**: A3 db-expert
- **日期**: 2026-05-03 台北
- **migration**: `migrations/027_create_rag_query_log.sql`、`migrations/028_create_learning_episodes.sql`
- **對應 ADR**: ADR-029（Hermes-First）、ADR-002（pgvector 唯一向量庫）、ADR-007（pgvector 啟用）
- **前置 migration**: 024（ai_calls）、025（mcp_calls + ai_call_budgets）、026（embedding_signature）

---

## 1. 為何分兩表（rag_query_log vs learning_episodes）

兩個表責任完全不同，混表會讓**讀寫模式衝突**且**保留週期混淆**。

| 維度 | rag_query_log | learning_episodes |
|---|---|---|
| **角色** | RAG 召回的 audit log | 知識庫前哨（蒸餾池） |
| **資料方向** | 從用戶/呼叫者「進來」 | 給 ai_insights「出去」 |
| **生命週期** | 90 天滾動刪除 | 長期（approved/rejected 走冷儲檔） |
| **寫入頻率** | 高（每次 RAG 召回都寫） | 中（過 quality 才寫） |
| **PII 風險** | 高（query_text = 用戶問題） | 低（distilled 已蒸餾） |
| **典型查詢** | 「過去 24h 命中率」「caller 分布」 | 「待人工驗收清單」「Stage 3 dedup query」 |
| **是否進 RAG 召回語料** | 否（只是 log） | 否（只有晉升 ai_insights 後才進） |

**反證**：若合表，會出現
- query_text PII 與蒸餾文本同表→ 90 天保留無法分別套用
- 高頻寫入 audit log 與低頻寫入蒸餾池共享 ivfflat 索引 → vacuum / REINDEX 衝突
- promotion_status 對 audit log 無意義，但要忍受 NULL

故維持分表。

---

## 2. ivfflat lists=100 計算依據

pgvector 官方建議：
- `lists ≈ rows / 1000`（rows < 1M）
- `lists ≈ sqrt(rows)`（rows ≥ 1M）

**rag_query_log 量推估**：
- 假設 Phase 11 上線後每日 RAG 召回 5,000 次（hermes_qa + openclaw_qa + 內部 caller）
- 90 天保留 → 穩態約 **450k 行**
- `lists ≈ 450 / 1`，但太小（<10）會退化成全掃；取下限 100
- 等流量上升到 1M 行時（約 200 天後若日 5k → 不會到 1M），再 `REINDEX ... WITH (lists=1000)`

**learning_episodes 量推估**：
- 假設每日蒸餾 200 筆（rejected ~70%、approved ~30%）→ 全保留
- 一年約 73k 行；2 年約 146k 行
- `lists=100` 在 1M 以下都合理

**重訓 SOP**（寫入 ADR-029 後續維運章節）：
```sql
-- 每月由 scheduler 檢查，若 EXPLAIN cost / actual_time 退化 5x，重訓
REINDEX INDEX CONCURRENTLY idx_rag_query_log_embedding;
REINDEX INDEX CONCURRENTLY idx_le_embedding;
```

**為何不用 HNSW（009 ai_insights 用 HNSW）**：
- HNSW 寫入比 ivfflat 慢 5-10x（高頻寫入的 rag_query_log 不適合）
- HNSW 不需訓練，但**索引大小**約為 ivfflat 的 2-4×
- ai_insights 是「讀多寫少」（KM 沉澱）—— HNSW 合理
- rag_query_log / learning_episodes 是「寫多讀中」—— ivfflat 合理

---

## 3. promotion_status 狀態機

```
                  ┌─────────────┐
                  │   pending   │ (初始)
                  └──────┬──────┘
                         │
            ┌────────────┴───────────────┐
            │                            │
   Stage 1: quality<0.7        Stage 2: 規則檢測幻覺
            │                            │
            ▼                            ▼
   ┌──────────────────┐      ┌───────────────────────┐
   │ rejected_quality │      │rejected_hallucination │
   └──────────────────┘      └───────────────────────┘

   Stage 1+2 通過：
            │
            ▼
   Stage 3: 與既有 insight cosine>0.95
            │
            ▼
   ┌────────────────────┐
   │ rejected_duplicate │ (若太相似)
   └────────────────────┘

   Stage 3 通過：
            │
   ┌────────┴────────────┐
   │                     │
 weight<0.8           weight>=0.8
   │                     │
   ▼                     ▼
┌──────────┐   ┌──────────────────┐
│ approved │   │ awaiting_review  │ ← Telegram 推播
└──────────┘   └────────┬─────────┘
   │                    │
   │         ┌──────────┼─────────────┐
   │         │          │             │
   │       人工 👍    人工 👎      24h 無反饋
   │         │          │             │
   │         ▼          ▼             ▼
   │    ┌──────────┐  ┌──────────────┐  ┌──────────┐
   │    │ approved │  │rejected_human│  │ expired  │── 降 weight=0.5 重走 Stage 4a
   │    └──────────┘  └──────────────┘  └──────────┘
   │         │
   ▼         ▼
   寫 ai_insights → insight_id 回填
```

**關鍵 invariants（已用 CHECK 強制）**：
1. `approved ⇔ insight_id IS NOT NULL`（chk_le_approved_consistent）
2. `rejected_* ⇒ rejected_reason IS NOT NULL`（chk_le_rejected_reason）
3. `human_approver IS NOT NULL ⇒ reviewed_at IS NOT NULL`（chk_le_review_consistent）

---

## 4. 90 天保留策略

| 表 | 保留 | 工具 | 預計排程 |
|---|---|---|---|
| `rag_query_log` | 90 天 | scheduler `DELETE WHERE queried_at < NOW() - INTERVAL '90 days'` | 03:30 daily |
| `learning_episodes` (pending/awaiting_review) | 永久（直到狀態變化） | — | — |
| `learning_episodes` (approved) | 永久（蒸餾溯源） | — | — |
| `learning_episodes` (rejected_*/expired) | 180 天後可冷儲檔 | 後續 ADR 定 | monthly |
| `ai_calls` | 90 天 | （已存在 migration 024 註解） | 03:00 daily |
| `mcp_calls` | 90 天 | 同上 | 03:15 daily |

**為何 rag_query_log 與 ai_calls 同 90 天**：兩者透過 `request_id` 串鏈；若不同保留期會出現「ai_calls 已刪、rag_query_log 留著」的孤兒，反查 trace 會斷。

**learning_episodes 不限期保留的依據**：蒸餾池是「為什麼這條 insight 進了 KM」的證據鏈。`rejected_*` 也保留是為了**防止同類錯誤被反覆生成**（PromotionGate Stage 3 dedup 可參考歷史 rejected）。

---

## 5. 風險評估

### R1（HIGH）—— query_text PII 落地

- **風險**：`rag_query_log.query_text` 是用戶原始輸入，可能含人名/手機/訂單號
- **緩解**：
  1. CHECK `octet_length <= 4096` 限長度
  2. 90 天滾動刪除
  3. 應用層在寫入前對「明顯 PII pattern」做 redact（如 `\d{10}` 手機）
  4. `learning_episodes.distilled_text` 必須是「蒸餾後」文本，**禁止**直接複製 query_text
- **未解殘留風險**：90 天內 DBA query 仍可看到原始問題；建議搭配 PostgreSQL row-level audit log 追蹤誰查過

### R2（HIGH）—— 蒸餾失誤污染 RAG

- **風險**：低品質 `learning_episodes` 過閘晉升 `ai_insights` → RAG 召回幻覺擴散
- **緩解**：
  1. PromotionGate 4 階段（quality / hallucination / duplicate / human）
  2. `weight>=0.8` 強制人工驗收（chk_le_review_consistent）
  3. `rejected_*` 必填 rejected_reason（chk_le_rejected_reason），事後可審計

### R3（MEDIUM）—— ivfflat 索引膨脹 / 退化

- **風險**：高頻寫入 + 不重訓 → recall 退化
- **緩解**：
  1. partial index `WHERE query_embedding IS NOT NULL` 縮體積
  2. monthly REINDEX CONCURRENTLY（見上 §2 SOP）
  3. EXPLAIN ANALYZE alarm（cost > baseline 5x 時告警）

### R4（MEDIUM）—— ai_insights 軟連結 dangling

- **風險**：`learning_episodes.insight_id` 無 FK，若 ai_insights archive，蒸餾池會留 dangling pointer
- **緩解**：
  1. archive 時保留 ai_insights 主鍵（採 status='archived' soft delete，而非 DELETE）
  2. 應用層 join 用 LEFT JOIN，dangling 顯示為 "已歸檔"

### R5（LOW）—— used_results BIGINT[] 反正規化

- **風險**：`rag_query_log.used_results` 用陣列存命中 ai_insights.id，違反正規化
- **緩解理由**：
  1. 召回每筆平均 3-5 個 id，若拆 join table 會 5x 寫入放大
  2. 反向查詢「某 insight 被多少 RAG 命中」是低頻分析，可用 `WHERE id = ANY(used_results)` 或 GIN 索引補（V2 再加）
- **接受該風險**

### R6（LOW）—— caller 白名單未在 DB 強制

- **風險**：應用層可能寫入未知 caller，污染統計
- **緩解**：
  1. ai_calls 已有 caller 白名單註釋，logger 統一強制
  2. 本表加 CHECK 會與 ai_calls 雙寫漂移；改由 application layer 單一真理源
- **接受該風險**

---

## 6. 驗收清單（給 critic）

- [x] 027 / 028 連續編號，未跳號
- [x] BIGSERIAL 主鍵對齊 024/025
- [x] CHECK 風格對齊 critic-A11（白名單 + size + range）
- [x] partial index 對 sparse 欄位（request_id / insight_id / status）
- [x] ivfflat lists=100 + cosine + 1024 維對齊 bge-m3
- [x] GRANT 權限對齊（momo + sequence）
- [x] 不在 migration 內 CONCURRENTLY（無既存大表）
- [x] 回滾腳本附在 migration 頂部註解
- [x] 與 ai_calls/mcp_calls 透過 request_id 串鏈
- [x] PII 護欄（query_text 4KB / distilled 16KB）
- [x] 狀態機 invariant 用 CHECK 鎖死
- [x] 不自動 commit / 不自動 apply
