杂乱数据如何变成一张可解释、可审计的知识图谱 5 / 10

第 5 章

冲突检测与去重

源码核对基于 semantica-agi/semantica commit `1ee2ae88`,tag `course-anchor-20260824`

场景还原

一家公司把 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 校验:

flowchart LR A[多源实体列表] --> B[去重\n检测与合并] B --> C[冲突检测\n按实体分组] C --> D[冲突消解\n七种策略] D --> E[SHACL 校验]

冲突检测:把打架的值摆上桌面

冲突检测的输入是一堆实体字典,输出是一堆 Conflict 对象。先看它定义了什么是一个"冲突"。五种类型枚举,加一个装全部证据的 dataclass:

semantica/conflicts/conflict_detector.py68:92
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 的多条记录聚到一起,再看同一属性上的值是否唯一:

semantica/conflicts/conflict_detector.py237:288
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 时,confidenceseverityrecommended_action 三个字段都来自配套的私有方法,把"打分"和"建议"拆出去单独算。

这三个配套方法看这里:

semantica/conflicts/conflict_detector.py558:612
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(成立年份)被当成生死字段,其他属性一律从宽。

第五种冲突类型"逻辑冲突"用的是另一套机制,靠的是类型互斥表:

semantica/conflicts/conflict_detector.py1025:1053
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)。减掉这五个键是关键:sourcemetadata 本来就是溯源字段,不参与冲突比较;identity_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 能对上,20082009 才算冲突。这个正则只认 1900 到 2099,两位数和更早的年份会被漏掉,是设计里一个可留意的取舍。

detect_type_conflictsdetect_value_conflicts 结构几乎一样,只是比较的对象从属性值换成 type 字段,冲突 id 后缀是 _type_conflict。顶层入口 detect_conflicts 带一个 method 参数做分发,method="all" 时把值冲突、类型冲突、时间冲突、逻辑冲突串成一组检测步骤依次跑。这套分发让调用方能按需只跑一种检测,也能一键全扫。

冲突消解:七种策略与一条审计链

检测只负责"发现",消解才负责"拍板"。先看消解器的两个核心数据结构:

semantica/conflicts/conflict_resolver.py70:93
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_reviewrequires_expert_review 的标记。这保证人工裁决也落在同一条审计链上,不会有"机器决定有记录、人工决定没记录"的裂缝。

单个冲突怎么被路由到具体策略,看 resolve_conflict 的中间段:

semantica/conflicts/conflict_resolver.py214:259
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 兜底走投票,保证枚举扩展后老调用不会崩。

投票和可信度加权是最常用的两种策略,看实现:

semantica/conflicts/conflict_resolver.py355:434
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_votingCounter 数频次,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,实现里藏着一个值得警惕的退化路径:

semantica/conflicts/conflict_resolver.py437:468
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 里。先看两个数据载体:

semantica/conflicts/source_tracker.py74:95
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.timestampOptional[datetime],没有默认成"现在",也就是说时间戳是可选证据,缺了就是缺了。

来源登记时做了一件事:同一份文档的同一个位置只记一次:

semantica/conflicts/source_tracker.py186:239
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:

semantica/conflicts/source_tracker.py379:407
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_credibilityValidationError 挡住范围外的输入,get_source_credibility 对没登记的来源返回 0.5。0.5 这个默认值含义是"不了解,先中立",乘以来源 confidence 后只会拉低权重,不会凭空抬举某个来源。

去重与合并:先合节点,再算总账

冲突解决的前提是"同一个实体只有一个 canonical 节点"。去重模块负责把散落的重复节点合并起来。先看它输出的两个结构:

semantica/deduplication/duplicate_detector.py52:72
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 的前半段:

semantica/deduplication/duplicate_detector.py770:818
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.0reasons=["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:

semantica/deduplication/duplicate_detector.py833:893
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 groups

entity_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

semantica/deduplication/merge_strategy.py54:82
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)
semantica/deduplication/merge_strategy.py383:400
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]
semantica/deduplication/merge_strategy.py456:498
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"。

合并的最后一步是留账。EntityMergerMergeOperation 和溯源写入:

semantica/deduplication/entity_merger.py56:64
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] = None
semantica/deduplication/entity_merger.py475:520
475    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_entity

MergeOperation 是一个合并事务的完整记录:源实体列表、合并结果、策略结果、时间戳、元数据。_add_provenancemerged_entity["metadata"]["provenance"] 下写 merged_from(每个源实体的 id、name、source)和 merge_count。这段代码保证"谁并了谁"永远可查,合并后的 canonical 节点自带溯源。

设计决策分析

第一个决策:检测、消解、来源追踪拆成三个类。看文档怎么给这套分工定性:

docs/guides/conflict-resolution.md9:31
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 从被发现到落地,生命周期只有四条边:

stateDiagram-v2 [*] --> Detected Detected --> Resolved: 自动策略命中 Detected --> Flagged: manual_review 或 expert_review Resolved --> Persisted: 写入 canonical 记录 Flagged --> Persisted: 人工裁决后写入 Persisted --> [*]

第二个决策:去重必须排在冲突之前。文档的 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_historyget_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:774813:缺失类型被当成"无证据",既不下否决票也不上赞成票,候选能否成立完全取决于名字和属性的相似度。

如果 conflicting_values 里混进不可哈希的 dict,投票策略会怎样? 检测层 conflict_detector.py:271set(str(v) for v in values) 字符串化后判唯一,dict 能活过检测。但消解层 conflict_resolver.py:365Counter(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-152238:单个 dict 不崩,但也不产出冲突,调用方拿到的空列表容易被误读成"没有冲突"。

横向对比

GraphRAG 没有 Semantica 这套冲突检测与消解层。但要说得更精确:它有"精确键去重"和"保留全部描述的聚合",缺的是"相似度判重"和"赢家选择"。看它最终确定实体表时做了什么:

packages/graphrag/graphrag/index/operations/finalize_entities.py38:56
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_rows

finalize_entities 用一个 seen_titles 集合按 title 精确去重,先到先得,后到的同名实体直接 continue 丢弃,再重新分配 uuid。它判"重复"的标准是 title 字符串完全相等,没有相似度、没有别名匹配、没有类型闸门。

抽取阶段的合并更说明问题:

packages/graphrag/graphrag/index/operations/extract_graph/extract_graph.py104:129
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 求和。

增量更新时这条思路延续下去:

packages/graphrag/graphrag/index/update/entities.py49:74
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_entitiessource_id 聚合 text_unit_idssource_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.pyindex/update/relationships.py 两处注释,没有独立的冲突检测模块目录;搜 similarity 也没有实体相似度计算。Semantica 对应的 semantica/conflicts/semantica/deduplication/ 是两个一级模块,各有六到十一个文件。

互动演示设计

形态:决策沙盘。

一句话结论:把"打架的值"和"打架的来源"摆到一张台子上,让策略在台子上公开挑赢家,并把挑的过程写进账本。

舞台元素与比喻:台面上放三张证据卡,每张卡上写一个来源文档名、一个值、一个可信度分数;台子中央是一台计票器和一架权威天平;台子右侧挂一个"留待人工"的抽屉。

分步动画:

  1. 三张证据卡飞入台面,分别来自 crm、erp、ldap,值分别是 alice@example.comalice.smith@example.comalice.smith@example.com
  2. 检测器把三张卡按实体 id 归成一摞,发现邮箱字段有三个值、两个唯一值,台面亮起"冲突"灯。
  3. 读者选策略:点"投票"按钮,计票器转动,两票对一票,alice.smith@example.com 胜出,置信度显示 0.67。
  4. 读者改点"可信度加权",权威天平接管,按每张卡的可信度分数加权,胜出值可能不同。
  5. 读者把策略改成"人工复核",抽屉打开,冲突卡被塞进去,结果标记 resolved=False
  6. 台面右侧的账本实时追加一行:冲突 id、用了什么策略、谁赢了、用了哪些来源、置信度多少。

每步字幕文案:

读者可操作项:打开任意 Python 环境,构造三个来源的 cust-001 记录,跑一遍 ConflictDetector().detect_value_conflictsConflictResolver().resolve_conflicts,分别传 "voting""credibility_weighted""manual_review",对比 ResolutionResult.confidenceresolved 三个字段的差异。

沙盘里的数据流用一张时序图收敛成五步:

sequenceDiagram participant U as 读者 participant D as ConflictDetector participant S as SourceTracker participant R as ConflictResolver U->>D: detect_value_conflicts 三来源实体 D->>S: track_property_source 逐条登记 D-->>U: 返回 Conflict 列表 U->>R: resolve_conflict 指定策略 R->>S: get_source_credibility 查权威分 R-->>U: 返回 ResolutionResult 带 sources_used

逻辑轨迹面板(右侧是真实行号):

text
按 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_usedResolutionResult。两者之间用数据对象衔接,任何一侧都能独立替换。这个拆分不依赖 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。

思考题

  1. 动手验证:打开 semantica/conflicts/conflict_resolver.py,把 _resolve_by_recency 第 453 行的 if timestamp: 改成 if not timestamp: raise ...,然后构造一个所有来源都没有 metadata.timestamp 的三来源冲突,跑 MOST_RECENT 策略。观察抛错的位置和原来的静默退化路径有什么区别,再想想生产里更合适的改法是把 resolved 置 False 还是抛异常。

  2. 概念题:Credibility scoreConfidence score 都是 0 到 1 的浮点数,都参与加权,为什么文档坚持把它们定义成两个概念?结合 conflict_resolver.py_resolve_by_credibility 的计算过程说明,如果这两个数被合并成一个字段,审计时丢掉了什么信息。

  3. 设计题:GraphRAG 用 description=list 把冲突描述全部保留,Semantica 用 resolved_value 挑一个赢家。各在什么前提下成立?如果 Semantica 的某个属性改用 GraphRAG 式的"全保留",下游查询要做什么改动才能不退化回"先到先得"?

  4. 边界题:detect_value_conflicts 第 271 行用 str(v) 字符串化后判唯一,但 _resolve_by_voting 第 365 行直接用原值做 Counter 键。给一个具体输入让这个冲突穿过检测层、在消解层炸掉,并说明修复应该落在哪一层。