第 5 章
冲突检测与去重
场景还原
一家公司把 CRM、ERP、LDAP 三套系统同时灌进知识图谱。同一个客户 cust-001,CRM 记的邮箱是 alice@example.com,ERP 和 LDAP 记的是 alice.smith@example.com。如果流水线按摄取顺序"先到先得",这个邮箱最终是什么,取决于哪份文件先被读到。换一次文件顺序,结论就变一次。审计员追问"这个值谁定的、凭什么",没人答得上来。
同一个客户还会带来第二个坑:威胁情报里 APT29、Cozy Bear、Midnight Blizzard 是同一个组织,却被抽成三个节点。关系散落在三处,查询"APT29 关联了哪些恶意软件"只返回三分之一的结果,图上的中心度指标也跟着失真。
本章看 Semantica 怎么处理这两件事。conflicts 模块把打架的值摆到台面上,判定谁该赢,并把裁决记下来;deduplication 模块把重复的节点合并成一个,再留一条"谁并了谁"的账。两者顺序不能反,这是本章贯穿始终的一条暗线。读完这章,你能回答三个具体问题:一次属性打架从检测到裁决经过哪几段代码;七个消解策略分别在什么数据上成立;去重和冲突的顺序为什么不能对调。
逐行精读
先看整体编排。去重在前,冲突在后,最后才是 SHACL 校验:
冲突检测:把打架的值摆上桌面
冲突检测的输入是一堆实体字典,输出是一堆 Conflict 对象。先看它定义了什么是一个"冲突"。五种类型枚举,加一个装全部证据的 dataclass:
68class ConflictType(str, Enum):69 """Conflict type enumeration."""7071 VALUE_CONFLICT = "value_conflict"72 TYPE_CONFLICT = "type_conflict"73 RELATIONSHIP_CONFLICT = "relationship_conflict"74 TEMPORAL_CONFLICT = "temporal_conflict"75 LOGICAL_CONFLICT = "logical_conflict"767778@dataclass79class Conflict:80 """Conflict information."""8182 conflict_id: str83 conflict_type: ConflictType84 entity_id: Optional[str] = None85 property_name: Optional[str] = None86 relationship_id: Optional[str] = None87 conflicting_values: List[Any] = field(default_factory=list)88 sources: List[Dict[str, Any]] = field(default_factory=list)89 confidence: float = 1.090 severity: str = "medium" # low, medium, high, critical91 recommended_action: Optional[str] = None92 metadata: Dict[str, Any] = field(default_factory=dict)Conflict 的字段名已经把设计意图讲完了。它不存"谁赢了",只存"谁和谁在哪个属性上打架":conflicting_values 放互相冲突的值,sources 放每个值来自哪份文档,severity 放严重度,recommended_action 放建议动作。confidence 默认 1.0,severity 默认 medium。注意这些字段都是"观测结果",不带任何裁决,裁决是下一节 ConflictResolver 的事。
检测的核心动作是把同一 entity_id 的多条记录聚到一起,再看同一属性上的值是否唯一:
237 for j, (entity_id, entity_list) in enumerate(entity_groups.items()):238 if len(entity_list) < 2:239 continue # Need at least 2 sources to have conflict240241 values = []242 sources = []243244 for entity in entity_list:245 if property_name in entity:246 value = entity[property_name]247 values.append(value)248249 # Track source if available250 if self.track_provenance:251 source_ref = SourceReference(252 document=entity.get("source", "unknown"),253 page=entity.get("page"),254 section=entity.get("section"),255 confidence=entity.get("confidence", 1.0),256 metadata=entity.get("metadata", {}),257 )258 self.source_tracker.track_property_source(259 entity_id, property_name, value, source_ref260 )261 sources.append(262 {263 "document": source_ref.document,264 "page": source_ref.page,265 "confidence": source_ref.confidence,266 "metadata": source_ref.metadata,267 }268 )269270 # Check for value conflicts271 unique_values = list(set(str(v) for v in values if v is not None))272273 if len(unique_values) > 1:274 conflict = Conflict(275 conflict_id=f"{entity_id}_{property_name}_conflict",276 conflict_type=ConflictType.VALUE_CONFLICT,277 entity_id=entity_id,278 property_name=property_name,279 conflicting_values=values,280 sources=sources,281 confidence=self._calculate_conflict_confidence(values, sources),282 severity=self._calculate_severity(property_name, values),283 recommended_action=self._recommend_action(284 property_name, values285 ),286 )287 conflicts.append(conflict)288 self.detected_conflicts[conflict.conflict_id] = conflict这段代码有三处关键逻辑。第一,第 238 行 if len(entity_list) < 2: continue,注释写明"Need at least 2 sources to have conflict"。单个来源不可能打架,冲突的定义前提就是同一实体出现两次以上。第二,第 271 行用 str(v) 把每个值字符串化再 set 去重,这是防御性的:值可能是数字、字符串,甚至不可哈希的 dict,字符串化让比较一定成立。第三,第 274 行构造 Conflict 时,confidence、severity、recommended_action 三个字段都来自配套的私有方法,把"打分"和"建议"拆出去单独算。
这三个配套方法看这里:
558 def _calculate_conflict_confidence(559 self, values: List[Any], sources: List[Dict[str, Any]]560 ) -> float:561 """Calculate confidence score for conflict."""562 if not sources:563 return 0.5564565 # Average confidence of sources566 avg_confidence = sum(s.get("confidence", 0.5) for s in sources) / len(sources)567568 # Higher confidence if values are very different569 value_diversity = (570 len(set(str(v) for v in values)) / len(values) if values else 0571 )572573 return min(1.0, avg_confidence * (1 + value_diversity))574575 def _calculate_severity(self, property_name: str, values: List[Any]) -> str:576 """Calculate conflict severity."""577 # Critical fields578 critical_fields = ["id", "name", "type", "founded_year", "revenue"]579 if property_name.lower() in critical_fields:580 return "critical"581582 # High severity for numeric conflicts with large differences583 try:584 numeric_values = [float(v) for v in values if v is not None]585 if numeric_values:586 value_range = max(numeric_values) - min(numeric_values)587 if value_range > 1000: # Large difference588 return "high"589 except (ValueError, TypeError):590 pass591592 return "medium"593594 def _recommend_action(self, property_name: str, values: List[Any]) -> str:595 """Recommend action for conflict."""596 try:597 if len(set(values)) == 2:598 return (599 "Compare source documents and use most recent or authoritative "600 "source"601 )602 except TypeError:603 # Handle unhashable types (like dicts or lists)604 # Convert to string representation for set comparison605 str_values = [str(v) for v in values]606 if len(set(str_values)) == 2:607 return (608 "Compare source documents and use most recent or authoritative "609 "source"610 )611612 return "Multiple conflicting values detected. Manual review recommended."_calculate_conflict_confidence 的算法是:先取所有来源 confidence 的平均值,再乘一个"多样性系数" (1 + value_diversity)。value_diversity 是唯一值个数除以总个数,值越分散、冲突越"明显",置信度越高,最后 min(1.0, ...) 封顶。这里有个隐含含义:它衡量的是"这个冲突检测得准不准",跟"最后选中的值准不准"是两回事,后者由消解器单独算。
_calculate_severity 是一张硬编码清单。critical_fields = ["id", "name", "type", "founded_year", "revenue"],命中直接返回 critical。否则试把值转成数字,极差大于 1000 返回 high,兜底 medium。这张清单是纯粹的业务假设,revenue(营收)和 founded_year(成立年份)被当成生死字段,其他属性一律从宽。
第五种冲突类型"逻辑冲突"用的是另一套机制,靠的是类型互斥表:
1025 def detect_logical_conflicts(1026 self, entities: List[Dict[str, Any]]1027 ) -> List[Conflict]:1028 """1029 Detect logical conflicts (e.g., Person cannot be Organization).10301031 Args:1032 entities: List of entity dictionaries10331034 Returns:1035 List of detected conflicts1036 """1037 tracking_id = self.progress_tracker.start_tracking(1038 file=None,1039 module="conflicts",1040 submodule="ConflictDetector",1041 message=f"Detecting logical conflicts in {len(entities)} entities",1042 )10431044 try:1045 conflicts = []10461047 # Logical rules: incompatible type combinations1048 incompatible_types = {1049 "Person": ["Organization", "Company", "Institution"],1050 "Organization": ["Person"],1051 "Company": ["Person"],1052 "Location": ["Person", "Organization"],1053 }incompatible_types 是一张四行的小表:Person 不能同时是 Organization、Company、Institution,Organization 和 Company 不能是 Person,Location 不能是 Person 或 Organization。这跟前面按值比较的冲突不同,它不比较值,只检查同一实体的类型标签之间是否违反常识。表很小,说明作者只放了最不可能被推翻的几对,把可争论的类型组合留给人工。
六个检测方法里,detect_entity_conflicts 是批量入口。它先算要查哪些字段:如果给定了 entity_type 且在 conflict_fields 里登记过,就查登记的那几个;否则把所有实体字典的键取并集,再减掉 {id, entity_id, type, source, metadata} 这五个簿记键(conflict_detector.py:497-506)。减掉这五个键是关键:source、metadata 本来就是溯源字段,不参与冲突比较;id、entity_id 是分组依据,不是被比较的属性。剩下的键逐个调 detect_value_conflicts,一轮扫完所有属性,调用方不用先列清单。
detect_temporal_conflicts 管时间类属性。它先列一张 temporal_properties 名单(founded、founded_year、established、created、timestamp、date、start_date、end_date,conflict_detector.py:840-850),只查这些键。比较前先归一化:字符串里用正则 \b(19|20)\d{2}\b 抠年份(conflict_detector.py:954),抠得出来就转成整数再比,抠不出来原样保留。这样 2008 年 和 2008 能对上,2008 和 2009 才算冲突。这个正则只认 1900 到 2099,两位数和更早的年份会被漏掉,是设计里一个可留意的取舍。
detect_type_conflicts 和 detect_value_conflicts 结构几乎一样,只是比较的对象从属性值换成 type 字段,冲突 id 后缀是 _type_conflict。顶层入口 detect_conflicts 带一个 method 参数做分发,method="all" 时把值冲突、类型冲突、时间冲突、逻辑冲突串成一组检测步骤依次跑。这套分发让调用方能按需只跑一种检测,也能一键全扫。
冲突消解:七种策略与一条审计链
检测只负责"发现",消解才负责"拍板"。先看消解器的两个核心数据结构:
70class ResolutionStrategy(str, Enum):71 """Conflict resolution strategy."""7273 VOTING = "voting"74 CREDIBILITY_WEIGHTED = "credibility_weighted"75 MOST_RECENT = "most_recent"76 FIRST_SEEN = "first_seen"77 HIGHEST_CONFIDENCE = "highest_confidence"78 MANUAL_REVIEW = "manual_review"79 EXPERT_REVIEW = "expert_review"808182@dataclass83class ResolutionResult:84 """Conflict resolution result."""8586 conflict_id: str87 resolved: bool88 resolved_value: Any = None89 resolution_strategy: Optional[str] = None90 confidence: float = 0.091 sources_used: List[str] = field(default_factory=list)92 resolution_notes: Optional[str] = None93 metadata: Dict[str, Any] = field(default_factory=dict)ResolutionStrategy 列出七种策略。ResolutionResult 里有一个关键的布尔字段 resolved:它区分"机器自己拍板了"和"转人工了"。resolved=False 的结果照样进入历史记录,只是带着 requires_manual_review 或 requires_expert_review 的标记。这保证人工裁决也落在同一条审计链上,不会有"机器决定有记录、人工决定没记录"的裂缝。
单个冲突怎么被路由到具体策略,看 resolve_conflict 的中间段:
214 # Check for property-specific rule if strategy was None215 if strategy is None:216 if conflict.property_name:217 rule_key = f"{conflict.entity_id}.{conflict.property_name}"218 if rule_key in self.resolution_rules:219 normalized_strategy = self.resolution_rules[rule_key]220221 strategy = normalized_strategy222223 self.logger.info(224 (225 f"Resolving conflict {conflict.conflict_id} using strategy: "226 f"{strategy.value}"227 )228 )229230 if strategy == ResolutionStrategy.VOTING:231 result = self._resolve_by_voting(conflict)232 elif strategy == ResolutionStrategy.CREDIBILITY_WEIGHTED:233 result = self._resolve_by_credibility(conflict)234 elif strategy == ResolutionStrategy.MOST_RECENT:235 result = self._resolve_by_recency(conflict)236 elif strategy == ResolutionStrategy.FIRST_SEEN:237 result = self._resolve_by_first_seen(conflict)238 elif strategy == ResolutionStrategy.HIGHEST_CONFIDENCE:239 result = self._resolve_by_confidence(conflict)240 elif strategy == ResolutionStrategy.MANUAL_REVIEW:241 result = self._flag_for_manual_review(conflict)242 elif strategy == ResolutionStrategy.EXPERT_REVIEW:243 result = self._flag_for_expert_review(conflict)244 else:245 result = self._resolve_by_voting(conflict)246247 result.resolution_strategy = strategy.value248 conflict_type = (249 conflict.conflict_type.value250 if hasattr(conflict.conflict_type, "value")251 else conflict.conflict_type252 )253 result.metadata.setdefault("conflict_type", conflict_type)254 result.metadata.setdefault("entity_id", conflict.entity_id)255 result.metadata.setdefault("property_name", conflict.property_name)256 result.metadata.setdefault("relationship_id", conflict.relationship_id)257 if conflict.metadata:258 result.metadata.setdefault("conflict_metadata", conflict.metadata)259 self.resolution_history.append(result)第 214 到 219 行是"按属性定制策略"的入口:只有调用方没显式传 strategy 时,才去 resolution_rules 里查 entity_id.property_name 这个键有没有预先登记的规则。这意味着默认策略可以被逐属性覆盖,且覆盖只发生在"调用方没指定"的空白处。第 230 到 245 行是一串 if/elif 分发,把七种枚举值对到七个私有方法,最后一个 else 兜底走投票,保证枚举扩展后老调用不会崩。
投票和可信度加权是最常用的两种策略,看实现:
355 def _resolve_by_voting(self, conflict: Conflict) -> ResolutionResult:356 """Resolve conflict by voting (most common value wins)."""357 if not conflict.conflicting_values:358 return ResolutionResult(359 conflict_id=conflict.conflict_id,360 resolved=False,361 resolution_notes="No conflicting values to resolve",362 )363364 # Count value occurrences365 value_counts = Counter(conflict.conflicting_values)366 most_common_value, count = value_counts.most_common(1)[0]367368 # Calculate confidence based on vote ratio369 total_votes = len(conflict.conflicting_values)370 confidence = count / total_votes if total_votes > 0 else 0.0371372 sources_used = [s.get("document", "unknown") for s in conflict.sources]373374 return ResolutionResult(375 conflict_id=conflict.conflict_id,376 resolved=True,377 resolved_value=most_common_value,378 confidence=confidence,379 sources_used=sources_used,380 resolution_notes=(381 f"Resolved by voting: {count}/{total_votes} votes for this value"382 ),383 )384385 def _resolve_by_credibility(self, conflict: Conflict) -> ResolutionResult:386 """Resolve conflict by credibility-weighted voting."""387 if not conflict.conflicting_values or not conflict.sources:388 return ResolutionResult(389 conflict_id=conflict.conflict_id,390 resolved=False,391 resolution_notes="Insufficient data for credibility-based resolution",392 )393394 # Weight values by source credibility395 value_weights: Dict[Any, float] = {}396397 for i, value in enumerate(conflict.conflicting_values):398 source = conflict.sources[i] if i < len(conflict.sources) else {}399 document = source.get("document", "unknown")400 source_confidence = source.get("confidence", 0.5)401 credibility = self.source_tracker.get_source_credibility(document)402403 weight = source_confidence * credibility404405 if value not in value_weights:406 value_weights[value] = 0.0407 value_weights[value] += weight408409 # Get value with highest weight410 if not value_weights:411 return ResolutionResult(412 conflict_id=conflict.conflict_id,413 resolved=False,414 resolution_notes="Could not calculate credibility weights",415 )416417 resolved_value = max(value_weights.items(), key=lambda x: x[1])[0]418 total_weight = sum(value_weights.values())419 confidence = (420 value_weights[resolved_value] / total_weight if total_weight > 0 else 0.0421 )422423 sources_used = [s.get("document", "unknown") for s in conflict.sources]424425 return ResolutionResult(426 conflict_id=conflict.conflict_id,427 resolved=True,428 resolved_value=resolved_value,429 confidence=confidence,430 sources_used=sources_used,431 resolution_notes=(432 "Resolved by credibility-weighted voting "433 f"(weight: {value_weights[resolved_value]:.2f})"434 ),_resolve_by_voting 用 Counter 数频次,most_common(1)[0] 取最高票,confidence = count / total_votes 就是票数占比。三票里两票一致,置信度就是 0.67,跟直觉对得上。_resolve_by_credibility 复杂一点:每个值按"来源 confidence 乘来源 credibility"加权累加,最后取权重最高的值,置信度是该值权重占总权重的比例。credibility 来自 source_tracker.get_source_credibility,这是下一节的证据底座。
注意投票策略的一个隐藏前提:Counter(conflict.conflicting_values) 直接拿原始值做键。如果 conflicting_values 里混进了不可哈希的 dict,这里会抛 TypeError。检测层在第 271 行做了字符串化防抖,消解层没有,两层对"值可哈希性"的容忍度不一致。
时间敏感的数据走 MOST_RECENT,实现里藏着一个值得警惕的退化路径:
437 def _resolve_by_recency(self, conflict: Conflict) -> ResolutionResult:438 """Resolve conflict by using most recent value."""439 if not conflict.sources:440 return ResolutionResult(441 conflict_id=conflict.conflict_id,442 resolved=False,443 resolution_notes="No source timestamps available",444 )445446 # Find most recent source (by timestamp if available)447 most_recent_idx = 0448 most_recent_time = None449450 for i, source in enumerate(conflict.sources):451 # Check metadata for timestamp452 timestamp = source.get("metadata", {}).get("timestamp")453 if timestamp:454 if not most_recent_time or timestamp > most_recent_time:455 most_recent_time = timestamp456 most_recent_idx = i457458 resolved_value = conflict.conflicting_values[most_recent_idx]459 sources_used = [conflict.sources[most_recent_idx].get("document", "unknown")]460461 return ResolutionResult(462 conflict_id=conflict.conflict_id,463 resolved=True,464 resolved_value=resolved_value,465 confidence=0.8,466 sources_used=sources_used,467 resolution_notes="Resolved by most recent value",468 )第 450 到 456 行的循环只在 source.get("metadata", {}).get("timestamp") 为真时更新 most_recent_idx。如果所有来源都没写 timestamp,循环一次都不进,most_recent_idx 停在初值 0,于是取 conflicting_values[0],置信度固定 0.8,resolution_notes 照样写"Resolved by most recent value"。换句话说,没有时间戳时它静默退化成 FIRST_SEEN,还对外宣称用的是最新值。这是一个真实现成的坑,边界剖析里会再展开。
七种策略里最后两种不挑赢家:_flag_for_manual_review 和 _flag_for_expert_review 都返回 resolved=False,区别只在 metadata 里的键,前者是 requires_manual_review,后者是 requires_expert_review。这两个键是给下游队列看的:机器把高风险的冲突挑出来挂起,等人在同一套审计链上补一笔决定,批次里的其他冲突照常自动消解,不会被一颗老鼠屎卡住整条流水线。
来源追踪:裁决要用的证据底座
消解器算可信度加权时,需要知道每个来源有多可信。这个信息存在 SourceTracker 里。先看两个数据载体:
74@dataclass75class SourceReference:76 """Source reference for a property value."""7778 document: str79 page: Optional[int] = None80 section: Optional[str] = None81 line: Optional[int] = None82 timestamp: Optional[datetime] = None83 confidence: float = 1.084 metadata: Dict[str, Any] = field(default_factory=dict)858687@dataclass88class PropertySource:89 """Source information for a property value."""9091 property_name: str92 value: Any93 sources: List[SourceReference] = field(default_factory=list)94 entity_id: Optional[str] = None95 metadata: Dict[str, Any] = field(default_factory=dict)SourceReference 是一个值的"出生证明":document、page、section、line、timestamp、confidence,外加一个自由发挥的 metadata。PropertySource 把某个属性上的所有来源聚成一个列表。注意 SourceReference.timestamp 是 Optional[datetime],没有默认成"现在",也就是说时间戳是可选证据,缺了就是缺了。
来源登记时做了一件事:同一份文档的同一个位置只记一次:
186 def track_property_source(187 self,188 entity_id: str,189 property_name: str,190 value: Any,191 source: SourceReference,192 **metadata,193 ) -> bool:194 """195 Track source for a property value.196197 Args:198 entity_id: Entity identifier199 property_name: Property name200 value: Property value201 source: Source reference202 **metadata: Additional metadata203204 Returns:205 True if tracking successful206 """207 if entity_id not in self.entity_sources:208 self.entity_sources[entity_id] = {}209210 if property_name not in self.entity_sources[entity_id]:211 self.entity_sources[entity_id][property_name] = PropertySource(212 property_name=property_name,213 value=value,214 entity_id=entity_id,215 metadata=metadata,216 )217218 property_source = self.entity_sources[entity_id][property_name]219220 # Add source if not already present221 source_exists = any(222 s.document == source.document223 and s.page == source.page224 and s.section == source.section225 for s in property_source.sources226 )227228 if not source_exists:229 property_source.sources.append(source)230231 # Update value if different (will be used for conflict detection)232 if property_source.value != value:233 property_source.value = value # Store latest value234235 self.logger.debug(236 f"Tracked property source: {entity_id}.{property_name} = {value} "237 f"from {source.document}"238 )239 return True第 221 到 226 行的 source_exists 用 document、page、section 三元组判重,同一来源重复登记不会堆叠。第 232 行 if property_source.value != value: property_source.value = value,注释写"Store latest value":追踪器只保留最后一个值,历史值不存。这跟冲突检测的"保留全部 conflicting_values"形成互补,一个记现状,一个记分歧。
可信度分数本身有边界校验,默认值是 0.5:
379 def set_source_credibility(self, document: str, credibility: float) -> bool:380 """381 Set credibility score for a source document.382383 Args:384 document: Document identifier385 credibility: Credibility score (0.0 to 1.0)386387 Returns:388 True if set successfully389 """390 if not 0.0 <= credibility <= 1.0:391 raise ValidationError("Credibility must be between 0.0 and 1.0")392393 self.source_credibility[document] = credibility394 self.logger.info(f"Set credibility for {document}: {credibility}")395 return True396397 def get_source_credibility(self, document: str) -> float:398 """399 Get credibility score for a source document.400401 Args:402 document: Document identifier403404 Returns:405 Credibility score (default: 0.5)406 """407 return self.source_credibility.get(document, 0.5)set_source_credibility 用 ValidationError 挡住范围外的输入,get_source_credibility 对没登记的来源返回 0.5。0.5 这个默认值含义是"不了解,先中立",乘以来源 confidence 后只会拉低权重,不会凭空抬举某个来源。
去重与合并:先合节点,再算总账
冲突解决的前提是"同一个实体只有一个 canonical 节点"。去重模块负责把散落的重复节点合并起来。先看它输出的两个结构:
52@dataclass53class DuplicateCandidate:54 """Duplicate candidate representation."""5556 entity1: Dict[str, Any]57 entity2: Dict[str, Any]58 similarity_score: float59 confidence: float60 reasons: List[str] = field(default_factory=list)61 metadata: Dict[str, Any] = field(default_factory=dict)626364@dataclass65class DuplicateGroup:66 """Group of duplicate entities."""6768 entities: List[Dict[str, Any]]69 similarity_scores: Dict[Tuple[str, str], float] = field(default_factory=dict)70 representative: Optional[Dict[str, Any]] = None71 confidence: float = 0.072 metadata: Dict[str, Any] = field(default_factory=dict)DuplicateCandidate 是"一对疑似重复":两个实体、相似度、置信度、reasons 里写命中原因。DuplicateGroup 是"一组传递闭包后的重复":实体列表、两两相似度、representative(代表实体)、组置信度。
候选怎么判出来,看 _create_duplicate_candidate 的前半段:
770 # Check entity type mismatch first: two entities with different771 # explicit types are not duplicates, whatever their similarity.772 entity_type1 = self._get_entity_value(entity1, "type")773 entity_type2 = self._get_entity_value(entity2, "type")774 if entity_type1 and entity_type2 and entity_type1 != entity_type2:775 return DuplicateCandidate(776 entity1=entity1,777 entity2=entity2,778 similarity_score=similarity_score,779 confidence=0.0,780 reasons=["type_mismatch"],781 metadata={782 "name_match": False,783 "common_properties": 0,784 "type_match": False,785 "type_mismatch": True,786 },787 )788789 # Check for exact name match (strong indicator)790 name1 = str(self._get_entity_value(entity1, "name", "")).lower().strip()791 name2 = str(self._get_entity_value(entity2, "name", "")).lower().strip()792 if name1 == name2 and name1: # Non-empty exact match793 reasons.append("exact_name_match")794 confidence += 0.1795796 # Check property value matches797 props1 = self._get_entity_value(entity1, "properties", {})798 props2 = self._get_entity_value(entity2, "properties", {})799800 common_props = set(props1.keys()) & set(props2.keys())801 if common_props:802 # Count properties with matching values803 prop_matches = sum(804 1 for prop in common_props if props1.get(prop) == props2.get(prop)805 )806 if prop_matches > 0:807 reasons.append(f"{prop_matches}_property_matches")808 # Boost confidence for each matching property809 confidence += 0.05 * prop_matches810811 # Check entity type match (only boosts when types are equal; mismatch812 # is handled above)813 if entity_type1 and entity_type2 and entity_type1 == entity_type2:814 reasons.append("same_type")815 confidence += 0.05816817 # Cap confidence at 1.0818 confidence = min(1.0, confidence)第 770 到 787 行是类型闸门:两个实体的 type 都填了、且不同,直接返回 confidence=0.0、reasons=["type_mismatch"]。这条否决是结构性的,后面的阈值再低也救不回来,注释写"two entities with different explicit types are not duplicates, whatever their similarity"。注意条件是 entity_type1 and entity_type2,两边都要真值,缺失 type 不触发否决。第 792 行精确同名(忽略大小写和首尾空白)加 0.1,第 806 到 809 行每个匹配属性加 0.05,最后 818 行 min(1.0, confidence) 封顶。
单个候选成不了"组",传递闭包靠这段 union-find:
833 def _build_duplicate_groups(834 self, candidates: List[DuplicateCandidate]835 ) -> List[DuplicateGroup]:836 """Build duplicate groups from candidates."""837 # Union-find structure838 entity_to_group = {}839 groups = []840841 for candidate in candidates:842 entity1_id = self._normalize_entity_id(candidate.entity1)843 entity2_id = self._normalize_entity_id(candidate.entity2)844845 group1 = entity_to_group.get(entity1_id)846 group2 = entity_to_group.get(entity2_id)847848 if group1 is None and group2 is None:849 # Create new group850 group = DuplicateGroup(851 entities=[candidate.entity1, candidate.entity2],852 similarity_scores={853 (entity1_id, entity2_id): candidate.similarity_score854 },855 )856 groups.append(group)857 entity_to_group[entity1_id] = group858 entity_to_group[entity2_id] = group859 elif group1 is not None and group2 is None:860 # Add entity2 to group1861 if candidate.entity2 not in group1.entities:862 group1.entities.append(candidate.entity2)863 group1.similarity_scores[(entity1_id, entity2_id)] = (864 candidate.similarity_score865 )866 entity_to_group[entity2_id] = group1867 elif group1 is None and group2 is not None:868 # Add entity1 to group2869 if candidate.entity1 not in group2.entities:870 group2.entities.append(candidate.entity1)871 group2.similarity_scores[(entity1_id, entity2_id)] = (872 candidate.similarity_score873 )874 entity_to_group[entity1_id] = group2875 elif group1 != group2:876 # Merge groups877 group1.entities.extend(878 [e for e in group2.entities if e not in group1.entities]879 )880 group1.similarity_scores.update(group2.similarity_scores)881 group1.similarity_scores[(entity1_id, entity2_id)] = (882 candidate.similarity_score883 )884885 # Update references886 for entity in group2.entities:887 entity_id = self._normalize_entity_id(entity)888 entity_to_group[entity_id] = group1889890 if group2 in groups:891 groups.remove(group2)892893 return groupsentity_to_group 字典把每个实体 id 映射到它所属的组。四种分支:两个都无组则新建组;一个有组则把另一个拉进去;两个分属不同组则合并,把 group2 的实体和相似度全部并入 group1,再从 groups 列表里删掉 group2。这实现了"A 像 B、B 像 C,即使 A 和 C 不直接达标也归为一组"的传递闭包。
去重还支持增量场景。incremental_detect 只拿新实体和已有实体做笛卡尔积比较,避免重跑全量两两配对,duplicate_detector.py:549 的 docstring 写"This method efficiently detects duplicates between new entities and an existing set of entities, avoiding the O(n²) comparison of all pairs"。关系去重另有 detect_relationship_duplicates,在 semantic_v2 模式下先按谓词同义词表归一化谓词,再按空格归一化对象字面量,用一个哈希签名判重,谓词占 60%、对象占 40% 做加权合成。这套逻辑和实体去重是两条独立路径:实体靠相似度打分,关系靠签名加加权。
合并时的策略定义和裁决逻辑,在 merge_strategy.py:
54class MergeStrategy(Enum):55 """Merge strategy types."""5657 KEEP_FIRST = "keep_first"58 KEEP_LAST = "keep_last"59 KEEP_MOST_COMPLETE = "keep_most_complete"60 KEEP_HIGHEST_CONFIDENCE = "keep_highest_confidence"61 MERGE_ALL = "merge_all"62 CUSTOM = "custom"636465@dataclass66class PropertyMergeRule:67 """Rule for merging properties."""6869 property_name: str70 strategy: MergeStrategy71 conflict_resolution: Optional[Callable] = None72 priority: int = 0737475@dataclass76class MergeResult:77 """Result of merge operation."""7879 merged_entity: Dict[str, Any]80 merged_entities: List[Dict[str, Any]]81 conflicts: List[Dict[str, Any]] = field(default_factory=list)82 metadata: Dict[str, Any] = field(default_factory=dict)383 def _select_base_entity(384 self, entities: List[Dict[str, Any]], strategy: MergeStrategy385 ) -> Dict[str, Any]:386 """Select base entity for merge."""387 if strategy == MergeStrategy.KEEP_FIRST:388 return entities[0]389 elif strategy == MergeStrategy.KEEP_LAST:390 return entities[-1]391 elif strategy == MergeStrategy.KEEP_MOST_COMPLETE:392 return max(393 entities,394 key=lambda e: len(e.get("properties", {}))395 + len(e.get("relationships", [])),396 )397 elif strategy == MergeStrategy.KEEP_HIGHEST_CONFIDENCE:398 return max(entities, key=lambda e: e.get("confidence", 0.0))399 else:400 return entities[0]456 def _resolve_property_conflict(457 self,458 property_name: str,459 value1: Any,460 value2: Any,461 default_strategy: MergeStrategy,462 ) -> Dict[str, Any]:463 """Resolve property conflict."""464 # Check for property-specific rule465 rule = self.property_rules.get(property_name)466467 if rule and rule.conflict_resolution:468 try:469 resolved_value = rule.conflict_resolution(value1, value2)470 return {471 "resolved": True,472 "value": resolved_value,473 "resolution": "custom_rule",474 }475 except Exception as e:476 self.logger.warning(f"Custom conflict resolution failed: {e}")477478 strategy = rule.strategy if rule else default_strategy479480 if strategy == MergeStrategy.KEEP_FIRST:481 return {"resolved": True, "value": value1, "resolution": "keep_first"}482 elif strategy == MergeStrategy.KEEP_LAST:483 return {"resolved": True, "value": value2, "resolution": "keep_last"}484 elif strategy == MergeStrategy.MERGE_ALL:485 # Merge into list if not already486 if isinstance(value1, list):487 if value2 not in value1:488 value1.append(value2)489 return {"resolved": True, "value": value1, "resolution": "merge_all"}490 else:491 return {492 "resolved": True,493 "value": [value1, value2],494 "resolution": "merge_all",495 }496 else:497 # Default: keep first498 return {"resolved": True, "value": value1, "resolution": "default"}_select_base_entity 决定哪条记录当"底稿":KEEP_MOST_COMPLETE 取 properties 加 relationships 数量最多的那条。_resolve_property_conflict 处理属性级分歧:先查有没有针对该属性的自定义规则和自定义函数,再按策略走;MERGE_ALL 把分歧值塞进列表,KEEP_FIRST 保底 value1,最后的 else 注释写"Default: keep first"。
合并的最后一步是留账。EntityMerger 的 MergeOperation 和溯源写入:
56@dataclass57class MergeOperation:58 """Entity merge operation representation."""5960 source_entities: List[Dict[str, Any]]61 merged_entity: Dict[str, Any]62 merge_result: MergeResult63 timestamp: Optional[str] = None64 metadata: Dict[str, Any] = None475 def _add_provenance(476 self, merged_entity: Dict[str, Any], source_entities: List[Dict[str, Any]]477 ) -> Dict[str, Any]:478 """479 Add provenance information to merged entity.480481 This method adds metadata about which entities were merged to create482 the merged entity, preserving the history of the merge operation.483484 Provenance Structure:485 metadata.provenance:486 - merged_from: List of source entity information (id, name, source)487 - merge_count: Number of entities that were merged488489 Args:490 merged_entity: The merged entity dictionary to add provenance to491 source_entities: List of source entities that were merged492493 Returns:494 Merged entity dictionary with provenance information added495 """496 # Ensure metadata structure exists497 if "metadata" not in merged_entity:498 merged_entity["metadata"] = {}499500 if "provenance" not in merged_entity["metadata"]:501 merged_entity["metadata"]["provenance"] = {}502503 provenance = merged_entity["metadata"]["provenance"]504505 # Record source entities506 provenance["merged_from"] = [507 {508 "id": get_entity_id(e),509 "name": self._get_entity_value(e, "name"),510 "source": self._get_entity_value(e, "metadata", {}).get("source") if hasattr(e, "metadata") or isinstance(e, dict) else None,511 }512 for e in source_entities513 ]514 provenance["merge_count"] = len(source_entities)515516 self.logger.debug(517 f"Added provenance for merge of {len(source_entities)} entity(ies)"518 )519520 return merged_entityMergeOperation 是一个合并事务的完整记录:源实体列表、合并结果、策略结果、时间戳、元数据。_add_provenance 在 merged_entity["metadata"]["provenance"] 下写 merged_from(每个源实体的 id、name、source)和 merge_count。这段代码保证"谁并了谁"永远可查,合并后的 canonical 节点自带溯源。
设计决策分析
第一个决策:检测、消解、来源追踪拆成三个类。看文档怎么给这套分工定性:
9<Info>10Run conflict detection after deduplication and before SHACL validation. Deduplication removes duplicate nodes; conflict resolution reconciles disagreeing property values on the same canonical entity. Running them out of order — detecting conflicts before deduplication — will produce spurious conflicts between entities that should have been merged first.11</Info>1213## What Is Conflict Resolution?1415When you merge data from multiple sources, the same real-world entity — a customer, a product, a threat actor, a drug compound — often appears with contradictory property values. One database says a customer's email is `alice@example.com`; another says `alice.smith@example.com`. One security feed rates a CVE at 10.0; two others rate it 9.1 and 9.5.1617**Conflict resolution** is the systematic process of deciding which value is most trustworthy and recording that decision with evidence, so the canonical entity ends up with one defensible, auditable value per property.1819### Key Concepts2021**Canonical entity** — The single authoritative record for a real-world thing. After deduplication, each entity has exactly one canonical node in your graph. Conflict resolution determines which property values belong on that node.2223**Conflicting values** — Two or more different values asserted for the same property on the same canonical entity, each reported by a different source.2425**Credibility score** — A number between 0.0 and 1.0 you attach to each source record, indicating how reliable that source is. A government registry might carry 0.99; a scraped blog might carry 0.30. You supply these; Semantica uses them during `CREDIBILITY_WEIGHTED` resolution.2627**Confidence score** — A number between 0.0 and 1.0 the resolver *computes* after resolution, reflecting how certain the outcome is. A unanimous vote produces high confidence; a close split among equally credible sources produces lower confidence. This appears on `ResolutionResult.confidence` and should be read as a signal, not a guarantee that the resolved value is correct.2829**Resolution strategy** — The rule for picking the winning value: majority vote, credibility-weighted average, latest timestamp, and so on. See [Resolution strategies at a glance](#resolution-strategies-at-a-glance) for the full list.3031**Audit trail** — The complete record of every resolution decision: conflict ID, strategy used, resolved value, sources consulted, and confidence score. Returned by `resolver.get_resolution_history()`.文档把四个概念并排定义:Credibility score 是你输入的对来源可靠性的信念,Confidence score 是解析器算出来的对结果的把握,Resolution strategy 是挑赢家的规则,Audit trail 是每次裁决的完整记录。这四个词对应三个类的分工:SourceTracker 存 credibility,ConflictResolver 产 confidence,ConflictDetector 只观测。拆开的直接好处是裁决能独立测试、独立替换,检测结果也可以不消解、直接进人工队列。
一个 Conflict 从被发现到落地,生命周期只有四条边:
第二个决策:去重必须排在冲突之前。文档的 Info 块写得很直白:去重先合并重复节点,冲突消解再在同一 canonical 节点上调和属性值,顺序反了会在本该被合并的实体之间产生假冲突。这个顺序在代码里也能对上:EntityMerger 产出带 provenance 的 canonical 实体,ConflictDetector 再按 entity_id 分组去查属性分歧。若先检测,两个本该合并的重复节点会被当成两个来源互相打架,产生一堆噪音冲突。
第三个决策:默认策略加逐属性覆盖,一次调用按属性各走各的策略,全局一刀切的方案留不住这种灵活。ConflictResolver.__init__ 里 default_strategy 默认 "voting",set_resolution_rule 只针对 entity_id.property_name 这一个键。文档明确说没有通配符,"There is no wildcard that applies a rule to all entities"。理由在代码里:legal_name 该用可信度加权,last_updated 该用最新值,同一批冲突需要不同规则,逐属性登记让一次 resolve_conflicts() 调用就能各走各的。
第四个决策:credibility 和 confidence 分开存、分开算。credibility 是 SourceTracker.source_credibility 字典里你手工 set 进去的数,confidence 是 ResolutionResult 里解析器算出来的数。文档在"Common Pitfalls"里警告:credibility 只是你输入的信念,0.99 的来源照样可能错,错误校准的信念会被加权策略放大。这个区分的价值在于把"输入假设"和"输出把握"拆成两个字段,审计时能分清哪部分是人给的、哪部分是机器算的。
第五个决策:用 union-find 做传递闭包,把链上重复的实体一次性收进一组,两两配对的方案会漏掉 A 与 C 这类间接重复。_build_duplicate_groups 的注释直接写"Union-find structure"。如果只按候选两两合并,A 像 B、B 像 C 但 A 和 C 不达标时,A 和 C 会留在两个组里,同一个组织还是两个节点。union-find 把链上的节点全部并入一组,这是去重质量的关键一步。
第六个决策:审计链挂在结果对象上,不在日志里。resolve_conflict 每次成功都把 result 追加进 self.resolution_history,get_resolution_history 返回副本。文档把 Audit trail 定义为"每次裁决的完整记录"。挂在对象上意味着审计记录和返回给调用方的结果走同一条路,调用方拿到 ResolutionResult 的同时历史里已经多了一行,不需要再手动写日志。返回副本挡住外部篡改,审计链不会被调用方悄悄改掉。
边界条件剖析
如果 _resolve_by_recency 的所有来源都没有 metadata.timestamp,会怎样? 循环第 450 到 456 行只在 timestamp 为真时更新 most_recent_idx。全缺失时循环体一次都不执行,most_recent_idx 停在初值 0,第 458 行取 conflicting_values[0],第 465 行置信度固定 0.8,第 467 行 notes 照写"Resolved by most recent value"。结论落在 conflict_resolver.py:450-467:它静默退化成取第一个值,还自报"用了最新值",没有任何告警。真实数据里 timestamp 缺失很常见,这个策略在那种数据上等于随机选。
如果两个实体的 type 一个填了 "Company"、一个是 None,会不会被 type 闸门否决? 不会。duplicate_detector.py:774 的条件是 if entity_type1 and entity_type2 and entity_type1 != entity_type2,两边都要真值。缺失 type 时条件短路为假,不返回 type_mismatch;第 813 行同样的条件也意味着不加 same_type 加分。结论落在 duplicate_detector.py:774 和 813:缺失类型被当成"无证据",既不下否决票也不上赞成票,候选能否成立完全取决于名字和属性的相似度。
如果 conflicting_values 里混进不可哈希的 dict,投票策略会怎样? 检测层 conflict_detector.py:271 用 set(str(v) for v in values) 字符串化后判唯一,dict 能活过检测。但消解层 conflict_resolver.py:365 的 Counter(conflict.conflicting_values) 直接拿原始值当键,dict 会抛 TypeError: unhashable type: 'dict'。结论落在 conflict_resolver.py:365:检测层防了抖,消解层没防,两层对输入可哈希性的假设不一致。
如果 detect_value_conflicts 收到的是单个实体 dict,会怎样? conflict_detector.py:147 判断 isinstance(entities, dict) 成立,"entities" in entities 不成立,走第 152 行 entities = [entities] 包成单元素列表。之后分组里只有一条记录,第 238 行 len(entity_list) < 2 直接 continue,返回空列表。结论落在 conflict_detector.py:147-152 和 238:单个 dict 不崩,但也不产出冲突,调用方拿到的空列表容易被误读成"没有冲突"。
横向对比
GraphRAG 没有 Semantica 这套冲突检测与消解层。但要说得更精确:它有"精确键去重"和"保留全部描述的聚合",缺的是"相似度判重"和"赢家选择"。看它最终确定实体表时做了什么:
38 sample_rows: list[dict[str, Any]] = []39 seen_titles: set[str] = set()40 human_readable_id = 04142 async for row in entities_table:43 title = row.get("title")44 if not title or title in seen_titles:45 continue46 seen_titles.add(title)47 row["degree"] = degree_map.get(title, 0)48 row["human_readable_id"] = human_readable_id49 row["id"] = str(uuid4())50 human_readable_id += 151 out = {col: row.get(col) for col in ENTITIES_FINAL_COLUMNS}52 await entities_table.write(out)53 if len(sample_rows) < 5:54 sample_rows.append(out)5556 return sample_rowsfinalize_entities 用一个 seen_titles 集合按 title 精确去重,先到先得,后到的同名实体直接 continue 丢弃,再重新分配 uuid。它判"重复"的标准是 title 字符串完全相等,没有相似度、没有别名匹配、没有类型闸门。
抽取阶段的合并更说明问题:
104def _merge_entities(entity_dfs) -> pd.DataFrame:105 all_entities = pd.concat(entity_dfs, ignore_index=True)106 return (107 all_entities108 .groupby(["title", "type"], sort=False)109 .agg(110 description=("description", list),111 text_unit_ids=("source_id", list),112 frequency=("source_id", "count"),113 )114 .reset_index()115 )116117118def _merge_relationships(relationship_dfs) -> pd.DataFrame:119 all_relationships = pd.concat(relationship_dfs, ignore_index=False)120 return (121 all_relationships122 .groupby(["source", "target"], sort=False)123 .agg(124 description=("description", list),125 text_unit_ids=("source_id", list),126 weight=("weight", "sum"),127 )128 .reset_index()129 )_merge_entities 按 ["title", "type"] 分组,description 聚合成一个列表,text_unit_ids 聚合成列表,frequency 计个数。注意它不挑赢家:三个 chunk 各抽出一句描述,就留三句,全塞进 description 列表里。_merge_relationships 同理,按 (source, target) 分组,weight 求和。
增量更新时这条思路延续下去:
49 # Group by title and resolve conflicts50 aggregated = (51 combined52 .groupby("title")53 .agg({54 "id": "first",55 "type": "first",56 "human_readable_id": "first",57 "description": lambda x: list(x.astype(str)), # Ensure str58 # Concatenate nd.array into a single list59 "text_unit_ids": lambda x: list(itertools.chain(*x.tolist())),60 "degree": "first", # todo: we could probably re-compute this with the entire new graph61 })62 .reset_index()63 )6465 # recompute frequency to include new text units66 aggregated["frequency"] = aggregated["text_unit_ids"].apply(len)6768 # Force the result into a DataFrame69 resolved: pd.DataFrame = pd.DataFrame(aggregated)7071 # Modify column order to keep consistency72 resolved = resolved.loc[:, ENTITIES_FINAL_COLUMNS]7374 return resolved, id_mapping_group_and_resolve_entities 的注释写着"Group by title and resolve conflicts",但它的"resolve"是 "id": "first"、"type": "first"、"description": lambda x: list(...)。所谓消解,是把冲突的描述拼成一个列表,把其他字段取第一个,频率按列表长度重算。没有一个字段是"从多个候选里选一个赢家"。
为什么 GraphRAG 可以没有赢家选择层?看它的输入形态:extract_graph 的 _merge_entities 按 source_id 聚合 text_unit_ids,source_id 是文本单元(chunk)的 id。它面对的是同一份文档语料被切成多个 chunk 后,各 chunk 抽出的重复实体,冲突的本质是"同一个东西被多个 chunk 各描述了一遍"。这种情况下"全部保留"是合理答案:描述之间通常互补,不存在哪份文档更权威的问题,查询时 community report 和 local search 会把多个描述一起引给模型。
而 Semantica 面向的是 30 种数据源的摄取(见第 2 章),LEI 注册局说的法人名和 CRM 里填的简称可能真的打架,且有一方确实更权威。这种场景必须挑赢家并记下来,否则下游审计没法回答"这个值谁定的"。两者的差异一句话概括:GraphRAG 的冲突解法是"聚合并保留全部",Semantica 的是"裁决并保留证据"。前者在单源语料下够用,后者是多源企业数据的硬需求。
企业多源场景缺了这套东西会出什么事?把 GraphRAG 的做法原样搬到 30 源摄取上,第一个坏处是权威来源被稀释:LEI 注册局的法人名和 CRM 的简称一起塞进 description 列表,下游模型看到两个名字,可能随手挑一个当答案,没有任何字段记录"为什么挑它"。第二个坏处是假实体无法归并:seen_titles 只认 title 字符串全等,BlackRock Inc. 和 BlackRock, Inc. 会是两个节点,关系被切开,中心度指标失真。Semantica 的相似度判重加合并正好补这两处。反过来,GraphRAG 的"全保留"也有 Semantica 该学的一面:当冲突来自同一份文档的多个 chunk、描述互补而非互斥时,全保留比挑赢家更安全,挑赢家反而会丢掉上下文。
一个可核对的检索证据:在 graphrag 仓库 packages/graphrag/graphrag 下搜 conflict,命中的实现只有 index/update/entities.py 和 index/update/relationships.py 两处注释,没有独立的冲突检测模块目录;搜 similarity 也没有实体相似度计算。Semantica 对应的 semantica/conflicts/ 和 semantica/deduplication/ 是两个一级模块,各有六到十一个文件。
互动演示设计
形态:决策沙盘。
一句话结论:把"打架的值"和"打架的来源"摆到一张台子上,让策略在台子上公开挑赢家,并把挑的过程写进账本。
舞台元素与比喻:台面上放三张证据卡,每张卡上写一个来源文档名、一个值、一个可信度分数;台子中央是一台计票器和一架权威天平;台子右侧挂一个"留待人工"的抽屉。
分步动画:
- 三张证据卡飞入台面,分别来自 crm、erp、ldap,值分别是
alice@example.com、alice.smith@example.com、alice.smith@example.com。 - 检测器把三张卡按实体 id 归成一摞,发现邮箱字段有三个值、两个唯一值,台面亮起"冲突"灯。
- 读者选策略:点"投票"按钮,计票器转动,两票对一票,
alice.smith@example.com胜出,置信度显示 0.67。 - 读者改点"可信度加权",权威天平接管,按每张卡的可信度分数加权,胜出值可能不同。
- 读者把策略改成"人工复核",抽屉打开,冲突卡被塞进去,结果标记
resolved=False。 - 台面右侧的账本实时追加一行:冲突 id、用了什么策略、谁赢了、用了哪些来源、置信度多少。
每步字幕文案:
- 第 1 步:「同一个 cust-001,三个来源给了两个邮箱,这是检测器的输入。」
- 第 2 步:「检测只回答『有没有打架』,不回答『谁赢』。」
- 第 3 步:「投票按票数占比给置信度,两票就是 0.67。」
- 第 4 步:「加权按来源可信度乘来源置信度,权威来源可以一票顶三票。」
- 第 5 步:「人工复核不挑赢家,只把冲突挂起,账本照样记一笔。」
- 第 6 步:「每行账目都带 sources_used,这就是审计能回答『谁定的』的原因。」
读者可操作项:打开任意 Python 环境,构造三个来源的 cust-001 记录,跑一遍 ConflictDetector().detect_value_conflicts 和 ConflictResolver().resolve_conflicts,分别传 "voting"、"credibility_weighted"、"manual_review",对比 ResolutionResult.confidence 和 resolved 三个字段的差异。
沙盘里的数据流用一张时序图收敛成五步:
逻辑轨迹面板(右侧是真实行号):
按 entity_id 分组,凑齐 ≥2 条才继续 # conflict_detector.py:237-239
收集每个来源的值和 SourceReference # conflict_detector.py:244-268
字符串化后判唯一,>1 才算冲突 # conflict_detector.py:271-273
构造 Conflict,打分和建议一并写入 # conflict_detector.py:274-288
查 resolution_rules 有没有该属性的专属策略 # conflict_resolver.py:214-219
按策略枚举分发到私有方法 # conflict_resolver.py:230-245
投票:Counter 取众数,票数占比为置信度 # conflict_resolver.py:365-370
加权:source_confidence × credibility 累加 # conflict_resolver.py:397-407
结果写 resolution_strategy 和 sources_used # conflict_resolver.py:247, 423可迁移结论
值得抄的第一条:把"检测"和"消解"拆成两个对象。检测是纯观测,产出可序列化的 Conflict 记录;消解是决策,产出带 sources_used 的 ResolutionResult。两者之间用数据对象衔接,任何一侧都能独立替换。这个拆分不依赖 Python,任何语言都能照做。
第二条:审计字段的最小三元组。ResolutionResult 里最值得抄的不是七种策略,而是三个字段:resolved_value(谁赢了)、sources_used(证据来自哪)、confidence(机器有多确定)。有了这三样,任何一个下游系统都能回答"这个值谁定的、凭什么"。
第三条:先合并重复节点,再在同一节点上裁决属性值。这条顺序是语言无关的管道契约,docs/guides/conflict-resolution.md 的 Info 块把它写成硬规则。顺序反了会产生假冲突,这句话可以直接抄进任何数据工程的验收清单。
最小成本形态:一个 defaultdict 按实体 id 聚组,一个 Counter 做投票,一张 source -> credibility 的字典做加权,一个列表存裁决历史。几十行就能覆盖 80% 的场景,dataclass 全家桶、进度追踪器、类型互斥表都可以后补。
最后一条:把"先到先得"这种隐式裁决显式化。很多管道没有冲突层,靠摄取顺序隐式地让第一条记录赢。Semantica 把这个选择变成显式的 ResolutionStrategy,默认投票、可逐属性覆盖、可转人工。哪怕暂时只实现投票一种策略,显式化本身就比隐式顺序多了一条审计依据。
哪些是过度设计:ConflictDetector.resolve_conflicts 里还有一个 auto_resolve 分支,注释自己写着"This is a placeholder for more complex logic",跟 ConflictResolver 的职责重叠,实际是把消解逻辑放错了类。另外三个文件里大量重复的进度追踪样板代码,update_interval 的计算在检测、消解、合并里复制了十几遍,属于噪音大于价值的部分。incompatible_types 那张四行互斥表也是:规则太少,覆盖不了真实本体的类型约束,真正的类型约束应该交给第 7 章的 SHACL。
思考题
-
动手验证:打开
semantica/conflicts/conflict_resolver.py,把_resolve_by_recency第 453 行的if timestamp:改成if not timestamp: raise ...,然后构造一个所有来源都没有metadata.timestamp的三来源冲突,跑MOST_RECENT策略。观察抛错的位置和原来的静默退化路径有什么区别,再想想生产里更合适的改法是把resolved置 False 还是抛异常。 -
概念题:
Credibility score和Confidence score都是 0 到 1 的浮点数,都参与加权,为什么文档坚持把它们定义成两个概念?结合conflict_resolver.py里_resolve_by_credibility的计算过程说明,如果这两个数被合并成一个字段,审计时丢掉了什么信息。 -
设计题:GraphRAG 用
description=list把冲突描述全部保留,Semantica 用resolved_value挑一个赢家。各在什么前提下成立?如果 Semantica 的某个属性改用 GraphRAG 式的"全保留",下游查询要做什么改动才能不退化回"先到先得"? -
边界题:
detect_value_conflicts第 271 行用str(v)字符串化后判唯一,但_resolve_by_voting第 365 行直接用原值做Counter键。给一个具体输入让这个冲突穿过检测层、在消解层炸掉,并说明修复应该落在哪一层。