跳到主要内容

Mem0 实体链接 — 取代图谱的轻量关联

这章讲 Mem0 怎么在不上图数据库的前提下,记住「实体之间/实体与记忆之间」的关联。核心是一个「实体库」:把实体也存成向量,payload 里挂一串它链接的记忆 id。

1. 它要解决的小问题

光有「一句句独立事实」还不够。用户问「Max 最近怎么样」,你希望所有提到 Max(那只狗)的记忆都被拉出来——哪怕某条的措辞和「Max」语义不太近。这需要一层实体级的关联

老版 Mem0 用图数据库(Neo4j 等)做这件事:实体是节点,关系是边。这个 commit 里图谱被移除了——grep MemoryGraph 在整个仓库零命中;exceptions.py 只在某异常的 details 示例里留了 feature=graph_store 字段(exceptions.py:396),并无 MemoryGraph 类。取而代之是一个轻得多的方案。

2. 思路/直觉:实体也是向量,挂一张链接表

把实体当成「第二种记忆」存进另一个向量集合(entity store):

  • 每个实体一行:data=实体文本、entity_type、以及 linked_memory_ids=它出现在哪些记忆里。
  • 写入记忆时:抽实体 → 在实体库里找有没有(精确或语义)→ 有就把新记忆 id 追加进 linked_memory_ids,没有就新建。
  • 检索时:从 query 抽实体 → 实体库里查相似实体 → 给它们 linked_memory_ids 里的记忆加分

类比:实体库就是一张倒排索引(实体 → 文档列表),只不过键值都用向量做模糊匹配。这比图数据库少一个外部依赖,代价是表达不了「关系类型」(只有「共现」这一种隐式边)。

3. 全景

写入(add):
记忆文本 ──extract_entities──▶ [("PERSON","Max"), ("GPE","Seattle")]
│ 每个实体

实体库里找匹配:精确(归一文本相等) or 语义(score≥0.95)
├─ 命中 → 把本记忆 id 追加进 linked_memory_ids
└─ 未命中 → 新建实体行,linked_memory_ids=[本记忆 id]

检索(search):
query ──extract_entities──▶ 实体(去重,最多 8 个)
│ embed,实体库 search(score≥0.5)

对命中实体的每个 linked_memory_id 算 boost → {memory_id: boost}
boost = similarity * 0.5 * 链接数衰减权重

4. 抽实体:spaCy + 规则

extract_entities 不只用 spaCy 的命名实体识别(NER),还叠了规则,产出四类:专有名词(PROPER)、引号文本(QUOTED)、名词复合(TOPIC)、标识符(IDENTIFIER)(mem0/utils/entity_extraction.py:1-16)。

两个噪声过滤值得一提:

  • 拒绝时间/数量类 NER 标签:DATE/TIME/CARDINAL/MONEY 等被排除(entity_extraction.py:114)——这些不是稳定实体,「上周」不该当 key。
  • 拒绝太泛的中心词:thing/stuff/way/idea/... 一大串 _GENERIC_HEADS(entity_extraction.py:36),避免「the thing」这种没用的实体。

批量版 extract_entities_batchnlp.pipe 一次处理多条,写入管线用它(main.py:1036)。spaCy 装不上时整个返回 [],实体功能静默关闭。

5. 写入端:upsert 去重

实体最怕重复——「Max」出现 100 次不能存 100 行。_upsert_entity两级匹配(mem0/memory/main.py:561):

# mem0/memory/main.py:577 —— 先精确,精确没有才看语义,且阈值很高
semantic_match = existing[0] if existing and existing[0].score >= 0.95 else None
match = exact_match or semantic_match
if match:
# 已有实体:把本记忆 id 追加进 linked_memory_ids
linked_ids.append(memory_id)
  • 精确匹配:把实体文本归一化(_normalize_entity_text:lower + 折叠空白,main.py:539)后比对已有实体(_existing_entities_by_text:一次 list(top_k=10000) 拉全量建字典,main.py:542)。
  • 语义匹配:精确没命中才 embed 去 search,且阈值卡到 0.95——很高,防止把「Max」和「Maxine」误并。

写入管线里这步是批量做的(main.py:1033-1137,Phase 7):先跨所有新记忆做一次全局实体去重(global_entities),再一次 embed_batch 所有唯一实体,一次 search_batch 查已有,最后分流成「批量 update」和「批量 insert」。又是「批量优先」那套。

6. 检索端:entity boost

_compute_entity_boosts(main.py:1680)。query 实体去重后最多取 8 个,embed_batch,然后用线程池并发(max_workers=4)在实体库里各查 top-500:

# mem0/memory/main.py:1748 —— boost 的两个因子
num_linked = max(len(linked_memory_ids), 1)
memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2))
boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight

两点设计:

  • 相似度门槛 0.5:实体相似度低于 0.5 不给 boost(main.py:1740)——比写入的 0.95 松,因为这里是「加分」不是「合并」,容错可以大些。
  • 链接数衰减(memory_count_weight):一个实体链了越多记忆,每条得到的 boost 越小。直觉是「Max 链了 200 条记忆」说明 Max 是个高频泛实体,对单条记忆的区分度低,该降权。

每条记忆取它从各实体得到的 最大 boost(main.py:1755),汇成 {memory_id: boost} 交给 score_and_rank(见 02 章 §5)。

7. 实体库怎么来的

实体库不是独立服务,而是同一个向量库 provider 下的第二个 collection,名字在原 collection 后加 _entities(_entity_collection_name,main.py:376)。懒加载——首次用到才建(entity_store property,main.py:515)。Qdrant 嵌入式模式下还会共享同一个 client,避免 RocksDB 锁冲突(main.py:528)。

8. 巧妙之处

  • 没有图数据库:用「向量集合 + linked_memory_ids 列表」模拟实体→记忆的倒排,零外部依赖。
  • 写入严(0.95)、检索松(0.5):合并要保守,加分可激进——两个阈值方向相反但都合理。
  • 链接数二次衰减:高频泛实体自动降权,避免「热门实体」淹没排序。
  • 检索端线程池并发查实体:多个 query 实体并行查,压低延迟。
  • 实体库复用主向量库后端:换 provider 时实体库自动跟着换,无需单独配置。

9. 边界与局限

  • 只有「共现」这一种隐式关系,表达不了关系类型(谁是谁的什么)——这是相对图谱的能力损失。
  • 全量精确去重靠 list(top_k=10000)(main.py:545),实体极多时这一步会变重。
  • 整条链路依赖 spaCy;没装就实体功能整体静默关闭,退化成纯语义+BM25。

10. 代码地图

主题文件符号
实体抽取mem0/utils/entity_extraction.pyextract_entities, extract_entities_batch
实体库(懒加载)mem0/memory/main.pyentity_store (:515)
集合命名mem0/memory/main.py_entity_collection_name (:376)
单条 upsertmem0/memory/main.py_upsert_entity (:561)
批量实体链接mem0/memory/main.py_add_to_vector_store Phase 7 (:1033)
检索 boostmem0/memory/main.py_compute_entity_boosts (:1680)
boost 权重常量mem0/utils/scoring.pyENTITY_BOOST_WEIGHT (:57)