給 LLM agent 用的記憶系統寫了半年,最後發現自己打開次數最多的不是 MEMORY.md 或主題筆記,而是 memory/YYYY-MM-DD.md 這份逐日 journal。日常查的也多半是「上次搞 PostgreSQL 是什麼時候」「那個 config 的決定寫在哪天」這種問題。

問題是:搜尋它不太好用。

  • grep 噪音太多。半年的 journal 把任何常用詞炸出幾百個 hit。
  • embedding-only 搜尋對 journal 不適合。journal 裡面多半是兩三行的短 entry,embedding 模型給短句的向量區分度不夠;而且 journal 每天長,每次 incremental embed 都要算 hash、做 rate limit、管 cache,出錯就是一整天的記憶搜不到。
  • 最關鍵的是,journal 是本質上有時間性的資料。我問「上次搞 PostgreSQL 是什麼時候」,語意相似度幫不上忙,時間才是主角。

所以我把 journal 搜尋重新當成一個 ranking 問題,用三個分數軸融合:keyword overlap × temporal recency × hall-type boost。Python 不到 130 行,沒有外部依賴。

這篇記錄設計過程、實測結果,還有一個在 bootstrap 測試時才發現的 case-sensitivity bug。

為什麼不直接用 embedding

先說清楚:embedding 不是沒用。我自己在 notes/ 的相關筆記推薦也還是用 embedding 跑 cron。問題是把它用在 journal 搜尋上有幾個具體的不匹配:

  1. 短文本訊號稀薄。一條 entry 常常只有一行,例如「決定採用 PostgreSQL」,embedding 模型對這種短句的區分度不夠。
  2. 時間性完全沒被 encode。cosine similarity 不知道「昨天的決定」比「三個月前的事件」更重要。要補回去就得再疊一層 rerank。
  3. Rebuild 成本隨時間線性增長。每天新增 entry 就得 incremental embed,任何一個環節出錯,當天記憶就查不到。
  4. API quota 風險。免費額度撐不住 journal 的 embed 頻率。

換句話說,embedding 適合「概念查詢」:query 跟 content 沒共用詞,只是意思接近。不適合「日誌式查詢」:使用者想找某個具體時間發生的事。我的 journal 搜尋場景幾乎都是後者。

三個分數軸的設計

核心函式長這樣:

STOPWORDS = frozenset("的了是在和有你他她它這那也就都不會能要...")

def extract_keywords(text: str) -> set:
    # 降 lower 在 regex 之前做,才能讓 "PostgreSQL" 匹配 "postgresql"
    words = re.findall(r'[\w\u4e00-\u9fff—、。()【】]{2,}', text.lower())
    return {w for w in words if w not in STOPWORDS and len(w) > 1}

def temporal_boost(mtime, now, days):
    age = (now - mtime) / 86400
    if age > days:
        return 0.3          # 超出視窗打 30% 折
    recency = max(0.0, 1.0 - age / days)
    return 1.0 + 0.4 * recency   # 最近的檔案最多加 40%

def hall_boost(text: str) -> float:
    t = text.lower()
    if re.search(r'決定|决策|選擇|採用|decided|adopted', t): return 1.3
    if re.search(r'發現|研究|分析|discover|found',       t): return 1.15
    if re.search(r'偏好|喜歡|習慣|prefer|like',           t): return 1.1
    if re.search(r'建議|推薦|應該|recommend|suggest',     t): return 1.1
    return 1.0

然後把三個分數 fuse 成最終 ranking:

base  = min(1.0, len(content) / 5000) * (0.3 if kw_overlap > 0 else 0.05)
fused = base * (1 + 0.3 * kw_overlap) * t_boost * h_boost

每一項都有特定目的:

  • Base 把「有沒有任何 keyword 命中」拉出來當二值開關(命中 0.3、沒命中 0.05),避免長檔案純粹靠字數堆分數。
  • Keyword overlap 是 query 詞在檔案中出現的比例,範圍 0–1。乘 (1 + 0.3 × overlap) 的意思是讓命中多的檔案略領先,而不是讓 keyword 主導排名。
  • Temporal boost 是主角:一週內的命中跟三個月前的命中相比,在權重上差了將近 5 倍。
  • Hall boost 是對 journal 內容性質的偏見——「決定」類訊息對我比「事件」類值錢,所以 hall_facts 乘 1.3,hall_events 不加成。hall_* 的分類靈感來自 milla-jovovich/mempalace 提出的 hall 模型;我只是把它簡化成 5 類當 scoring signal,實際 entry 的 hall 前綴是另一支 hall-tagger.sh 依 keyword 規則自動標上的。

係數(0.3、0.4、1.3、1.15、1.1)全是拍腦袋調出來的。實際用一陣子會知道要不要再微調。

走一個真實 query

對本機 240+ 個 journal + note 檔跑 memory-search-hybrid.py "postgresql database" --days 30 --top 3

[1] 02-Areas/Infrastructure/tinyauth-pocketid-setup.md  score=0.7052  kw=1.0  temp=1.391
    ...支援 S3 / SQLite / PostgreSQL 儲存...

[2] 01-Projects/Active/cramclaw-line-instance.md  score=0.6239  kw=1.0  temp=1.391
    ...SQLite / PostgreSQL | 題庫 DB | 題目 + embedding...

[3] archive-2026-03/2026-03-25-auth-comparison.md  score=0.597  kw=1.0  temp=1.178

三個都是有效結果。兩個 query 詞(postgresqldatabase)都命中,kw=1.0。第三個因為是 16 天前的 archive,temp=1.178 明顯低於前兩者的 1.391,所以排到第三。排名邏輯就是常識:命中都對,越新越前面。

一個 bug

上面這段成立有個前提:keyword 要在比較前 lowercase。第一版寫錯了,extract_keywords 只在 stopword 過濾時 lowercase,回傳的 set 保留原本大小寫:

# 錯的版本
words = re.findall(r'...', text)
return {w for w in words if w.lower() not in STOPWORDS ...}

結果:query "postgresql database" 萃出來是 {"postgresql", "database"}(query 本來就是小寫),content 萃出來是 {"PostgreSQL", "資料庫", ...}。兩個 set 永遠 overlap 不到。

這個 bug 在本機跑了兩週才被發現,因為日常查詢九成都是中文——中文沒有大小寫問題,分數還是對的。直到把同一段程式碼搬到另一個 repo、在乾淨環境跑 smoke test、用一個純英文 query 當 fixture,才看到 kw_overlap 全 0。

修法是一行:

words = re.findall(r'...', text.lower())

整段寫下來只有兩件事值得記:第一,測試資料要包含你日常不會碰到的 corner——中文使用者測英文 query,英文使用者測中文 query。第二,把程式碼搬到乾淨環境跑一遍是抓 bug 成本最低的方法之一;bootstrap 的 smoke test 跑到這段以前,它都在裝沒事。

適用範圍

這套有幾個具體的不適合場景:

  • 概念查詢:query 跟 content 沒共用詞,只是意思相近。這時 embedding 會贏。
  • 大規模 corpus:240 個檔案全掃一次約 100 ms。如果 journal 超過 10k 檔案,就得改用 inverted index,不能每次全掃。
  • 非 Markdown 格式:現在的 regex tokenizer 是針對 Markdown 寫的,其他格式要另外處理。

我的使用場景是「單一使用者、幾百到幾千個檔案、中英文混雜、每天有新 entry」。對這類場景,三軸 fused scoring 比純 grep 或純 embedding 都更合用一些。如果你的 journal 規模跟我差不多,可能也合用。

跟 hall tagger 串起來才是完整 pipeline

hall_boost 能運作的前提是 entry 開頭有 [hall_*] 前綴。手動打太累,所以另外寫了一支 scripts/hall-tagger.sh:掃過去 N 天的 journal,依 keyword 規則自動補前綴。Idempotent,加過的不會重加,可以每週 cron 跑一次。

整個 pipeline:

打字 → memory/YYYY-MM-DD.md entry
週期 cron → hall-tagger.sh → 補 [hall_*] 前綴
查詢 → memory-search-hybrid.py → fused ranking

Cron 做 deterministic 的事,搜尋腳本做 ranking,LLM 只做真正需要判斷的事。這是這套工作流的設計原則之一。

程式碼位置

完整檔案在 openclaw-workspace-templatescripts/memory-search-hybrid.pyscripts/hall-tagger.sh,隨 v2.4.0 引入,MIT License。

係數都是拍腦袋調的,你的 journal 寫作風格不同,調一下才合身。