# 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