大多數人做記憶檢索,是「把查詢嵌入,向量庫 top-5,塞進 prompt」,然後在召回不準的時候去調 embedding 模型。 真正的答案是:記憶檢索的失敗有三種完全不同的成因——語意相近但講的不是同一件事、要找的是一個專有名詞而向量模型覺得同類詞都很像、以及答案根本沒有出現在查詢的字面裡。 一條訊號解決不了三種問題。 這一篇拆的是那三條訊號怎麼算、怎麼合。
前言
Part 2 的結論留下一個很重的包袱:v3 的萃取是 ADD-only,舊事實不會被覆蓋。 「使用者住在里斯本」和「使用者住在柏林」會同時存在記憶庫裡。
這把壓力完全轉移到了讀取路徑。如果檢索沒辦法讓正確的那一條排在前面,整個 ADD-only 的設計就崩了。
這一篇拆解 Mem0 怎麼做這件事。好消息是:讀取路徑完全不呼叫 LLM,所以它快(~100ms)又便宜;壞消息是:它因此只能靠純演算法的訊號,而那些訊號的權重是寫死在原始碼裡的常數——你調不動,但你可以理解並繞過。
本篇的目標:讀完之後,你能看懂 explain=True 吐出來的每一個欄位,能解釋為什麼某條記憶沒被撈到,並且知道在中文語料上這套機制會退化成什麼樣子。
一、核心問題:純向量檢索在記憶場景的三個盲點
1.1 記憶檢索 ≠ 文件檢索
先講清楚為什麼不能照搬 RAG 的做法。
文件 RAG 記憶檢索
─────────────────────────────────────────────────────────
chunk 有幾百 token 記憶只有一句話(10–30 token)
→ 語意向量資訊量足 → 向量很容易和同類句子撞在一起
chunk 之間大多獨立 記憶之間高度相關且可能互相矛盾
→ 取 top-5 就好 → 要判斷哪條才是「現在的」
查詢通常是完整問句 查詢常常是一個名字或一個詞
→ 語意匹配有效 → 需要精確匹配
語料是靜態的 記憶是持續累積且單調成長的
→ 索引可以離線優化 → 舊的同義記憶會擠掉新的
第一行是最根本的差異。一句 20 個 token 的事實,嵌成 1536 維向量之後,資訊密度非常低——「使用者喜歡爵士樂」和「使用者喜歡古典樂」的餘弦相似度可能高達 0.92。純靠語意,你分不開它們。
1.2 三個具體的盲點
盲點一:專有名詞被同類詞淹沒。
查詢:「專案代號 PX-441 的狀況?」
純語意檢索的結果:
0.89 使用者正在跟進專案 PX-882
0.88 使用者提到專案 PX-441 已經延期 ← 正確答案排第二
0.87 使用者負責的專案 PX-103 上線了
向量模型認為所有 "PX-xxx" 都長得很像,因為它們確實很像。
盲點二:實體出現在記憶裡,但不在查詢的語意空間裡。
查詢:「關於 Alice 我們知道什麼?」
記憶庫裡有:
「她推薦了中山區那家拉麵店」 ← Alice 這個詞根本沒出現
「跟 Alice 週四要開規劃會議」 ← 有出現
純語意檢索只會撈到第二條。第一條的資訊永遠取不回來,
除非寫入時就把它和「Alice」這個實體連過。
盲點三:矛盾的新舊事實分數幾乎相同。
查詢:「使用者住在哪裡?」
0.91 使用者住在里斯本 (2025-03 寫入)
0.91 使用者住在柏林 (2026-08 寫入)
語意上兩者對這個查詢的相關度完全一樣。純向量檢索無法排序。
Mem0 的三條訊號,正好對應前兩個盲點:
| 訊號 | 解決的盲點 | 實作 |
|---|---|---|
| 語意(Semantic) | 基礎召回 | 向量庫餘弦相似 |
| 關鍵字(BM25) | 盲點一:專有名詞、ID、精確詞 | text_lemmatized 欄位 + 後端的 BM25 索引 |
| 實體(Entity) | 盲點二:實體共現但字面不符 | 實體庫的 linked_memory_ids 反向索引 |
第三個盲點在 OSS 沒有解。 官方文件列了第四條訊號「Temporal」,但明確標注它是 Platform 專屬:「Both parameters raise a ’not supported by the OSS Memory SDK’ error」。OSS 使用者必須自己在應用層處理時序,第八節有做法。
二、三個演進階段
╔═══════════════════════════════════════════════════╗
║ Phase 1:純語意 —— 一次向量檢索 ║
╚═══════════════════════════════════════════════════╝
query ──embed──▶ vector_store.search(top_k=5) ──▶ 結果
- 可接受的捷徑:一行程式碼,任何向量庫都支援。
- 成本:一次 embedding(~30ms、$0.00002)+ 一次向量檢索(10–50ms)。
- 解決了什麼:概念性查詢(「他對遠端工作的看法?」)表現很好。
- 還沒解決什麼:1.2 節的三個盲點全在。而且沒有 threshold 的話,就算記憶庫裡沒有相關內容,也會硬撈 5 條不相干的回來——塞進 prompt 之後模型會被誤導。
╔═══════════════════════════════════════════════════╗
║ Phase 2:語意 + 關鍵字 —— 混合檢索 ║
╚═══════════════════════════════════════════════════╝
┌─── embed ──▶ 語意檢索 ──┐
query ─────┤ ├──▶ 融合 ──▶ 結果
└── lemmatize ▶ BM25 ─────┘
- 新增元件:寫入時要多存一個
text_lemmatized欄位(詞形還原版本)、後端要有 BM25 索引能力、以及一個把兩種分數合起來的融合函式。 - 複雜度 delta:融合是難的部分。BM25 的分數是無界的(0 到 20+,看查詢長度和語料統計),語意分數是 [0,1]——不做正規化直接相加,BM25 會完全主導結果。
- 效果 delta:盲點一解決。專有名詞、ID、代號類查詢的召回大幅改善。
- 還沒解決什麼:盲點二、三。而且不是所有向量庫都支援 BM25——Part 4 會列出 25 種裡哪 15 種有。
╔═══════════════════════════════════════════════════╗
║ Phase 3:三訊號 + 實體反向索引 ║
╚═══════════════════════════════════════════════════╝
┌─── embed ──────▶ 語意檢索(over-fetch)──┐
│ │
query ─────┼── lemmatize ───▶ BM25 檢索 ──────────────┼──▶ 加權相加
│ │ ÷ 自適應分母
└── spaCy 抽實體 ▶ 實體庫查 ──▶ 反查 │ ──▶ top-k
linked_memory_ids ──────┘
- 新增元件:寫入時的實體抽取與連結(Part 2 的 Phase 7)、檢索時的實體庫查詢(併發,4 個 worker)、以及一個會隨「哪些訊號有值」而變化的分母。
- 複雜度 delta:三個訊號的量綱都不同,融合邏輯變成整個系統最微妙的一段程式碼(
mem0/utils/scoring.py,只有 140 行但每個常數都有理由)。 - 效果 delta:盲點二解決。「關於 Alice 的一切」這類實體中心查詢能撈到字面不含 Alice 的記憶。
- 還沒解決什麼:盲點三(時序)在 OSS 仍然無解。以及非英語語料上實體與 BM25 兩條訊號都會退化。
三、九個步驟:逐行拆解
以下是 Memory._search_vector_store() 的完整流程(mem0/memory/main.py 約第 1628 行起)。
┌─ Step 1:查詢預處理 ──────────────────────────────────────────┐
│ query_lemmatized = lemmatize_for_bm25(query) │
│ query_entities = extract_entities(query) ← spaCy │
│ │
│ 兩者都在 CPU 上跑,合計幾毫秒 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 2:嵌入查詢 ────────────────────────────────────────────┐
│ embeddings = embedding_model.embed(query, "search") │
│ ← 注意第二個參數 "search":部分模型的查詢與文件用不同前綴 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 3:語意檢索(over-fetch)───────────────────────────────┐
│ internal_limit = max(limit * 4, 60) │
│ semantic_results = vector_store.search(top_k=internal_limit) │
│ │
│ ★ 為什麼要多撈?因為後面的 BM25 與實體加權會重排。 │
│ 只撈 top-20 的話,一條語意排第 45 但 BM25 滿分的記憶 │
│ 根本進不了候選池。 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 4:關鍵字檢索 ─────────────────────────────────────────┐
│ keyword_results = vector_store.keyword_search( │
│ query=query_lemmatized, top_k=internal_limit) │
│ ★ 後端不支援時回傳 None → 整條 BM25 訊號靜默停用 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 5:BM25 分數正規化 ────────────────────────────────────┐
│ midpoint, steepness = get_bm25_params(query, ...) │
│ for mem in keyword_results: │
│ bm25_scores[id] = normalize_bm25(raw, midpoint, steepness)│
│ ← sigmoid 把無界分數壓進 [0,1]。細節見 4.1 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 6:實體加權 ───────────────────────────────────────────┐
│ if query_entities: │
│ entity_boosts = self._compute_entity_boosts(...) │
│ ← 4 個執行緒併發查實體庫。細節見 4.2 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 7:組候選集 ───────────────────────────────────────────┐
│ for mem in semantic_results: │
│ if not show_expired and _payload_is_expired(payload): │
│ continue ← 過期記憶在這裡被濾掉 │
│ candidates.append({id, score, payload}) │
│ │
│ ★ 候選池只來自語意檢索。BM25 撈到但語意沒撈到的, │
│ 不會進入最終結果 —— 這是一個重要的行為邊界 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 8:融合排序 ───────────────────────────────────────────┐
│ scored_results = score_and_rank( │
│ semantic_results=candidates, │
│ bm25_scores=..., entity_boosts=..., │
│ threshold=threshold, top_k=limit, explain=explain) │
│ ← 細節見 4.3 │
└────────────────────────────────────────────────────────────────┘
▼
┌─ Step 9:格式化輸出 ─────────────────────────────────────────┐
│ promoted_payload_keys = [user_id, agent_id, run_id, │
│ actor_id, role, attributed_to, expiration_date] │
│ ← 這幾個從 payload 提到結果的頂層,其餘進 metadata │
│ ← explain=True 時附上 score_details │
└────────────────────────────────────────────────────────────────┘
3.1 Step 7 的行為邊界:BM25 不能單獨召回
這是整個檢索設計裡最重要、也最容易誤解的一點,值得單獨講。
候選池的來源:只有 semantic_results
┌──────────────────────────────────────────────────┐
│ 語意檢索 top-60 │
│ ┌────────────────────────────────────────────┐ │
│ │ 這裡面的記憶,才有資格被 BM25 與實體加權 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ BM25 檢索 top-60 │
│ ┌────────────────────────────────────────────┐ │
│ │ 不在語意 top-60 裡的,分數算了也白算 │ │
│ │ ← 它們根本不會出現在 candidates 清單裡 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
實務後果:如果你的查詢是一個罕見的 ID(PX-441),而向量模型對這個 ID 的語意表徵很差,導致正確記憶在語意排名第 200——BM25 救不了它,因為它進不了 top-60 的候選池。
這也是為什麼 over-fetch 是 max(limit*4, 60) 而不是 limit:那個 60 的地板,就是在給 BM25 與實體加權留重排空間。但 60 仍然是一個有限的窗口。
繞過方法:如果你有大量純 ID 查詢,不要依賴 search(),改用 get_all() 加 metadata filter:
1# ❌ 依賴語意撈到 ID
2m.search("PX-441 的狀況", filters={"user_id": u})
3
4# ✅ 寫入時就把 ID 放進 metadata,檢索時精確過濾
5m.add(msgs, user_id=u, metadata={"project_code": "PX-441"})
6m.get_all(filters={"user_id": u, "project_code": "PX-441"})
四、三個訊號的數學
這一節把 mem0/utils/scoring.py 的 140 行拆開。
4.1 BM25:sigmoid 正規化與查詢長度自適應
問題:BM25 的原始分數是無界的,且與查詢長度強相關——查詢詞越多,能累加的項越多,分數自然越高。直接和 [0,1] 的餘弦相似度相加會失衡。
解法:用邏輯 sigmoid 壓到 [0,1],且 sigmoid 的參數隨查詢長度變動。
1def normalize_bm25(raw_score, midpoint, steepness):
2 return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))
參數表(原始碼裡的 get_bm25_params,以詞形還原後的詞數為準):
| 查詢詞數 | midpoint | steepness | 含義 |
|---|---|---|---|
| ≤ 3 | 5.0 | 0.7 | 短查詢:原始分 5 分就算「中等」,且轉折陡 |
| 4–6 | 7.0 | 0.6 | |
| 7–9 | 9.0 | 0.5 | |
| 10–15 | 10.0 | 0.5 | |
| > 15 | 12.0 | 0.5 | 長查詢:要 12 分才算中等,轉折平緩 |
用圖看比較清楚:
正規化後
1.0 ┤ 短查詢(5.0, 0.7) 長查詢(12.0, 0.5)
│ ╭────────── ╭────────
0.8 ┤ ╱ ╱
│ ╱ ╱
0.5 ┼────●─────────────────────●─────────── ← midpoint 在這裡
│ ╱ ╱
0.2 ┤ ╱ ╱
│ ╱ ╱
0.0 ┼───────────────────────────────────────▶ 原始 BM25 分數
0 5 12 20
意義:同樣 8 分的原始 BM25——
短查詢下 → 正規化約 0.89(很強的匹配)
長查詢下 → 正規化約 0.12(只是剛好有幾個詞撞到)
這個設計在防什麼:長查詢裡有大量常見詞(「的」「這個」「請問」),它們會貢獻分數但不代表相關。提高 midpoint 等於要求長查詢必須有更集中的詞彙匹配才算數。
陡度為什麼短查詢要高:短查詢(一個專有名詞)要嘛命中要嘛沒命中,是二元的。陡的 sigmoid 讓分數更接近 0/1,強化這個二元性。
4.2 實體加權:一個帶反懲罰的公式
1similarity = match.score # 實體向量相似度
2if similarity < 0.5: continue # 門檻
3
4num_linked = max(len(linked_memory_ids), 1)
5memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2))
6boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight
7 # ENTITY_BOOST_WEIGHT = 0.5
8
9# 一條記憶被多個查詢實體命中時,取最大值而非累加
10memory_boosts[mid] = max(memory_boosts.get(mid, 0.0), boost)
三個設計決策,每個都值得說:
決策一:memory_count_weight 是一個反懲罰項。
連結的記憶數 n 權重 boost(假設 similarity=1.0)
─────────────────────────────────────────────────────────
1 1.000 0.500
5 0.984 0.492
10 0.924 0.462
20 0.735 0.368
50 0.294 0.147
100 0.092 0.046
200 0.025 0.012
在懲罰什麼:一個連到 200 條記憶的實體(例如「使用者」「工作」這種泛稱),它的區別力幾乎是零——加權給 200 條記憶等於沒加權。這個平方衰減讓高頻實體的影響力快速歸零,而稀有實體(連到 1–5 條)保有完整的加權。
這其實就是 IDF 的思想:出現得越普遍的詞,資訊量越低。只是用了一個平滑的平方倒數而不是對數。
決策二:多實體命中取 max 而不是 sum。 查詢「Alice 跟 Bob 的會議」抽出兩個實體,某條記憶被兩者都命中——分數不會翻倍。這防止了「實體堆疊」導致某條記憶的加權遠超其他訊號。
決策三:門檻 0.5 的語意相似度。 實體庫的查詢是語意的(entity_store.search),所以「Alice」可以命中「Alice Chen」。0.5 是一個相當寬鬆的門檻,配合前面的反懲罰項來控制誤傷。
另外注意:查詢實體最多取 8 個(query_entities[:8]),且會先做正規化去重;實體庫查詢用 top_k=500 且開 4 個執行緒併發——這是整個 search() 裡唯一的併發點。
4.3 融合:自適應分母
score_and_rank() 的核心只有幾行,但每一行都有意義:
1has_bm25 = bool(bm25_scores)
2has_entity = bool(entity_boosts)
3
4max_possible = 1.0 # 語意
5if has_bm25: max_possible += 1.0 # BM25
6if has_entity: max_possible += 0.5 # 實體(ENTITY_BOOST_WEIGHT)
7
8for result in semantic_results:
9 semantic_score = result.get("score") or 0.0
10 if semantic_score < threshold: # ★ 在合併「之前」就 gate
11 continue
12 raw_combined = semantic_score + bm25_score + entity_boost
13 combined = min(raw_combined / max_possible, 1.0)
分母的四種可能:
| 啟用的訊號 | max_possible | 什麼時候會是這樣 |
|---|---|---|
| 只有語意 | 1.0 | 後端不支援 BM25,且查詢沒抽到實體(中文查詢的典型情況) |
| 語意 + BM25 | 2.0 | 後端支援 BM25,查詢沒抽到實體 |
| 語意 + 實體 | 1.5 | 後端不支援 BM25,查詢有實體 |
| 三者齊全 | 2.5 | 理想狀態 |
為什麼要自適應:如果分母永遠是 2.5,那在 BM25 不可用的後端上,所有分數都會被壓到 0.4 以下——使用者看到的 score 全部很低,看起來像是「沒找到相關記憶」,但實際上排序是正確的。自適應分母讓分數的量綱在不同配置下保持可比。
權重是隱含在量綱裡的:語意最高貢獻 1.0、BM25 最高 1.0、實體最高 0.5。所以語意與關鍵字同權,實體只有一半權重——它是一個「加分項」而不是「主要訊號」。這也解釋了為什麼官方文件說「semantic relevance always dominates」。
4.4 threshold 到底在 gate 什麼
這是最容易誤解的一個參數。
1if semantic_score < threshold:
2 continue
它 gate 的是語意分數,不是最終分數。 原始碼的文件字串寫得很明確:
Threshold gates the semantic score BEFORE combining — candidates below the threshold are excluded even if BM25/entity would boost them.
實務後果:
threshold = 0.1(v3 的新預設值)
某條記憶:
語意分數 0.08 ← 低於門檻
BM25 分數 0.95 ← 完美的關鍵字匹配
實體加權 0.45 ← 強實體關聯
→ 被丟掉。最終分數根本不會被計算。
這是有意的:它在防「純字面撞詞但語意無關」的誤召回。但如果你的場景大量依賴精確匹配(ID、代號、產品編號),threshold=0.1 可能太嚴——原始碼允許傳 threshold=0.0 來還原 v2 的無過濾行為。
v3 的三個預設值變更(來自官方遷移文件)值得一起記:
| 參數 | v2 預設 | v3 預設 | 還原方式 |
|---|---|---|---|
top_k | 100 | 20 | 明確傳 top_k=100 |
threshold | None(不過濾) | 0.1 | 傳 threshold=0.0 |
rerank | True | False | 傳 rerank=True |
升級後召回突然變差,九成是這三個之一。
五、explain=True:診斷工具
這是 Mem0 最被低估的一個功能。
1results = m.search("Alice 推薦的餐廳",
2 filters={"user_id": "u_123"},
3 explain=True)
4
5for r in results["results"]:
6 print(r["memory"], r["score_details"])
回傳的 score_details:
1{
2 "semantic_score": 0.62, # 向量餘弦
3 "bm25_score": 0.81, # sigmoid 正規化後
4 "entity_boost": 0.43, # similarity × 0.5 × count_weight
5 "raw_score": 1.86, # 三者相加
6 "max_possible_score": 2.5, # 自適應分母
7 "final_score": 0.744, # 1.86 / 2.5
8 "threshold": 0.1
9}
5.1 症狀 → 診斷 → 處方
| 症狀 | 看 score_details 的哪裡 | 根因 | 處方 |
|---|---|---|---|
所有結果的 bm25_score 都是 0 | max_possible_score 是 1.0 或 1.5 | 後端不支援 keyword_search | 換支援 BM25 的向量庫(Part 4 有清單) |
所有結果的 entity_boost 都是 0 | 同上 | (a) 沒裝 spaCy 模型;(b) 查詢是中文 | pip install "mem0ai[nlp]" + 下載模型;中文見第七節 |
| 分數普遍偏低(< 0.4)但排序正確 | max_possible_score | 訊號沒全開,分母小但分子更小 | 這是正常的,別用絕對分數當門檻 |
| 正確記憶完全沒出現 | 用 threshold=0.0 重跑看它出不出現 | 若出現 → 被 threshold 擋;若仍不出現 → 沒進 top-60 候選池 | 見 3.1 的繞過方法 |
semantic_score 很高但明顯不相關 | — | 記憶太短,向量資訊密度低 | 萃取時要求更完整的自包含句子(custom_instructions) |
| 舊記憶排在新記憶前面 | 兩者的 semantic_score 幾乎相同 | 盲點三:OSS 沒有時序訊號 | 見第八節 |
5.2 一個實際的調校流程
1. 收集 30–50 個真實查詢,以及每個查詢「應該撈到哪條記憶」的標註
← 這一步不能跳。沒有標註就沒有調校,只有猜測
2. 全部用 explain=True 跑一次,記錄:
· 正確記憶有沒有進 top-k
· 如果沒有,它的 semantic_score 是多少(有沒有進候選池)
· 三個訊號各貢獻多少
3. 分類失敗案例:
├─ 進了候選池但排不上去 → 訊號權重問題 → 考慮外掛 reranker
├─ 沒進候選池 → 召回問題 → 改 embedding 模型或加 metadata filter
└─ 被 threshold 擋 → 調 threshold
4. 只改一個變數,重跑,比較
第 1 步是全部的關鍵。 記憶系統的品質無法用單元測試驗證,只能用標註集。30 個查詢就足以看出系統性問題。
六、Reranker:第四條路
v3 把 rerank 的預設值從 True 改成 False,但功能還在,而且支援五種後端:
| Reranker | 類型 | 特性 |
|---|---|---|
| Cohere | 雲端 API | 品質好、有成本、多語言支援佳 |
| Sentence Transformer | 本機 cross-encoder | 免費、需要 GPU 才快 |
| HuggingFace | 本機 | 可選任意模型 |
| LLM Reranker | 呼叫 LLM | 最靈活、最貴、最慢 |
| Zero Entropy | 雲端 API |
1m = Memory.from_config({
2 "reranker": {
3 "provider": "cohere",
4 "config": {"model": "rerank-multilingual-v3.0", "top_n": 10}
5 }
6})
7results = m.search(q, filters={...}, rerank=True)
選擇 選 rerank=True 的理由 不選的理由 / 翻轉條件
────────────────────────────────────────────────────────────────────────
開 reranker cross-encoder 能看到 query 與 額外 50–300ms 延遲
vs 不開 memory 的交互,比三訊號加權準 額外成本(雲端 API)
多語言 reranker 能救中文場景 v3 已改成預設關閉
────────────────────────────────────────────────────
翻轉條件:
· 你的標註集顯示「進了候選池但排不上去」是主要失敗模式
→ 開 reranker,這正是它解決的問題
· 你是中文/多語言場景 → 強烈建議開多語言 reranker,
因為 BM25 與實體兩條訊號在中文上基本失效(見第七節)
· 你對延遲極度敏感(< 100ms)→ 別開
· 你的失敗模式是「沒進候選池」→ reranker 幫不上忙,
它只能重排已經撈到的東西
LLM Reranker 特別注意:它會讓 search() 從「無 LLM 呼叫」變成「有 LLM 呼叫」,延遲從 100ms 變成 1s+,成本從近乎零變成每次查詢都要錢。這抵銷掉了讀取路徑最大的優勢,只在離線批次或極高價值的查詢上才划算。
七、中文與多語言:這套機制會退化成什麼樣
這一節是官方文件不會講、但你一定會遇到的問題。
7.1 兩條訊號會靜默失效
中文查詢:「Alice 推薦的那家拉麵店?」
Step 1 lemmatize_for_bm25(query)
→ 詞形還原是針對英語動詞變位設計的(running → run)
→ 中文沒有詞形變化,這步幾乎是 no-op
→ 而且中文沒有空格,BM25 的分詞從一開始就不成立
Step 1 extract_entities(query) ← spaCy en_core_web_sm
→ 抽「大寫多字序列」:中文沒有大寫
→ 抽「引號內文字」:這個還有用
→ 抽「複合名詞片語」:需要中文的 POS tagger
→ 結果:大概率回傳 []
Step 8 score_and_rank
has_bm25 = False
has_entity = False
max_possible = 1.0
→ 退化成純語意檢索
而且完全沒有錯誤訊息。 你的三訊號系統靜靜地變成單訊號系統,只有用 explain=True 才看得出來。
7.2 四個補救方向
補救一:換 spaCy 的中文模型。
1python -m spacy download zh_core_web_sm
mem0/utils/spacy_models.py 負責模型載入。這能救回部分實體抽取能力(中文 NER),但 _GENERIC_HEADS 那組停用詞清單仍然是英文的,效果會打折。值得試,但別期待和英文同等的品質。
補救二:開多語言 reranker。 這是最有效的單一手段。Cohere 的多語言 rerank 模型在中文上表現很好,而且它是 cross-encoder——不依賴任何前處理,直接看原文。
補救三:把實體放進 metadata,用精確過濾取代實體訊號。
1# 寫入時自己抽實體(用 LLM 或中文 NER 工具)
2m.add(msgs, user_id=u, metadata={"entities": ["Alice", "拉麵店"]})
3
4# 查詢時精確過濾
5m.get_all(filters={"user_id": u, "entities": {"contains": "Alice"}})
補救四:選一個原生支援中文全文檢索的後端。 Elasticsearch / OpenSearch 配上 IK 或 jieba 分詞器,keyword_search 就能在中文上真的運作。這需要在向量庫層面配置分詞器,不是 Mem0 能控制的——但 Mem0 只是呼叫 vector_store.keyword_search(),後端做得好就有效。
選擇 理由 代價
──────────────────────────────────────────────────────────────
多語言 reranker 單一改動、效果最顯著 延遲 +100ms、API 成本
zh spaCy 模型 救回部分實體訊號 停用詞表仍是英文
metadata 精確過濾 100% 可靠 要自己抽實體、要改應用碼
ES/OpenSearch + 分詞 BM25 真的能用 多一個要維運的系統
翻轉條件:如果你只做中文,優先順序是「多語言 reranker → metadata 過濾 →
換後端 → zh spaCy」。前兩者的投入產出比最高。
八、時序問題:OSS 的缺口與補法
盲點三(新舊矛盾的事實分數相同)在 OSS 完全沒有解。官方文件對這件事很誠實:
Temporal Reasoning | Platform: Boosts memories whose event dates match the time expressed in a query | OSS: Not supported. Both parameters raise a “not supported by the OSS Memory SDK” error
而 Part 2 的結論是:ADD-only 保證了你一定會遇到這個問題。
8.1 應用層的三個補法
補法一:檢索後按時間重排同主題的記憶。
1results = m.search(q, filters={"user_id": u}, top_k=20)["results"]
2
3# 用 metadata 裡自己標的 topic 分群,每群只留最新的
4from collections import defaultdict
5by_topic = defaultdict(list)
6for r in results:
7 topic = (r.get("metadata") or {}).get("topic", r["id"])
8 by_topic[topic].append(r)
9
10latest = [max(g, key=lambda x: x["created_at"]) for g in by_topic.values()]
前提是寫入時有標 topic,這可以在 custom_instructions 裡要求 LLM 輸出,或事後用規則補。
補法二:把時間寫進記憶文字本身。 與其存「使用者住在柏林」,存「使用者自 2026 年 8 月起住在柏林」——這樣時間資訊就進了語意向量,查詢「他現在住哪」時,帶年份的那條會和「現在」有更好的語意匹配。這可以透過 custom_instructions 要求:
1m = Memory.from_config({
2 "custom_instructions": "當事實涉及狀態變化(居住地、職稱、關係),"
3 "務必在記憶文字中包含對話發生的日期。"
4})
補法三:對會過時的事實設 expiration_date。 見 Part 2 第七節。
補法四(也是最誠實的):把「哪條是最新的」交給下游 LLM 判斷。 把新舊兩條都塞進 prompt,並附上 created_at:
已知關於使用者:
- [2025-03-14] 使用者住在里斯本
- [2026-08-02] 使用者住在柏林
現代模型處理這種明確標註時間的矛盾資訊相當可靠。 與其在檢索層做半吊子的時序推理,不如把完整證據交出去——這其實正是 ADD-only 設計背後的哲學:保留證據,讓最有能力判斷的那一層去判斷。
九、為什麼選 X 不選 Y
選擇 選 X 的理由 不選 Y 的理由 / 翻轉條件
──────────────────────────────────────────────────────────────────────────────
三訊號融合 覆蓋三類不同的失敗模式 純語意:一行程式碼、
vs 純語意檢索 專有名詞與實體查詢大幅改善 任何後端都支援
──────────────────────────────────────────────────────────
翻轉條件:純中文場景且沒有設定中文分詞——此時三訊號實際上
就是純語意,多出來的只有幾毫秒的無效前處理。
加權相加 簡單、可解釋、explain 能逐項看 RRF(倒數排名融合):
vs RRF 融合 分數量綱可控 不需要正規化分數,
────────────────────────────────────────────────────────── 對量綱不敏感
翻轉條件:當你的 BM25 分數分布很怪(語料極小或極大)時,
RRF 更穩健。但 Mem0 的 sigmoid 正規化已經處理了大部分情況,
而且加權相加的可診斷性(score_details)價值很高。
sigmoid 正規化 把無界分數壓進 [0,1] min-max 正規化:更簡單
vs min-max 查詢長度自適應 ──────────────────────
單調且平滑 翻轉條件:min-max 需要
────────────────────────────────────────────────────────── 知道全域最大值,
在串流檢索裡拿不到——所以實際上不是一個選項。
threshold 在合併前 防止「字面撞詞但語意無關」的誤召回 合併後 gate:能讓
vs 合併後 gate 語意始終是必要條件 BM25 滿分的記憶救回來
──────────────────────────────────────────────────────────
翻轉條件:大量 ID / 代號查詢的場景。此時傳 threshold=0.0
並依賴 BM25,但要注意 3.1 的候選池邊界仍然存在。
實體取 max 防止實體堆疊主導分數 取 sum:多實體命中的
vs 實體取 sum 單一實體的加權上限明確(0.5) 記憶確實更相關
──────────────────────────────────────────────────────────
翻轉條件:無明顯翻轉條件。sum 會讓一條命中 5 個實體的記憶
拿到 2.5 的加權,直接壓過語意訊號——那不是「加分項」了。
反懲罰高頻實體 泛稱實體(「工作」)自動失效 不懲罰:稀有與常見
vs 不懲罰 等同於一個平滑的 IDF 實體一視同仁
──────────────────────────────────────────────────────────
翻轉條件:無。這是資訊檢索五十年的共識。
over-fetch 4× / 60 給 BM25 與實體留重排空間 只撈 top_k:省一點
vs 只撈 top_k ────────────────────────────────────────────────────────── 向量庫的頻寬
翻轉條件:記憶庫極大(百萬級)且向量庫檢索是瓶頸時,
over-fetch 的成本才會顯著。多數場景下 60 筆的差別可忽略。
不開 reranker 零額外延遲與成本 開 reranker:cross-encoder
(v3 預設) 三訊號已覆蓋多數場景 更準、能救多語言
──────────────────────────────────────────────────────────
翻轉條件:見第六節。判準是「你的失敗模式是排序問題還是
召回問題」——reranker 只解決前者。
十、系列導航
本篇拆解了讀取路徑:九個步驟、三條訊號、一個自適應分母,以及那個容易誤解的 threshold。
三件事值得帶走:
- 候選池只來自語意檢索(3.1)——BM25 與實體是重排訊號,不是召回訊號。這是整個檢索設計最重要的行為邊界。
- 權重寫死在原始碼裡(語意 1.0 / BM25 1.0 / 實體 0.5)——你調不動,但你可以用
explain=True看懂它,並用 metadata filter、reranker、應用層重排來繞過。 - 非英語場景下兩條訊號會靜默失效(第七節)——這不會報錯,只會讓你的檢索品質莫名其妙地比 benchmark 差。
讀寫兩條路徑都拆完了。但這兩條路徑上的每一次 insert、search、keyword_search,實際上都委託給了你配置的後端——而後端選得對不對,決定了上面講的機制有沒有真的在運作。BM25 訊號能不能用,就是一個純粹的後端能力問題。
下一篇下到儲存層:三個 store 的實際 schema、25 種向量庫裡哪 15 種支援 keyword_search、實體庫為什麼是「同一個向量庫的另一個 collection」、SQLite 的兩張表各存什麼、配置系統怎麼組,以及自架 server 的部署。
- Part 4 — 儲存層與後端選型:三個 store、25 種向量庫、配置系統與自架服務
← Part 2 — 寫入路徑 — ADD-only 萃取管線與那次自我否定 | Part 4 — 儲存層與後端選型 — 三個 store、25 種向量庫與那個關鍵能力差異 →
本文基於 mem0 main 分支(2026 年 9 月,版本 2.0.20)原始碼撰寫。檢索流程、常數與公式皆對照 mem0/memory/main.py 的 _search_vector_store() / _compute_entity_boosts() 與 mem0/utils/scoring.py;v2 與 v3 的預設值差異對照官方 docs/migration/oss-v2-to-v3.mdx。延遲數字為量級估算。
