数据截至 (上游 commit 001c235229be)
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_batch 用 nlp.pipe 一次处理多条,写入管线用它(main.py:1036)。spaCy 装不上时整个返回 [],实体功能静默关闭。
5. 写入端:upsert 去重
实体最怕重复——「Max」出现 100 次不能存 100 行。_upsert_entity 做两级匹配(mem0/memory/main.py:605):
# 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.py | extract_entities, extract_entities_batch |
| 实体库(懒加载) | mem0/memory/main.py | entity_store (:515) |
| 集合命名 | mem0/memory/main.py | _entity_collection_name (:376) |
| 单条 upsert | mem0/memory/main.py | _upsert_entity (:561) |
| 批量实体链接 | mem0/memory/main.py | _add_to_vector_store Phase 7 (:1033) |
| 检索 boost | mem0/memory/main.py | _compute_entity_boosts (:1680) |
| boost 权重常量 | mem0/utils/scoring.py | ENTITY_BOOST_WEIGHT (:57) |