跳到主要内容

Graphiti 去重与时序 — MinHash 去重 + 双时间轴失效

这是 Graphiti 最有「精华」的两块。去重解决「这个实体/事实是不是已经有了」,时序解决「事实变了怎么办」。两者都遵循同一哲学:能用确定性算法搞定就别花 LLM 的钱,LLM 只在不确定时兜底。

第一部分:实体去重(resolve_extracted_nodes)

1.1 要解决的小问题

LLM 这次抽到一个叫「Kendra」的人。图里可能已经有「Kendra」「Kendra Smith」「kendra」。它们是同一个人吗?每次都问 LLM 太贵、太慢。

Graphiti 的答案是三级降级:先试最便宜的精确匹配,不行再试中等成本的模糊匹配,都拿不准才升级到 LLM。

1.2 总览:三级降级

对每个抽出的候选节点:
┌─ ① 先按 group_id 向量召回候选(cosine 相似的已有节点)
│ node_similarity_search,阈值 0.6,最多 15 个

② 确定性匹配 _resolve_with_similarity
│ ├─ (a) 规范化后精确同名? 唯一命中 → 直接复用,结束
│ ├─ (b) 多个同名(歧义)? → 交给 LLM
│ ├─ 熵闸门:名字太短/太单调? → 交给 LLM(不信模糊匹配)
│ └─ (c) MinHash/LSH 模糊:Jaccard ≥ 0.9? → 复用,结束

③ 仍未决 → _resolve_with_llm 批量问 LLM

入口 resolve_extracted_nodes(node_operations.py:627),确定性引擎 _resolve_with_similarity(dedup_helpers.py:220),LLM 兜底 _resolve_with_llm(node_operations.py:467)。

1.3 第①步:候选从哪来

不是拿全图比对,而是先用名字的向量相似度召回一小批候选(_semantic_candidate_search,node_operations.py:418):

# 示意,非源码(逻辑见 node_operations.py:436-450)
node_similarity_search(
driver, query_vector, SearchFilters(),
[node.group_id],
NODE_DEDUP_CANDIDATE_LIMIT, # = 15
NODE_DEDUP_COSINE_MIN_SCORE, # = 0.6
)

两个常量在 node_operations.py:64-65。所以确定性匹配和 LLM 都只在这 ≤15 个语义近邻里挑,范围可控。

1.4 第②步(a):精确名匹配——永远先试

_normalize_string_exact(dedup_helpers.py:39)把名字转小写、压缩空白。规范化后若候选里恰好一个同名,直接认定是它(dedup_helpers.py:236-243)。

注释特意强调:精确匹配对所有名字都做,不受长度/熵限制;熵闸门只挡模糊匹配(dedup_helpers.py:226-230)。多个同名是「歧义」,直接升级 LLM(dedup_helpers.py:244-248)。

1.5 熵闸门:为什么短名字不敢做模糊匹配

模糊匹配前有道关卡 _has_high_entropy(dedup_helpers.py:79)。它用 香农熵(Shannon entropy,衡量字符串「信息量/独特性」的指标) 算名字的特异性:

# 示意,非源码(逻辑见 dedup_helpers.py:52-85)
# 太短且 token 太少 → 直接判定不可靠
if len(name) < 6 and token_count < 2:
return False
# 否则要求字符熵 >= 1.5
return shannon_entropy(name) >= 1.5

直觉:像「AI」「Co」这种又短又单调的名字,模糊匹配极易误判(任何含这些字母的名字都「像」),所以宁可交给 LLM也不靠相似度。阈值 _NAME_ENTROPY_THRESHOLD=1.5_MIN_NAME_LENGTH=6(dedup_helpers.py:31-33)。

1.6 第②步(c):MinHash + LSH 模糊匹配

这是去重的工程核心。目标:在不做 N×N 两两比对的前提下,快速找出「名字拼写接近」的候选。三个概念:

  • Shingle(n-gram 切片): 把名字切成 3 字符片段集合。_shingles(dedup_helpers.py:88)。例:kendra → {ken, end, ndr, dra}。
  • MinHash 签名: 用 32 个不同哈希种子,各取 shingle 集合的最小哈希值,得到一个 32 维签名(_minhash_signature,dedup_helpers.py:103)。两个集合越像,签名碰撞越多。
  • LSH 分桶(局部敏感哈希): 把 32 维签名切成每 4 个一段的「band」,同 band 落同桶(_lsh_bands,dedup_helpers.py:117)。只比对同桶的候选,避免全量比对。

找到同桶候选后,用真实的 Jaccard 相似度(交集/并集)精算,≥ _FUZZY_JACCARD_THRESHOLD = 0.9 才认定重复(dedup_helpers.py:271)。

这些结构在每轮去重开头由 _build_candidate_indexes(dedup_helpers.py:192)一次性预计算好。

1.7 第③步:LLM 兜底

确定性拿不准的(歧义、低熵、模糊没过阈值)才进 _resolve_with_llm(node_operations.py:467)。它把未决节点和候选打包,让 LLM 用 duplicate_candidate_id(<0 表示「不是任何已有节点」)指认。

值得学的是这里的防御性校验(node_operations.py:560-619):对 LLM 返回的 id 做范围检查、去重、漏检告警——即使模型乱答,ingestion 也保持确定不崩。

1.8 一个巧妙细节:类型提升

_promote_resolved_node(dedup_helpers.py:170):若图里的规范节点只有裸 Entity 标签,而新抽的副本带了更具体的类型(如 Person),就把具体类型「提升」到规范节点上。这样实体类型会随信息增多而变精确,而不是停留在第一次抽取的粗糙状态。

第二部分:事实的时序失效

2.1 双时间轴(bi-temporal)

Graphiti 对每条 EntityEdge(事实)记两套时间(edges.py:271-279):

时间轴字段含义类比
事件时(valid time)valid_at / invalid_at事实在现实中何时开始/停止为真「这件事三月到六月成立」
系统时(transaction time)created_at / expired_at这条记录在库里何时被写入/被标记失效「我们六月才知道它失效了」

关键:invalid_at 表达「现实里失效」,expired_at 表达「系统里被标记停用」。两者分开,所以你既能问「现在为真的事实」(invalid_at 为空),也能问「三月那个时间点为真的事实」。

事实从不被删——只是盖上时间戳。这是它区别于普通 RAG 的根本。

2.2 一条新事实进来,要判定什么

核心函数 resolve_extracted_edge(edge_operations.py:623),对单条抽出的边判定三件事:

新事实 e 进来

├─ 是重复吗? → 找到 duplicate → 复用旧边(只补 episode 来源)

├─ 推翻了谁? → contradicted → 那些旧边要 invalidate

└─ 自己该过期吗? → 若有更晚的矛盾事实 → 给自己盖 invalid_at

2.3 候选怎么来:两类

resolve_extracted_edges(edge_operations.py:325)为每条新边召回两类候选:

  • related_edges(同端点的边):EntityEdge.get_between_nodes 拿同一对节点之间的边——用于判重复(edge_operations.py:365-370)。
  • invalidation candidates(语义相关的边):对 fact 做 EDGE_HYBRID_SEARCH_RRF 搜索——用于判矛盾(edge_operations.py:407-418)。

两者去重:同时出现在两边的,只留在 related(edge_operations.py:422-430)。

2.4 快路径:逐字相同直接复用

判重复前有两道廉价快路径,省掉 LLM:

  1. 抽取批内,(source, target, 规范化fact) 三元组完全相同的先折叠(edge_operations.py:344-358)。
  2. 若某条 related 边端点相同且 fact 规范化后逐字相同,直接复用它、只把当前 episode 追加进 episodes(edge_operations.py:684-695)。

2.5 LLM 判重复 + 矛盾(连续索引技巧)

过不了快路径才问 LLM。prompt 是 dedupe_edges.resolve_edge(prompts/dedupe_edges.py:43)。它返回两个列表:

  • duplicate_facts:哪些 EXISTING FACTS 与新事实重复。
  • contradicted_facts:哪些事实被新事实推翻。

巧妙处——连续索引。 代码把 related_edges 和 existing_edges 拼成一个连续编号空间喂给 LLM:related 占 0..len-1,invalidation candidates 从 len 起编号(edge_operations.py:700-707)。回来后再按 offset 拆回两组(edge_operations.py:769-776)。prompt 里明确约束「duplicate 只能来自 EXISTING FACTS」(prompts/dedupe_edges.py:55-58)。返回的 id 同样做了越界校验(edge_operations.py:736-744)。

prompt 的示例还点出一个微妙区别:「软件工程师」→「高级工程师」是矛盾(更新)而非重复(prompts/dedupe_edges.py:90-92)。

2.6 时序判定:谁让谁过期

这是最容易绕晕的一段,分两半:

(a) 新边是否该被自己过期。 若某个矛盾候选的 valid_at 比新边更晚,说明有更新的事实,新边一出生就过期(edge_operations.py:826-839):

# 示意,非源码(逻辑见 edge_operations.py:826-839)
for candidate in sorted(invalidation_candidates, key=valid_at):
if candidate.valid_at > resolved_edge.valid_at:
resolved_edge.invalid_at = candidate.valid_at
resolved_edge.expired_at = now
break

(b) 新边让哪些旧边过期。 resolve_edge_contradictions(edge_operations.py:538)处理反向:对每个矛盾候选,若它的有效期在新边之前开始,就把它的 invalid_at 设为新边的 valid_at、盖上 expired_at(edge_operations.py:564-571)。注意它跳过时间窗本就不重叠的候选(edge_operations.py:553-562)——不重叠就谈不上推翻。

另外:只要边带了 invalid_at 却没 expired_at,落库前会补上 expired_at = now(edge_operations.py:822-823)。

2.7 时间戳从哪来

  • 抽取时 LLM 可能直接给 valid_at/invalid_at(edge_operations.py:274-288)。
  • 没给的话,新边走 _extract_edge_timestamps(edge_operations.py:576)再单独问一次 LLM(用更小的模型 ModelSize.small),以 episode 的 valid_at 为参考时间。已有时间戳就跳过(edge_operations.py:587-588)。

小结

两块共享的设计哲学:

确定性快路径LLM 兜底
实体去重精确名 + MinHash/LSH(Jaccard≥0.9)歧义/低熵/没过阈值时
事实去重逐字相同 fact + 同端点语义重复/矛盾判定

下一章:这些带时间窗的事实,查询时怎么被混合检索出来。

代码地图

主题文件符号
实体去重入口graphiti_core/utils/maintenance/node_operations.pyresolve_extracted_nodes
确定性匹配引擎graphiti_core/utils/maintenance/dedup_helpers.py_resolve_with_similarity
熵闸门graphiti_core/utils/maintenance/dedup_helpers.py_has_high_entropy_name_entropy
MinHash/LSHgraphiti_core/utils/maintenance/dedup_helpers.py_minhash_signature_lsh_bands_jaccard_similarity
类型提升graphiti_core/utils/maintenance/dedup_helpers.py_promote_resolved_node
LLM 去重兜底graphiti_core/utils/maintenance/node_operations.py_resolve_with_llm
事实解析(单条)graphiti_core/utils/maintenance/edge_operations.pyresolve_extracted_edge
事实解析(批)graphiti_core/utils/maintenance/edge_operations.pyresolve_extracted_edges
矛盾→失效graphiti_core/utils/maintenance/edge_operations.pyresolve_edge_contradictions
去重 promptgraphiti_core/prompts/dedupe_edges.pyresolve_edgeEdgeDuplicate
双时间轴字段graphiti_core/edges.pyEntityEdge(valid_at/invalid_at/expired_at)