Mem0 Intro Part 3 — 讀取路徑 — 語意、關鍵字與實體的三訊號融合

大多數人做記憶檢索,是「把查詢嵌入,向量庫 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-fetchmax(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,以詞形還原後的詞數為準):

查詢詞數midpointsteepness含義
≤ 35.00.7短查詢:原始分 5 分就算「中等」,且轉折陡
4–67.00.6
7–99.00.5
10–1510.00.5
> 1512.00.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,且查詢沒抽到實體(中文查詢的典型情況)
語意 + BM252.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_k10020明確傳 top_k=100
thresholdNone(不過濾)0.1threshold=0.0
rerankTrueFalsererank=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 都是 0max_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

三件事值得帶走:

  1. 候選池只來自語意檢索(3.1)——BM25 與實體是重排訊號,不是召回訊號。這是整個檢索設計最重要的行為邊界。
  2. 權重寫死在原始碼裡(語意 1.0 / BM25 1.0 / 實體 0.5)——你調不動,但你可以用 explain=True 看懂它,並用 metadata filter、reranker、應用層重排來繞過。
  3. 非英語場景下兩條訊號會靜默失效(第七節)——這不會報錯,只會讓你的檢索品質莫名其妙地比 benchmark 差。

讀寫兩條路徑都拆完了。但這兩條路徑上的每一次 insertsearchkeyword_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。延遲數字為量級估算。

Yen

Yen

Yen