我的 AI agent 每收到一則訊息,hook 會先跑一次記憶搜尋,把最相關的幾份 journal 或筆記摘要注入 context。這套搜尋從四月的 keyword × 時間 × hall 類型三軸排序 一路長到現在:BM25(jieba 分詞)算基礎分,常駐的 EmbeddingGemma 2 服務給一份語意排名,兩份排名用加權 RRF 合併。
前天深夜到昨天下午,我讓 agent 對它做了一整輪改善,分三層:純程式調整、本機模型、LLM。每一層做完都交叉審查兩輪,總共二十來個 commit。最後採用的不多,但學到最多的部分跟搜尋演算法本身關係不大,主要在量測方法。
先修尺:bench 會被自己汙染
原本的做法是改一版程式、跑一次 recall bench、看 MRR 有沒有升。這樣跑出來的數字有兩種汙染,都是過程中實際撞到的。
語料在兩次 bench 之間變了。 dense 索引每 60 秒增量更新,cron 也會歸檔 journal。某一題的正解剛好是我正在補比較結果的那份筆記,筆記改完之後,這題的 dense 名次從 3 掉到 48,整題變 MISS。看起來像是新版退步,其實是語料不同。
筆記把題目抄進去了。 筆記裡逐字引用了題庫某題的提問,結果這份筆記自己變成 BM25 的第二名,把正解的領先倍數從 1.7 壓到 1.47,剛好低於我設的保護門檻(1.5),保護規則就失效了。修法是改筆記,門檻不動。
所以第一層先做的是量測工具:
| 改動 | 解決什麼 |
|---|---|
| bench 語料快照 | 把 journal、notes、向量索引凍結成一份,另開一個 port 起 embed 服務,前後比較都跑同一份資料;程式一律跑 repo 現行版 |
| 看注入命中,不只看 MRR | --hook 模式重現 hook 實際的注入邏輯(天數、領域加減分、同類去重、門檻、6 秒逾時),回報 inj_hit_rate |
| dev / holdout 切分 | 取捨只看 dev,holdout 只在每層最後驗收;依正解檔分組切,共用正解的題目放同一側 |
看注入命中這點後來很關鍵。分數是依名次繼承的,注入門檻 0.7 幾乎不過濾,所以真正決定 agent 拿到什麼的是排名,MRR 小幅上升不一定代表注入變好。
採用的改動
| 改動 | 結果(同語料對照) |
|---|---|
| dense 切塊改成依 H1–H3 標題切段、小段合併到 800 字 | v1 題庫 MRR 0.796 → 0.840。純依標題切反而退到 0.633:一行標題的小塊在 max-pool 下太容易撞到高分 |
| BM25 壓倒性命中保護:第一名不在 dense 前 50、且 raw 分數 ≥ 其餘最高的 1.5 倍時,釘回第 0 名 | 真實題庫 MRR 0.568 → 0.608,只觸發 1 題、0 退步 |
| RRF pool 50 → 200 | 0.608 → 0.630;dense 服務 k=50 和 k=200 都是 39ms(本來就要對全部區塊算分),hook 端到端中位數 1015 → 1032ms |
| 舊 journal 依 hall 類型決定時間衰減:discoveries / advice / preferences 過窗後 ×0.7,events / facts 維持 ×0.3 | 舊條目題庫 MRR 0.29 → 0.47,另外兩個題庫完全不變 |
| 非每日檔(反思、learnings 封存這類)不吃 mtime 時間加權 | 修正長期檔因為最近被動過就被當成新資料 |
| cross-encoder 重排(bge-reranker-v2-m3) | 見下一段 |
RRF pool 放大那次有個連帶問題:pool 變大之後,原本「不在 dense pool 裡」的正解擠進了尾段,保護條件因此不成立,那題從第 1 掉到 MISS。後來把條件改成「dense 名次 > 50」,跟 pool 大小脫鉤。
保護規則那條只有一題觸發,樣本數是 1。採用它的理由是機制合理:RRF 只看名次,BM25 18.8 對 11.1 這種壓倒性領先,如果 dense 沒排進前段,融合後就會被擠出前 10。
cross-encoder 重排
第二層試本機模型。hybrid 融合後取前 10 名,每份檔挑 2 段給 bge-reranker-v2-m3 打分,再跟原排名做 1:1 RRF。
快照全題的注入命中:
| 題庫 | 前 | 後 |
|---|---|---|
| 真實提問(42 題) | 32 | 35 |
| 簡單題(15 題) | 14 | 13 |
| 舊 journal 條目題(32 題) | 13 | 19 |
純用 cross-encoder 分數排序反而變差,真實題 MRR 0.667 → 0.540,長指令型的提問特別吃虧,所以只做融合。
代價也明確:embed 服務的記憶體 footprint 從 2.2GB 升到 3.56GB(16GB 的 M4),hook 搜尋總時間從約 1 秒變成 2.6–3.2 秒。同時跑其他 LLM 批次時還出現過兩次超過 2.5 秒。
這個代價值不值,bench 回答不了。我讓它在正式環境試開一週,hook 每次呼叫記延遲,到期依下面條件決定:
- hook 逾時(6 秒)次數為 0
- p90 < 4 秒,超過 5 秒的次數接近 0
- 重排成功率 > 90%
- 期間沒有記憶體壓力或 MPS OOM
任何一條不過就關掉。
試過但不採用的
| 做法 | 結果 |
|---|---|
| notes 和 journal 統一改段落級 BM25 | 真實題 MRR 0.565 → 0.529,注入 0.69 → 0.58;稀有詞題的領先倍數被稀釋 |
| journal 條目直接當文件計分 | 短條目靠長度正規化霸榜,真實題 0.647 → 0.51 |
| BM25 加多字虛詞停用詞 | 舊條目題 +0.066,真實題注入少 1 題 |
| 長提問依 IDF 只留前 N 個詞 | MRR 小升、注入下降 |
| dense 的 journal 依條目切塊 | 舊條目題 +0.04,真實題 MRR 0.680 → 0.577 |
| 注入門檻 0.7 → 0.6 | 只多 1 題命中 |
| LLM 改寫查詢 | 只對原始長提問有效(+0.09),對關鍵詞查詢有害。長提問只出現在 hook,而 hook 的 6 秒放不下一次 LLM 呼叫;手動搜尋本來就是 agent 自己下關鍵詞 |
LLM 那層最後什麼都沒進搜尋路徑。比較實用的產出是放在寫入端的一支腳本:每次整理記憶改了筆記,就讓它找出可能被這份筆記推翻的舊 journal 條目,人工確認後在舊條目補一句「已被取代,見某筆記」。搜尋端分不出哪句是過時的,在寫入端標記比較可靠。
真正的瓶頸:題庫太小
把量測做乾淨之後,數字講出一件不太好聽的事:dev 的真實提問大約 20 題,一題翻轉就是 ±0.05 的 MRR。第一層的四項改動,效果都在這個解析度以下。上面表格裡那些 0.03、0.04 的差異,大多分不出是改動還是運氣。
我也試著擴題庫。真實提問是從過去的對話紀錄抽出來的,昨天讓 LLM 判讀 99 個候選,只多出 6 題可用,剩下的都是「繼續」「好」這類接續指令,或答案在彙整檔裡的題目。我沒有自編題目湊數,自編題的作者知道答案,措辭會偏向正解。
所以現在的判斷方式是兩層:數字差距大到超過解析度(舊條目題 0.29 → 0.47、注入 13 → 19)才算數;差距在雜訊內的,就看機制講不講得通,講得通且沒有退步才收。題庫只能靠每月從新的對話紀錄慢慢累積。
順手踩到的坑
- 測試在覆寫正式快取。 有一支 pytest 一直在寫正式的分詞快取,下次 hook 冷啟動要重切整份語料,超過 6 秒逾時,那一輪就沒有記憶注入。用 root
conftest.py把快取路徑隔離掉。 - 批次呼叫
claude -p會帶上整套使用者設定。 不加參數時,每次呼叫都會載入使用者的 hooks 和CLAUDE.md,還會各留一份 session 紀錄。判讀 99 個候選加上其他批次,暫存目錄裡留了 304 份。批次用途要加--safe-mode --no-session-persistence。 - 有 rerank 的 bench 不能並行。 幾組 bench 同時跑會搶 MPS,重排逾時,bench 會把這種情況當模式錯誤直接中止。
收尾
這一輪下來,搜尋本身的改進有限,比較大的收穫在量測:知道哪些數字可以信、哪些在雜訊內。下次再有「換個切法 MRR 升了 0.03」的想法,我會先問題庫分不分得出來。
rerank 還在試開,一週後看延遲紀錄決定留不留。
