跳到主要内容

Mem0 检索 — 三路分数融合

这章讲 search() 怎么把一个查询变成排好序的记忆列表。核心是「三路并行打分 + 自适应分母融合」。读完你能讲清楚语义/BM25/实体分数是怎么加到一起、为什么分母会变。

1. 它要解决的小问题

纯向量语义检索有个老毛病:精确关键词反而捞不准。你查「订单号 A1B2」,语义模型可能觉得「我下过一个订单」更像,而真正含 A1B2 的那条排到后面。反过来纯关键词又抓不住「打车」≈「叫 Uber」这种同义。

Mem0 的答案是 hybrid retrieval(混合检索):同时跑语义 + 关键词,再叠一层「实体命中」的加权,三路分数融合。

2. 思路/直觉:over-fetch,再融合

先各路多捞一点(over-fetch),凑一个候选池,再在池子里融合打分、取最终 top-k。这样不会因为某一路漏了就永久错过。

_search_vector_store 把 internal limit 放大到 max(limit*4, 60)(mem0/memory/main.py:1588):你要 20 条,它先各路捞 80 条来融合。

3. 全景:9 步

query

├─ lemmatize ──────────────▶ query_lemmatized (给 BM25)
├─ extract_entities ───────▶ query_entities (给实体 boost)
├─ embed ──────────────────▶ 向量 (给语义)


[A] 语义检索 vector_store.search(top_k=80) ──┐
[B] 关键词检索 vector_store.keyword_search ───┤── 候选池
[C] 实体 boost _compute_entity_boosts ────────┘


score_and_rank: 对每条候选
combined = (semantic + bm25 + entity_boost) / max_possible
语义分 < threshold 的先踢掉(门控在融合之前)


排序取 top-k → 组装成 MemoryItem 返回

三路在 _search_vector_store 里依次算出来(语义 :1589、BM25 :1594、实体 :1608),最后交给 score_and_rank 融合(:1627)。

4. 逐路走读

4.A 语义路

最直白:embed query,vector_store.search,拿回每条的相似度 score(main.py:1585-1591)。这是主路——下面 BM25 和实体都是「在语义候选之上加分」,门控也只卡语义分(见 §5)。

4.B 关键词路(BM25)

先把 query 做 lemmatization(词形还原):attending/attends → attendmemories → memory(mem0/utils/lemmatization.py:22)。这样关键词匹配不被时态/单复数干扰。写入时每条记忆也存了 text_lemmatized(见 01 章),两边用同一套规则,匹配才对齐。

BM25 原始分是无上界的(0 到 20+),不能直接和 [0,1] 的语义分相加。Mem0 用 sigmoid 归一压到 [0,1]:

# mem0/utils/scoring.py:43 normalize_bm25
return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))

巧的是 sigmoid 的 midpoint/steepness 随查询长度自适应(scoring.py:16 get_bm25_params):查询词越多,原始 BM25 越高,midpoint 就调高(≤3 词用 5.0,>15 词用 12.0)。这样不同长度的查询归一后落在可比区间。

不是所有向量库都支持关键词搜索。 基类 keyword_search 默认返回 None(mem0/vector_stores/base.py:68),不支持的库这一路直接为空,退化成纯语义——Memory.__init__ 还会在这种情况打 warning(main.py:499-506)。

4.C 实体路(boost)

从 query 抽实体,去实体库查,命中就给「链接到该实体的记忆」加分。细节在 03-entity-linking.md;这里只需知道它产出一个 {memory_id: boost} 字典,boost 上限 ENTITY_BOOST_WEIGHT = 0.5(scoring.py:57)。

5. 融合:自适应分母(精华)

三路分数怎么加?Mem0 不固定分母,而是按「哪几路有信号」动态调整最大可能分,再用它归一:

# mem0/utils/scoring.py:97 —— 分母随活跃信号增长
max_possible = 1.0
if has_bm25:
max_possible += 1.0 # 语义+BM25 → 2.0
if has_entity:
max_possible += ENTITY_BOOST_WEIGHT # 再+实体 → 2.5
# ...
raw_combined = semantic_score + bm25_score + entity_boost
combined = min(raw_combined / max_possible, 1.0)

为什么要自适应?如果固定除以 2.5,那只有语义信号的查询永远到不了高分(最多 1/2.5=0.4),分数就不可比。按实际活跃的信号数当分母,单路满分仍能逼近 1.0,跨查询的分数才有可比性。各组合的分母列在 scoring.py:77-81

门控在融合之前(关键细节):语义分低于 threshold 的候选直接踢掉,哪怕 BM25 或实体能给它加很多分也不行(scoring.py:111-112)。语义是「准入门票」,BM25/实体只是「锦上添花」。

6. 结果组装

融合排序后取 top-k,每条组装成 MemoryItem(configs/base.py:16)再 model_dump,把 user_id/agent_id/run_id/actor_id/role 等几个键提升到顶层、其余塞进 metadata(main.py:1636-1676)。explain=True 时还会带上 score_details(每路分数 + 分母),便于调试为什么某条排前面(scoring.py:126-135)。

过期记忆在这步被过滤:payload 里 expiration_date 早于今天就跳过,除非 show_expired=True(main.py:1617,_payload_is_expired:396)。

7. 巧妙之处

  • over-fetch 4 倍再融合(main.py:1588):各路多捞,避免单路漏召回。
  • sigmoid 参数随查询长度自适应(scoring.py:16):让无界 BM25 分跨查询可比。
  • 分母随活跃信号数变(scoring.py:97):缺某一路也不压低总分,跨查询分数可比。
  • 语义门控在融合前(scoring.py:111):防止关键词/实体把无关记忆「抬」进结果。
  • 两端共用同一 lemmatizer:写入存 text_lemmatized、查询 lemmatize query,BM25 匹配才对齐。

8. 边界与局限

  • BM25 路完全依赖向量库是否实现 keyword_search,不实现就退化纯语义。
  • lemmatization 依赖 spaCy;装不上(get_nlp_lemma() 为 None)就原样返回,不报错但词形还原失效(lemmatization.py:31-32)。
  • 实体 boost 依赖实体库已被写入端填充;新库或实体抽取失败时这一路为空。
  • rerank=True 且配了 reranker 才会在融合后再过一遍重排,否则融合结果即最终序(main.py:1447)。

9. 代码地图

主题文件符号
检索核心mem0/memory/main.py_search_vector_store (:1575)
公开入口mem0/memory/main.pyMemory.search (:1326)
分数融合mem0/utils/scoring.pyscore_and_rank (:60)
BM25 归一mem0/utils/scoring.pynormalize_bm25 (:43), get_bm25_params (:16)
词形还原mem0/utils/lemmatization.pylemmatize_for_bm25 (:22)
关键词搜索契约mem0/vector_stores/base.pykeyword_search (:68)
过期判定mem0/memory/main.py_payload_is_expired (:396)