数据截至 (上游 commit 10374d6044f9)
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 的根本。