第 4 章 · 02 去重与实体合并


第 4 章 · 02 去重与实体合并

本节摘要:装配管线第④段的后半。deduplication 模块(10 个 Python 文件、4600 行)解决的是冗余而非矛盾:同一个 Apple,年报抽出「Apple Inc.」、新闻抽出「Apple」,图谱里就是两个节点,中心性与社区检测结果全部被稀释。本节重点读它的两段式架构——第一阶段阻塞(blocking):用简单键(姓名 token 前缀、类型、Soundex 语音码)把 n 个实体分进若干桶,只在桶内生成候选对,把 O(n²) 的比对量压到 O(n·bucket);第二阶段语义精判:候选对逐对做多因子加权相似度(字符串 Jaro-Winkler、属性重合、关系重合、可选嵌入余弦)。随后读 union-find 聚组、五种合并策略与属性级合并规则,以及 _add_provenance 的双来源溯源;最后划清它与第 3 章 EntityResolver 的分工,总结多源融合的经典难题。

内容来源:semantica/deduplication/duplicate_detector.py 961 行、similarity_calculator.py 830 行、cluster_builder.py 581 行、entity_merger.py 580 行、merge_strategy.py 577 行、methods.pyregistry.pyconfig.pydeduplication_provenance.py__init__.py

⚠️ 注意:两段式的召回率完全由阻塞键决定——分桶键选错,真正重复的一对若从不落在同一个桶里,语义精判再准也救不回来(legacy 策略只按名字首字母分桶,"Apple" 与 "apple inc." 大小写归一后同桶,但「苹果公司」与 "Apple" 永远不同桶)。另外嵌入相似度默认不启用:embedding_weight=0.0,且只有两个实体都带 embedding 字段才计算——要启用语义向量精判,需在第 3 章抽取时就把向量挂到实体上。

学习目标

  1. 算清重复实体对图分析的伤害,理解去重为什么是「入库后治理」的第一刀。
  2. 掌握两段式架构(阻塞求召回+精判求精确)与三种阻塞键(token 前缀、类型+token、Soundex)及候选对生成、截断。
  3. 掌握多因子加权相似度:四路打分、权重再归一、短路剪枝与预过滤门。
  4. 理解 union-find 聚组、组置信度、代表实体选择与 _create_duplicate_candidate 的置信度加成。
  5. 会用五种合并策略与 add_property_rule 属性级规则,理解 merged_from 溯源。
  6. 说清 EntityResolver 与 deduplication 的边界:抽取时消解 vs 入库后治理。

一、重复的代价与两段式总架构

10 万实体两两比对约 50 亿对,每对都跑完整相似度计算不可行。模块的答案是把「找出候选」与「确认重复」拆开:

n 个实体 │ ① 阻塞:按简单键分桶(token 前缀/类型/语音码),桶内两两组合 → 候选对(O(n·bucket)) │ ② 语义精判:string(0.6)+property(0.2)+relationship(0.2)(+embedding),score ≥ 0.7 → 相似对 │ ③ 聚组:union-find 把"A~B、B~C"并组;置信度加成后 ≥ 0.6 → 重复候选,选代表实体 └ ④ 合并:策略选基实体、属性逐个消解、关系去重、补 provenance

两个数据结构承载全程(duplicate_detector.py 第 52—72 行):DuplicateCandidate 记一对实体及 similarity_score/confidence/reasons——reasons 是可解释性清单(exact_name_match3_property_matchessame_type),报告里能直接回答「凭什么说它俩重复」;DuplicateGroup 把同组实体、两两相似度 similarity_scores、代表实体与组置信度打包,供合并阶段消费。

二、第一阶段:阻塞分桶,O(n²) 降到 O(n·bucket)

阻塞实现在 similarity_calculator.py 第 651—734 行,_build_block_indexes 给每个实体计算一个或多个桶键:

def _build_block_indexes(self, processed_entities, options): blocks: Dict[str, List[int]] = {} strategy = options.get("candidate_strategy", "legacy") for idx, entity in enumerate(processed_entities): name = entity.get("_lower_name", "") # 预处理阶段已小写化 if not name: blocks.setdefault("___empty___", []).append(idx) continue if strategy == "legacy": blocks.setdefault(name[0], []).append(idx) # 首字母分桶 elif strategy in ("blocking_v2", "hybrid_v2"): keys_to_add = set() tokens = [t for t in name.replace("_", " ").replace("-", " ").split() if len(t) > 2] if not tokens: tokens = [name] for t in tokens: keys_to_add.add(f"tok:{t[:4]}") # token 前 4 字符为键 if "type" in options.get("blocking_keys", ["prefix", "token"]): e_type = str(entity.get("type", "unknown")).lower() keys_to_add.add(f"type:{e_type}:{tokens[0][:4]}") if options.get("enable_phonetic_blocking", False): for t in tokens: keys_to_add.add(f"pho:{self._soundex(t)}") # 语音码键 for k in keys_to_add: blocks.setdefault(k, []).append(idx) # 一实体可进多桶 return blocks

三个设计点。多键入桶blocking_v2 下一个实体同时挂 tok:appltype:company:applpho:A140 多个桶,任一键相同即成候选——单键漏掉的靠键组合补回,这是控召回的阀门。Soundex 语音阻塞(第 635—649 行)把单词按辅音映射成 4 位码(BFPV→1、CGJKQSXZ→2……),"smith" 与 "smyth" 同码,专治拼读变体。空名兜底:无名字的实体统一进 ___empty___ 桶互为候选,不至于漏网。随后两个小函数完成候选生成:

def _generate_candidate_pairs(self, blocks): candidate_pairs = set() for indices in blocks.values(): n = len(indices) for i_idx in range(n): for j_idx in range(i_idx + 1, n): i, j = indices[i_idx], indices[j_idx] candidate_pairs.add((min(i, j), max(i, j))) # 序标准化天然去重 return candidate_pairs

(min, max) 序标准化让同一对实体即使出现在多个桶里也只生成一个元组,set 吸收重复;_cap_candidate_pairs(第 713—734 行)再按 max_candidates_per_entity 给每个实体截断至多 N 个邻居(按索引排序的确定性截断),防御「所有实体都叫 unknown」的超级大桶。桶内组合从 n² 降到 Σ 桶大小平方,桶均匀时约 O(n·bucket)——这就是第一阶段的意义。

三、第二阶段:语义精判的多因子加权

候选对进入 calculate_similarity(第 220—369 行),四路组件按权重合成:

string_score = self.calculate_string_similarity(name1, name2) # 默认 jaro_winkler property_score = self.calculate_property_similarity(entity1, entity2) relationship_score = self.calculate_relationship_similarity(entity1, entity2) embedding_score = 0.0 if "embedding" in entity1 and "embedding" in entity2: # 双方都带向量才算 embedding_score = self.calculate_embedding_similarity( entity1["embedding"], entity2["embedding"]) # 加权聚合:只对「实际算出来的组件」做权重再归一 weights = {"string": self.string_weight, # 默认 0.6 "property": self.property_weight, # 默认 0.2 "relationship": self.relationship_weight, # 默认 0.2 "embedding": self.embedding_weight if embedding_score > 0 else 0.0} total_weight = sum(w for k, w in weights.items() if k in components) weights = {k: w / total_weight for k, w in weights.items() if k in components} overall_score = sum(components.get(key, 0.0) * weight for key, weight in weights.items())

两个工程细节。其一,权重再归一:没带向量的实体对没有 embedding 分,剩余三路权重重新归一到 1——不会因缺一路就把总分系统性压低。其二,短路剪枝(第 290—294 行):字符串权重高于 0.5 且字符串分低于 0.3、又无嵌入可比时,直接按 string_score × string_weight 返回 method="short_circuit"——名字天差地别的对,不必再算细账。字符串引擎是一族(第 371—649 行):默认 Jaro-Winkler(前缀加成 min(prefix_len, 4) * 0.1,对 "Apple" vs "Apple Inc." 这类前缀一致型最敏感),备选 Levenshtein 与字符 bigram 余弦;嵌入相似度(第 487 行起)算向量余弦后 (cos+1)/2 折到 0—1。入口 batch_calculate_similarity(第 740—831 行)串起两段:预处理(小写名、关系哈希化)→建桶→生成候选→截断→逐对精判→score ≥ threshold 的对以原始实体三元组返回。可选的 _prefilter_pair(第 162—217 行)再设三道便宜闸门:类型不同直接拒、名字长度比低于 0.3 拒、token 零重合拒。

四、从候选对到重复组:置信度加成与 union-find

DuplicateDetector.detect_duplicates(第 186—320 行)把相似对升级为重复候选,靠 _create_duplicate_candidate(第 736—803 行)做置信度加成:

reasons = [] confidence = similarity_score # 基础分即底仓 if name1 == name2 and name1: # 名字精确重合(强信号) reasons.append("exact_name_match") confidence += 0.1 if prop_matches > 0: # 每个同值公共属性 +0.05 reasons.append(f"{prop_matches}_property_matches") confidence += 0.05 * prop_matches if entity_type1 and entity_type1 == entity_type2: # 同类型 +0.05 reasons.append("same_type") confidence += 0.05 confidence = min(1.0, confidence)

相似度是「像不像」,置信度是「敢不敢断定重复」——精确同名、属性同值、类型一致逐项加分并留痕进 reasons。过了 confidence_threshold(默认 0.6)的对进 detect_duplicate_groups(第 322 行起),由 _build_duplicate_groups(第 805—865 行)聚组——union-find 的字典实现,四种情形:双新则开新组、一旧一新则新者并入旧组、对称情形同理、分属两组则合并两组并改挂全部引用。传递性由此而来:AB、BC 即使 A 与 C 从未直接比对,也会并进同一组。组置信度 _calculate_group_confidence(第 867—878 行)取组内两两相似度均值乘 (0.8 + 0.2 × size_factor)——组越大越给一点奖励(多路佐证);代表实体 _select_representative(第 880—892 行)取属性与关系总数最多者,即「信息最全的写法」代表全组。增量场景走 incremental_detect(新实体只与既有实体比),配合下一节的 incremental_merge 支撑流式入库。

五、实体合并:五种策略、属性级规则与双来源溯源

EntityMerger.merge_duplicates(第 134—299 行)对每个 ≥2 实体的组调用 MergeStrategyManager.merge_entities,合并骨架是「选基实体+逐字段填充」:

class MergeStrategy(Enum): # merge_strategy.py 第 53—61 行 KEEP_FIRST = "keep_first" # 保第一个 KEEP_LAST = "keep_last" KEEP_MOST_COMPLETE = "keep_most_complete" # 保信息最全(默认) KEEP_HIGHEST_CONFIDENCE = "keep_highest_confidence" MERGE_ALL = "merge_all" # 逐字段融合 CUSTOM = "custom" def _select_base_entity(self, entities, strategy): # 第 376—393 行 if strategy == MergeStrategy.KEEP_MOST_COMPLETE: return max(entities, key=lambda e: len(e.get("properties", {})) + len(e.get("relationships", []))) elif strategy == MergeStrategy.KEEP_HIGHEST_CONFIDENCE: return max(entities, key=lambda e: e.get("confidence", 0.0)) ...

基实体提供底盘,其余实体的属性在 _merge_properties(第 395—447 行)逐个合入:新属性直接补上,同属性异值交给 _resolve_property_conflict(第 449—491 行),消解次序是属性级规则优先于全局策略

# manager.add_property_rule("revenue", "keep_highest_confidence", conflict_resolution=fn) if rule and rule.conflict_resolution: # ① 自定义函数最优先 resolved_value = rule.conflict_resolution(value1, value2) strategy = rule.strategy if rule else default_strategy if strategy == MergeStrategy.KEEP_FIRST: return {"resolved": True, "value": value1, ...} elif strategy == MergeStrategy.KEEP_LAST: return {"resolved": True, "value": value2, ...} elif strategy == MergeStrategy.MERGE_ALL: # 融合为多值列表,不丢任何一说法 return {"resolved": True, "value": [value1, value2], ...}

解不开的记入 MergeResult.conflicts 留给人工。关系合并 _merge_relationships(第 493—532 行)以 (subject, predicate, object) 三元组去重,semantic_v2 模式额外启用谓词同义归一(predicate_synonym_map 把 "works_for"/"employed_by" 折成同一谓词)与字面量归一,避免同义谓词造成关系重复。合并完成后 _add_provenanceentity_merger.py 第 474—519 行)把来龙去脉写进结果实体——这就是「保留双来源溯源」:metadata.provenance.merged_from 逐个记录被吞并实体的 id/name/source,外加 merge_count,合并可回滚、可审计,被合并者没有凭空消失。validate_merge(第 557—577 行)按「缺名字、缺类型、有未解冲突」扣分给出 quality_scoreincremental_merge(第 387—472 行)用 processed_new/processed_existing 两个集合保证流式场景下每个实体只被合并一次。

六、与 EntityResolver 的分工与多源融合难题

第 3 章 kg/entity_resolver.pyEntityResolver(strategy=fuzzy/exact/semantic,similarity_threshold=0.7)由 GraphBuildermerge_entities=True 时构造,管抽取时消解:建图那一刻把「苹果公司」与 "Apple Inc." 归并成同一节点,追求即时、轻量。本模块管入库后治理:批量重跑、可配置阻塞键、可解释的候选理由、合并策略与规则、provenance 与增量合并——治理是持续动作,不是一次性钩子。二者是同一问题的两个相位:Resolver 保证建图时大体干净,Dedup 定期清掉漏网重复并为每次合并记账。

最后盘点多源融合经典难题在本模块的落点:实体对齐难(写法无穷),靠阻塞多键+语义精判;值冲突(年龄两说),交给第 4.1 节的消解策略,合并时靠属性级规则;传递性风险(AB、BC 但 A 与 C 其实不同——连锁误并),union-find 会闭眼合并,只能靠调高 similarity_thresholdvalidate_merge 事后体检;合并不可逆,用 merged_from 溯源兜底;召回与精确的跷跷板,靠阻塞键组合与 max_candidates_per_entity 调节。没有一劳永逸的解,只有可配置、可解释、可追溯的工程折中——这正是数据治理段的全部要义。

💡 装配要点:本段心智模型是「两段漏斗+三步合并」。漏斗第一级阻塞求召回(token 前缀/类型/语音码多键入桶,O(n²)→O(n·bucket)),第二级多因子加权求精确(string 0.6+property 0.2+relationship 0.2,缺路自动再归一,可选嵌入);候选对经置信度加成与 union-find 聚组后,按策略选基实体、属性级规则消解冲突、三元组去重关系,最后 merged_from 记账。接进管线的方式:抽取产物先过本模块(或先经第 4.1 节冲突消解)得到唯一实体表,再喂给第 5 章的 kg.build()——节点唯一性是图统计可信的前提。

本节要点回顾

  • 两段式:阻塞(简单键分桶生成候选对,控复杂度与召回)+语义精判(多因子相似度,控精确);batch_calculate_similarity 是两段的粘合点。
  • 阻塞键三种:tok: token 前 4 字符、type: 类型+token、pho: Soundex 语音码;legacy 只按首字母;(min,max) 序标准化+set 天然去重,_cap_candidate_pairs 防超级大桶。
  • 多因子加权:四路组件、缺路权重再归一、string_score<0.3 短路、_prefilter_pair 三道闸门;字符串默认 Jaro-Winkler(前缀加成),嵌入余弦 (cos+1)/2 且默认权重 0。
  • 聚组:置信度=相似度+0.1(精确同名)+0.05×属性同值数+0.05(同类型);union-find 四情形合并(含传递性);组置信度=均值×(0.8+0.2×size_factor);代表实体取信息最全者。
  • 合并:五种策略(默认 keep_most_complete)、属性级规则(自定义函数>属性策略>全局策略)、关系三元组去重(semantic_v2 谓词同义归一)、merged_from 双来源溯源、incremental_merge 流式去重。
  • 分工:EntityResolver 管抽取时消解(建图内联),deduplication 管入库后治理(批量、可解释、可回溯);对齐、冲突、传递性、不可逆、召回/精确五大难题各有工程化落点。

下一章:第 5 章 · 01 Rete 前向链推理——治理干净的图上才能跑确定性推理:Rete 网络四类节点、事实沿网传播与不动点前向链。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U