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

第 8 章

推理与溯源

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

场景还原

一家券商的风控团队把供应商关系抽进知识图谱,然后写了一条规则:供应商给高风险客户供货,就自动升为高风险供应商。系统跑出结论「DELTA-3 是高风险供应商」,采购部门据此冻结了它的订单。

三个月后审计来了。审计员问:这个结论谁推导的、用了哪些事实、按哪条规则、原始材料在哪里。团队翻遍日志,只找到一句「forward_chain 推导出 HighRiskSupplier(DELTA-3)」。更糟的是,运维上个月清理数据库时手滑删了一行溯源记录,没人发现,因为没有任何机制能察觉「少了一行」。

这就是本章的两个核心问题:推理结论的可信度靠什么保证,以及溯源记录凭什么在「被改、被删」之后还能被查出来。Semantica 的回答分成两半:推理层用确定性引擎保证结论可复现,溯源层用 W3C PROV-O 加哈希链保证记录可审计。两半之间还有一条裂缝,本章末尾会点出来。

逐行精读

先看推理层的两条确定性路线。Datalog 负责递归可达性,Rete 负责大规模规则匹配。两者都不碰 LLM。

Datalog:半朴素不动点

semantica/reasoning/datalog_reasoner.py18:22
18@dataclass(frozen=True)19class DatalogFact:20    """Represents a ground truth fact."""21    predicate: str22    args: Tuple[str, ...]

DatalogFact 是不可变事实,谓词加参数元组。变量用大写字母开头区分常量,这个约定贯穿整个解析器。事实「supplied(delta3, gamma7)」和规则「reaches(X, Y) :- supplied(X, Z), reaches(Z, Y)」都落进这个结构。核心的推导循环在 derive_all

semantica/reasoning/datalog_reasoner.py242:293
242    def derive_all(self) -> List[str]:243        """244        Executes bottom-up semi-naive evaluation until fixpoint is reached.245        Returns a list of all derived facts as strings.246        """247        if self._derived:248            return [f"{f.predicate}({', '.join(f.args)})" for f in self._all_facts]249250        tracking_id = self.progress_tracker.start_tracking(251            module="reasoning",252            submodule="DatalogReasoner",253            message="Starting semi-naive fixpoint evaluation"254        )255256        iteration = 0257        newly_derived_count = 0258259        try:260            self._delta_new = self._all_facts.copy()261262            while self._delta_new:263                iteration += 1264265                # Shift deltas266                self._delta_old = self._delta_new267                self._delta_new = set()268269                delta_index = defaultdict(set)270                for f in self._delta_old:271                    delta_index[f.predicate].add(f)272273                for rule in self._rules:274                    new_facts = self._apply_rule(rule, delta_index)275276                    for fact in new_facts:277                        if fact not in self._all_facts:278                            self._delta_new.add(fact)279                            self._all_facts.add(fact)280                            self._fact_index[fact.predicate].add(fact)281                            newly_derived_count += 1282283                self.logger.debug(f"Datalog Iteration {iteration}: derived {len(self._delta_new)} new facts")284285            self._derived = True286        finally:287            self.progress_tracker.stop_tracking(288                tracking_id,289                status="completed",290                message=f"Fixpoint reached in {iteration} iterations. {newly_derived_count} new facts derived."291            )292293        return [f"{f.predicate}({', '.join(f.args)})" for f in self._all_facts]

这里的关键是「半朴素」优化:每轮只把上一轮新产生的事实(_delta_old)拿来套规则,已推导过的事实全量留在 _all_facts 里,所以递归规则不会每轮都从头算一遍。循环终止条件是 _delta_new 为空,有限图必然收敛。_derived 标志位做了记忆化,第二次调用直接返回缓存,因为事实集不会变。

_apply_rule 是半朴素的内核:evaluation_paths = range(len(rule.body)) 让规则体的每个位置轮流当「delta 位置」,该位置的事实只从 delta_index 里取,其余位置从 _fact_index 全量取。这样一条递归规则 reaches(X, Y) :- supplied(X, Z), reaches(Z, Y) 无论新事实落在 supplied 还是 reaches,都能被新组合覆盖。_unify 做逐项合一:变量首字母大写,常量必须相等,变量绑定冲突则返回 None。整个过程纯内存集合运算,不碰数据库、不碰 LLM。

flowchart TD A[delta_new 初始化为全部事实] --> B{delta_new 非空} B -->|是| C[delta_old 接替 delta_new\n清空 delta_new] C --> D[对每条规则调用 apply_rule] D --> E[新事实写入 all_facts 与 delta_new] E --> B B -->|否| F[标记 derived\n返回全部事实]

配套的 add_fact 接受字符串或字典两种输入:字符串走 _parse_fact_string,字典则按 subject/predicate/objectsource/targettype/id 三种形状分别转成 DatalogFact,谓词和参数统一 lower(),避免图里的大写实体名撞上「大写即变量」的约定。query?var 或大写变量两种写法查已推导事实,内部把 ?actor 规范成首字母大写再走统一化。load_from_graphContextGraph 的边和节点直接灌进来,边转二元谓词、节点转一元类型谓词。这三件套让 Datalog 引擎能直接吃图谱数据,输入输出都走同一个事实结构。

Rete:alpha 与 beta 节点

Rete 面向的场景和 Datalog 相反:规则多、事实增量到达。它和上面那位共享同一套事实与规则数据结构,定义在 reasoner.py

semantica/reasoning/reasoner.py25:46
25class Rule:26    """Simplified rule definition."""27    rule_id: str28    name: str29    conditions: List[Any]30    conclusion: Any31    rule_type: RuleType = RuleType.IMPLICATION32    confidence: float = 1.033    priority: int = 034    handler: Optional[Callable] = None35    metadata: Dict[str, Any] = field(default_factory=dict)3637@dataclass38class Fact:39    """Simple fact representation."""40    fact_id: str41    predicate: str42    arguments: List[Any]43    metadata: Dict[str, Any] = field(default_factory=dict)4445    def __str__(self) -> str:46        return f"{self.predicate}({', '.join(map(str, self.arguments))})"

Rule 把条件列表和结论分开,confidencepriority 决定多条规则冲突时谁先谁后;Fact 是谓词加参数列表,__str__ 把它拼回 Person(John) 这样的字符串。ReteEngine 直接复用这个 RuleFact,没有另起炉灶。

Rete 的三种节点构成一条匹配流水线。先看 alpha 节点,它对单个条件做匹配:

semantica/reasoning/rete_engine.py64:82
64class AlphaNode(ReteNode):65    """Alpha node for single condition matching."""6667    def __init__(self, node_id: str, condition: Any):68        super().__init__(node_id)69        self.condition = condition70        self.matches: List[Fact] = []7172    def add_fact(self, fact: Fact) -> bool:73        """Add fact if it matches condition."""74        if self._matches(fact):75            self.matches.append(fact)76            return True77        return False7879    def _matches(self, fact: Fact) -> bool:80        """Check if fact matches condition."""81        # Simple matching - can be enhanced82        return True

注意 _matches 的返回是硬编码 True,注释写着「Simple matching - can be enhanced」。这是一个未完成的桩,任何事实进入任何 alpha 节点都会被收进 matches。beta 节点同样:

semantica/reasoning/rete_engine.py85:104
85class BetaNode(ReteNode):86    """Beta node for joining conditions."""8788    def __init__(self, node_id: str, left: ReteNode, right: ReteNode):89        super().__init__(node_id)90        self.left = left91        self.right = right92        self.matches: List[Tuple[Fact, Fact]] = []9394    def join(self, left_fact: Fact, right_fact: Fact) -> bool:95        """Join facts from left and right nodes."""96        if self._can_join(left_fact, right_fact):97            self.matches.append((left_fact, right_fact))98            return True99        return False100101    def _can_join(self, left_fact: Fact, right_fact: Fact) -> bool:102        """Check if facts can be joined."""103        # Simple join logic - can be enhanced104        return True

_can_join 同样恒真。terminal 节点负责收集激活:

semantica/reasoning/rete_engine.py107:117
107class TerminalNode(ReteNode):108    """Terminal node representing rule activation."""109110    def __init__(self, node_id: str, rule: Rule):111        super().__init__(node_id)112        self.rule = rule113        self.activations: List[Match] = []114115    def activate(self, match: Match) -> None:116        """Activate rule."""117        self.activations.append(match)

网络怎么搭起来,看 _add_rule_to_network

semantica/reasoning/rete_engine.py192:222
192    def _add_rule_to_network(self, rule: Rule) -> None:193        """Add rule to Rete network."""194        # Create alpha nodes for each condition195        alpha_nodes = []196        for condition in rule.conditions:197            node_id = f"alpha_{self.node_counter}"198            self.node_counter += 1199            alpha_node = AlphaNode(node_id, condition)200            alpha_nodes.append(alpha_node)201            self.network[node_id] = alpha_node202203        # Create beta nodes for joining204        if len(alpha_nodes) > 1:205            current = alpha_nodes[0]206            for i in range(1, len(alpha_nodes)):207                node_id = f"beta_{self.node_counter}"208                self.node_counter += 1209                beta_node = BetaNode(node_id, current, alpha_nodes[i])210                self.network[node_id] = beta_node211                current = beta_node212            final_node = current213        else:214            final_node = alpha_nodes[0] if alpha_nodes else None215216        # Create terminal node217        if final_node:218            node_id = f"terminal_{self.node_counter}"219            self.node_counter += 1220            terminal_node = TerminalNode(node_id, rule)221            final_node.children.append(terminal_node)222            self.network[node_id] = terminal_node

一条规则按条件拆成若干个 alpha 节点,相邻两个之间串一个 beta 节点做 join,最后挂一个 terminal 节点。事实到达后的传播路径在 _propagate_from_alpha

semantica/reasoning/rete_engine.py245:264
245    def _propagate_from_alpha(self, alpha_node: AlphaNode, fact: Fact) -> None:246        """Propagate from alpha node to children."""247        for child in alpha_node.children:248            if isinstance(child, BetaNode):249                # Join with matches from left side250                for left_fact in alpha_node.matches:251                    if child.join(left_fact, fact):252                        # Propagate to children253                        for grandchild in child.children:254                            if isinstance(grandchild, TerminalNode):255                                match = Match(256                                    rule=grandchild.rule,257                                    facts=[left_fact, fact],258                                    confidence=1.0,259                                )260                                grandchild.activate(match)261            elif isinstance(child, TerminalNode):262                # Direct activation263                match = Match(rule=child.rule, facts=[fact], confidence=1.0)264                child.activate(match)

这里能看到一个边界特征:join 用的左表是 alpha_node.matches 的当前快照,也就是「到此刻为止已经进入该 alpha 节点的事实」。因为 _can_join 恒真,所以新事实只要到达,就会和左表里所有历史事实配对,产生一堆 Match。这套网络是「编译一次、增量传播」的结构,代价与新增激活数成正比,规则集再大也不每回重扫。

sequenceDiagram participant W as 工作内存 participant A as Alpha 节点 participant B as Beta 节点 participant T as Terminal 节点 W->>A: add_fact 传入事实 A->>A: _matches 恒真收进 matches A->>B: join 左表快照与新事实 B->>T: 生成 Match 并 activate T->>T: activations 累积

溯源层:ProvenanceManager

推理层算出结论,可信度的一半靠「确定性、可复现」,另一半靠溯源。溯源层的入口是 _save_entry,它负责把每条记录写进哈希链:

semantica/provenance/manager.py154:192
154    def _save_entry(155        self,156        entry: ProvenanceEntry,157        _conn: Optional[Any] = None,158        _raise_on_error: bool = False,159    ) -> Optional[ProvenanceEntry]:160        """Compute checksum, store entry persistently, and log/handle storage errors (#783)."""161        # Hash-chain linkage (issue #825, Part A item 2): link this entry to162        # the previous entry in global insertion order before hashing, so163        # deleting a row later breaks the chain for whatever followed it.164        try:165            head = self.storage.get_chain_head(_conn)166        except Exception:167            head = None168        entry.sequence_id = (head[0] + 1) if head else 1169        entry.previous_checksum = head[1] if head else None170171        entry.checksum = compute_checksum(entry)172173        try:174            if _conn is not None:175                self.storage._store_with_conn(_conn, entry)176            else:177                self.storage.store(entry)178        except Exception as e:179            self.logger.error(180                "Failed to save provenance entry for entity '%s': %s",181                entry.entity_id,182                e,183                exc_info=True,184            )185            # Propagate when called from a batch's shared transaction so the186            # caller's per-item try/except can skip counting this item instead187            # of reporting an unpersisted entry as tracked (#807).188            if _raise_on_error:189                raise190            return None  # Graceful failure - don't return unpersisted entry (#783)191192        return entry

写入前先取链头,把「上一条的校验和」存进本条,再算本条校验和。这样每条记录都链着前一条,删除中间任何一行,后一条的 previous_checksum 就指到了一个不存在的东西,链断掉了。校验和本身怎么算,在 integrity.py

semantica/provenance/integrity.py74:94
74    # Concatenate critical fields for checksum (entity_id intentionally excluded, see docstring)75    if isinstance(entry, dict):76        used_entities = entry.get("used_entities") or []77        data = (78            f"{entry.get('entity_type') or ''}"79            f"{entry.get('activity_id') or ''}"80            f"{entry.get('agent_id') or ''}"81            f"{entry.get('agent_type') or ''}"82            f"{entry.get('source_document') or ''}"83            f"{entry.get('timestamp') or ''}"84            f"{entry.get('confidence') if entry.get('confidence') is not None else 1.0}"85            f"{entry.get('parent_entity_id') or ''}"86            f"{entry.get('previous_version_id') or ''}"87            f"{entry.get('derived_from_id') or ''}"88            f"{','.join(used_entities)}"89            f"{entry.get('previous_checksum') or ''}"90            f"{bool(entry.get('invalidated'))}"91            f"{entry.get('invalidated_at_time') or ''}"92            f"{entry.get('invalidated_by') or ''}"93            f"{entry.get('invalidation_reason') or ''}"94        )

注意 entity_id 被刻意排除在哈希之外。这是为了支持版本归档:老版本会被复制成一个新 id,比如 X 变成 X:v:...,如果 id 参与哈希,这个纯改名操作就会改掉校验和,把合法的归档误判成篡改。这个取舍在本章设计决策里再展开。

版本与推导的两条链

track_entity 里,同一个 entity_id 第二次被追踪时,会先把旧记录归档:

semantica/provenance/manager.py329:339
329                archived_history_id = None330                if existing:331                    history_entry = copy.deepcopy(existing)332                    base_history_id = f"{entity_id}:v:{existing.last_updated}"333                    history_id = base_history_id334                    counter = 1335                    while self.storage._retrieve_with_conn(conn, history_id):336                        history_id = f"{base_history_id}:{counter}"337                        counter += 1338339                    history_entry.entity_id = history_id

归档条目只改 entity_id,校验和与序号原样保留。然后新条目区分两种链接:修正同一条事实还是从别的实体推导:

semantica/provenance/manager.py394:396
394                entry.previous_version_id = archived_history_id395                if explicit_parent_supplied:396                    entry.derived_from_id = parent_id

previous_version_id 表示「这条修正了同一条事实的旧版本」,derived_from_id 表示「这条是从另一个实体推导出来的」。两者都同时存在时,归档 id 也会被追加进 used_entities。这套区分把「改版」和「溯源推导」拆开,后面导出 PROV-O 时各映射到不同的三元组。

stateDiagram-v2 [*] --> tracked: 首次 track_entity tracked --> archived: 再次 track_entity 同 id tracked --> invalidated: invalidate archived --> tracked: 归档成为父节点 invalidated --> [*]: 墓碑保留可查询

删除不硬删,用墓碑。invalidate 先把失效前状态归档,再写一条 invalidated 标记的记录:

semantica/provenance/manager.py1093:1102
1093            entry = copy.deepcopy(existing)1094            entry.invalidated = True1095            entry.invalidated_at_time = utc_now_iso()1096            entry.invalidated_by = agent_id1097            entry.invalidation_reason = reason1098            entry.previous_version_id = history_id1099            if metadata:1100                entry.metadata = {**entry.metadata, **metadata}11011102            self._save_entry(entry, _conn=conn, _raise_on_error=True)

审计要回答「这条被谁、何时、为什么撤销」,这四个字段就是答案。

PROV-O 导出

溯源数据最终要能导出成标准 RDF。export_prov 把内部字段映射成 W3C PROV-O 三元组:

semantica/provenance/manager.py1256:1299
1256        PROV = Namespace("http://www.w3.org/ns/prov#")1257        EX = Namespace(base_uri or DEFAULT_BASE_URI)12581259        g = Graph()1260        g.bind("prov", PROV)1261        g.bind("ex", EX)12621263        def uri(entity_id: Any) -> URIRef:1264            return URIRef(EX[str(entity_id)])12651266        for e in self.storage.retrieve_all():1267            ent_uri = uri(e.entity_id)1268            g.add((ent_uri, RDF.type, PROV.Entity))12691270            if getattr(e, "timestamp", None):1271                g.add(1272                    (1273                        ent_uri,1274                        PROV.generatedAtTime,1275                        Literal(e.timestamp, datatype=XSD.dateTime),1276                    )1277                )12781279            ag_uri = None1280            if getattr(e, "agent_id", None) and e.agent_id != "unknown":1281                ag_uri = uri(e.agent_id)1282                g.add((ag_uri, RDF.type, PROV.Agent))1283                prov_subclass = self._AGENT_TYPE_PROV_CLASS.get(1284                    getattr(e, "agent_type", None)1285                )1286                if prov_subclass:1287                    g.add((ag_uri, RDF.type, PROV[prov_subclass]))1288                g.add((ent_uri, PROV.wasAttributedTo, ag_uri))12891290                # Qualified Association with hadRole (issue #825, Part A item1291                # 6): distinguishes "approved by" from "generated by" from1292                # "reviewed by" for the same agent/entity pair, which the1293                # plain wasAttributedTo triple above cannot express.1294                association = BNode()1295                g.add((ent_uri, PROV.qualifiedAssociation, association))1296                g.add((association, RDF.type, PROV.Association))1297                g.add((association, PROV.agent, ag_uri))1298                role = getattr(e, "role", None) or "generator"1299                g.add((association, PROV.hadRole, uri(f"role_{role}")))

每个实体映射成 prov:Entity,agent 映射成 prov:Agent,再按 agent_type 细分到 prov:Personprov:SoftwareAgentprov:Organization 子类。qualifiedAssociation 带上 hadRole,同一个 agent 对同一个实体可以同时有「生成者」「审批者」等不同角色。空节点 association 是 PROV-O 里标准做法:用合格关联表达「谁以什么身份参与了这件事」。

链校验

最后是 verify_chain,审计时跑它就能发现被删的行:

semantica/provenance/manager.py1477:1520
1477        entries = sorted(1478            (e for e in self.storage.retrieve_all() if e.sequence_id is not None),1479            key=lambda e: e.sequence_id,1480        )14811482        broken_links: List[Dict[str, Any]] = []1483        expected_previous: Optional[str] = None1484        expected_sequence: Optional[int] = None1485        for entry in entries:1486            if not verify_checksum(entry):1487                broken_links.append({1488                    "entity_id": entry.entity_id,1489                    "sequence_id": entry.sequence_id,1490                    "reason": "checksum_mismatch",1491                })1492            else:1493                sequence_gap = (1494                    expected_sequence is not None1495                    and entry.sequence_id != expected_sequence + 11496                )1497                checksum_break = entry.previous_checksum != expected_previous1498                if sequence_gap or checksum_break:1499                    broken_links.append({1500                        "entity_id": entry.entity_id,1501                        "sequence_id": entry.sequence_id,1502                        "reason": "chain_break",1503                        "expected_previous_checksum": expected_previous,1504                        "actual_previous_checksum": entry.previous_checksum,1505                        "expected_sequence_id": (1506                            expected_sequence + 1 if expected_sequence is not None else None1507                        ),1508                    })15091510            # Advance state from this entry's own stored fields regardless of1511            # whether it was flagged above, so a single corrupted entry1512            # doesn't cascade into spurious breaks for every entry after it.1513            expected_previous = entry.checksum1514            expected_sequence = entry.sequence_id15151516        return {1517            "valid": len(broken_links) == 0,1518            "total_entries": len(entries),1519            "broken_links": broken_links,1520        }

sequence_id 排序后逐条比对两件事:本条校验和是否与内容一致,本条的 previous_checksum 是否等于上一条的校验和。序号跳号也算断裂。注意循环末尾无条件推进期望值,单条损坏不会把后面的每条都误报成链断。

溯源查询是双向的。正向 get_lineageentity_id 出发,沿 parent_entity_idused_entities 做 BFS 一路往上找到源头,返回的 lineage_chain 里第一条永远是查询对象本身,祖先按深度排后。反向 trace_descendants 回答「某个实体错了,下游哪些事实用了它」这种事故复盘问题,它沿 parent_entity_idprevious_version_idderived_from_idused_entities 建反向邻接表再 BFS。审计问「从哪来」用前者,问「影响面多大」用后者。这两条路共用同一份存储,只是遍历方向相反。

裂缝:推理与溯源没接上

前面两半各自完整,但它们之间几乎没连。推理模块里唯一的溯源包装器是 reasoning_provenance.py,它的初始化直接导入一个不存在的类:

semantica/reasoning/reasoning_provenance.py24:44
24    def __init__(25        self,26        provenance: bool = False,27        agent_id: Optional[str] = None,28        is_automated: bool = True,29        **config,30    ):31        from .reasoning_engine import ReasoningEngine3233        self.provenance = provenance34        self._engine = ReasoningEngine(**config)35        self._prov_manager = None36        self._agent_id = agent_id or self.__class__.__name__37        self._is_automated = is_automated3839        if provenance:40            try:41                from semantica.provenance import ProvenanceManager42                self._prov_manager = ProvenanceManager()43            except ImportError:44                self.provenance = False

semantica/reasoning/ 目录下只有 reasoner.py,没有 reasoning_engine.py,全仓也没有 class ReasoningEngine。所以这个包装器一旦实例化就会在 from .reasoning_engine import ReasoningEngineImportError,且它没被 reasoning/__init__.py 导出。结论是:推理引擎本身不会自动写溯源,Datalog、Rete、Reasoner 里没有任何 track_entity 调用。推理结论要进溯源,得由调用方自己拿结论去调 ProvenanceManager.track_entity。这条裂缝是本章最重要的工程事实。

设计决策分析

为什么推理层优先确定性引擎。 项目自己的指南把推理和检索区分得很清楚:

docs/guides/reasoning.md12:12
12**Reasoning vs. Retrieval:** Retrieval finds existing information that matches your query. Reasoning applies logical rules to derive new facts that weren't explicitly stored.

推导出来的事实必须能复现:同样的图、同样的规则,跑一万遍结论一样。Datalog 的半朴素不动点、Rete 的固定网络都是确定性算法,不引入采样温度。LLM 路线在 GraphReasoner.reason 里只是把图转成文本再让模型生成回答,返回值是字符串,没有结构化结论,也没法逐条回溯。指南里也写明「For reproducible, auditable decisions, use Reasoner or DatalogReasoner instead」。这是推断:作者把可审计性排在了 LLM 的自由度前面。

PROV-O 的采用成本与收益。 收益是互操作:导出成标准 RDF 后,外部工具能直接消费审计数据,不用读私有 schema。成本在 export_prov 里体现得很具体:实体、agent、activity 三种角色要分别发三元组,qualified 形式还要再发一遍空节点结构,代码量不小。指南也提醒溯源不担保真相:

docs/guides/provenance.md326:326
326**Provenance does not guarantee truth.** Provenance records faithfully track where information came from and how it was processed, but it cannot verify that the original sources were accurate. A perfectly documented chain from a flawed or malicious source still produces unreliable data.

所以溯源解决的是「这个结论能指回来源」,不解决「来源本身对不对」。

哈希链为什么要把 previous_checksum 存进本条再哈希。 单条记录的 SHA-256 只能证明「这一行没被改」,证明不了「少了一行」。把前一条的校验和并进本条哈希,删掉一行就会让后一条算出来的哈希和存的值对不上,verify_chain 就能报断裂。这是推断,代码注释里直接写着「deleting a row later breaks the chain for whatever followed it」。

版本归档为什么是纯改名。 归档时老记录深拷贝后只改 entity_id,校验和、sequence_idprevious_checksum 全部原样保留。若重算校验和或重新分配序号,要么让后来链着它的记录断链,要么在链上留一个假缺口。compute_checksum 排除 entity_id 正是为这个设计服务的。

版本与推导为什么要拆成两条字段。 previous_version_idderived_from_id 语义不同:前者回答「这条事实被修正过,旧版本是哪条」,后者回答「这条结论是从别的实体推出来的」。审计时问的问题不一样,前者对应「这个值改过几次」,后者对应「这个结论的前提链在哪」。若都塞进一个 parent_entity_id,两种追问就混在一起,导出 PROV-O 时也没法把修正关系和推导关系分开映射。compute_checksum 排除 entity_id 的取舍,正是为了让归档的纯改名不触发链断裂,这条决策链前后是咬合的。

为什么推理层几乎不写溯源。 这是本章反复强调的裂缝:reasoning_provenance.py 引用了一个不存在的 ReasoningEngine,而 ReasonerDatalogReasonerReteEngine 内部都没有任何 track_entity 调用。推断的原因是,溯源被设计成一条独立于业务逻辑的旁路,各模块通过包装器按需接入,而推理模块的包装器恰好没写完、也没被导出。这解释了为什么「图上的推理结论凭什么可信」在实际代码里要分两半回答:确定性保证结论可复现,溯源需要调用方自己补。

边界条件剖析

如果同一个 entity_id 第二次被 track_entity 进入 if existing: 分支,旧记录被深拷贝并改名成 {entity_id}:v:{existing.last_updated},若该 id 已存在则追加 :{counter} 直到唯一,见 manager.py 329-337 行的 while 循环。新条目写入时,previous_version_id 指向归档 id,parent_entity_id 在无显式父节点时也指向归档 id。结果是:当前值永远在 entity_id 名下,历史版本散落在带 :v: 后缀的 id 里。

如果有人在 SQLite 里直接 DELETE 掉一行。 verify_chainsequence_id 排序后会发现两个信号:被删行后面那条的 previous_checksum 对不上前一条的校验和,且 sequence_id 出现跳号。两种情况都落进 reason: "chain_break" 分支,见 manager.py 1493-1508 行。单行被原地改字段则是 verify_checksumchecksum_mismatch。删除和篡改都能被区分开。

如果给 Datalog 喂一个以大写开头的常量事实。 解析层把大写首字母一律当变量,add_fact 会显式拒绝:

semantica/reasoning/datalog_reasoner.py105:110
105            if parsed_fact:106                for arg in parsed_fact.args:107                    if not arg:108                        raise ValueError("Facts cannot contain empty arguments")109                    if arg[0].isupper():110                        raise ValueError(f"Facts must be constants only. Found variable '{arg}' in {fact}")

所以从图加载时,load_from_graph 里的谓词和参数都被 lower() 过,避免真实实体名撞上变量约定。

如果一条双条件规则,第二个条件的事实先到达。 事实传播到 alpha 节点时,_propagate_from_alphaalpha_node.matches 当前快照做左表去 join。第一个 alpha 节点还没有匹配时,新事实到达第二个 alpha 节点,join 左表为空,不产生激活;等第一个条件的事实到达,它作为新事实和已有事实配对,此时才激活。所以激活顺序取决于事实到达顺序,结论集合最终一致,因为 _matches_can_join 恒真,任何顺序最终都会两两配对。

如果批量追踪时中间一条写失败。 track_entities_batch 按每 1000 条一批开启事务,批内每条再用 savepoint 包一层:单条失败会回滚到该条之前的保存点,except Exception: pass 后继续下一条,不拖垮整批。批事务成功提交后才把 batch_count 累加进返回值,中途块级失败则记录日志不累加。所以「追踪了多少条」这个数字是「实际落库并提交」的数量,失败条目静默跳过,调用方不会拿到一个虚高的计数。

横向对比

GraphRAG 既没有推理引擎,也没有溯源层。在它整个 packages/graphrag/graphrag 目录里检索 datalogreteforward chainingprovenance,均无匹配。它回答问题的可信度靠另一个机制:把检索到的原文片段塞进提示词,让模型在回答里引用片段 id。

上下文构建端,build_text_unit_context 把文本单元渲染成一张带 idtext 两列的表:

packages/graphrag/graphrag/query/context_builder/source_context.py39:52
39    # add context header40    current_context_text = f"-----{context_name}-----" + "\n"4142    # add header43    header = ["id", "text"]44    attribute_cols = (45        list(text_units[0].attributes.keys()) if text_units[0].attributes else []46    )47    attribute_cols = [col for col in attribute_cols if col not in header]48    header.extend(attribute_cols)4950    current_context_text += column_delimiter.join(header) + "\n"51    current_tokens = tokenizer.num_tokens(current_context_text)52    all_context_records = [header]

提示词端要求模型把每句论断挂上数据引用:

packages/graphrag/graphrag/prompts/query/local_search_system_prompt.py18:28
18Points supported by data should list their data references as follows:1920"This is an example sentence supported by multiple data references [Data: <dataset name> (record ids); <dataset name> (record ids)]."2122Do not list more than 5 record ids in a single reference. Instead, list the top 5 most relevant record ids and add "+more" to indicate that there are more.2324For example:2526"Person X is the owner of Company Y and subject to many allegations of wrongdoing [Data: Sources (15, 16), Reports (1), Entities (5, 7); Relationships (23); Claims (2, 7, 34, 46, 64, +more)]."2728where 15, 16, 1, 5, 7, 23, 2, 7, 34, 46, and 64 represent the id (not the index) of the relevant data record.

这就是它的「溯源」形态:[Data: Sources (15, 16)] 这种内联引用,指向的是被塞进上下文窗口的原文片段 id,而不是一条独立的、可校验、可链式的溯源记录。

它可以没有这两层,因为它的定位是检索式问答:输入是单一语料,答案由 LLM 基于窗口内的片段生成,引用片段 id 已经足以回答「这句话来自哪段原文」。它补不上的场景正是 Semantica 面向的:多源事实冲突需要按来源权威度消解、推导出的结论需要指回规则和前提、审计需要证明「少了一行」。这些在 GraphRAG 里没有对应物,检索关键词 datalogreteprovenancechecksum 均无结果。代价是:它回答「为什么是这个结论」时只能指回检索片段,不能证明推导过程。

两边的可信度形态也不同。GraphRAG 的引用是「过程内」的:片段 id 只在这一次 prompt 往返里有意义,会话结束,引用就失去可查询的载体。Semantica 的溯源是「过程外」的:ProvenanceEntry 落进存储,带上校验和和序号,跨进程、跨时间都还能被 verify_chain 重新校验。前者便宜,因为不持久化任何东西;后者贵,但换来了「三个月后审计还能查」的能力。选择哪一边,取决于结论的保质期和问责强度,两条路线各有各的适用面。

互动演示设计

演示形态是格式实验台:读者在一侧输入 Datalog 事实和规则,另一侧实时看到推导结果,以及每条新事实对应的溯源链如何长出来。

一句话结论: 确定性引擎产出可复现的结论,溯源层把这些结论变成一条能被审计、被导出、被校验的链条。

舞台元素与比喻: 把推导看成一条流水线。事实是「原料」,Datalog 规则是「配方」,derive_all 是不动点搅拌机,ProvenanceManager 是给每批成品贴的防伪标签,标签之间用链条串着,撕掉一张就会被发现。实验台左侧是原料和配方,中间是搅拌机,右侧是标签墙,墙上一行标签就是一条溯源记录。

分步动画:

  1. 面板出现两个输入框:事实框和规则框。事实框预填 supplied(delta3, gamma7)supplied(gamma7, apt29),规则框预填 reaches(X, Y) :- supplied(X, Y). 和递归句。
  2. 点击「推导」,搅拌机开始转:第一轮 delta 从两个事实出发,套规则产出 reaches(delta3, gamma7)reaches(gamma7, apt29)
  3. 第二轮用新事实再套,产出 reaches(delta3, apt29),delta 为空,停止。右侧逐行亮起 derive_all 的 while 循环。
  4. 每产出一条结论,左侧自动调 track_entity,溯源面板多一行,sequence_id 递增,previous_checksum 指向上一行。
  5. 读者点「删除中间一行」,再点「校验」,verify_chain 面板报 chain_break,并高亮断点前后两条。

每步字幕文案:

读者可操作项: 改规则为只保留非递归句,观察 reaches 只产出直接边;给事实加一个大写开头参数,观察 ValueError: Facts must be constants only;把 export_prov 的输出粘贴到 RDF 查看器,观察 prov:Entityprov:qualifiedAssociation 三元组。

逻辑轨迹面板伪代码(右侧标真实行号):

text
delta_new = all_facts.copy()          # datalog_reasoner.py:260
while delta_new:                       # datalog_reasoner.py:262
    delta_old, delta_new = delta_new, set()   # 265-267
    for rule in rules: new = apply_rule(rule, delta_old)  # 273-274
    for f in new: all_facts.add(f)     # 276-280
track_entity(conclusion, source)       # manager.py:268 起
    head = storage.get_chain_head()    # manager.py:165
    entry.previous_checksum = head[1]  # manager.py:170
    entry.checksum = compute_checksum(entry)  # manager.py:172
verify_chain()                         # manager.py:1450 起

可迁移结论

值得抄的: 哈希链是最便宜的抗删除审计。任何「只能追加、不许删改」的日志系统都能照搬这个模式:每条记录存「上一条的哈希」和「自增序号」,校验时看两样,序号跳号或前链断裂就是删除。这套逻辑不依赖 Python,SQL、Go、任何有哈希函数的语言都能实现。

最小成本形态: 如果只想做溯源、不想要 PROV-O 全套,可以只留三个字段:entity_idsource_documentprevious_checksum。前两个回答「从哪来」,第三个回答「没被删改」。compute_checksum 里那串字段拼接可以按业务裁减,但「排除主键 id、纳入前链哈希」这两个取舍要保留,否则版本归档会误报。

哪些是过度设计: ProvenanceManageragent_typeroleacted_on_behalf_ofinformed_by_activitiesbundle_id 这些 PROV-O 细化字段,单团队小项目里多数用不上。export_prov 里 qualified 系列的空节点结构,只有在需要「同一 agent 同一实体多种角色」时才值得。Rete 引擎的 alpha/beta 网络也是过度设计:_matches_can_join 是恒真桩,网络结构搭好了但匹配逻辑没实现,当前用它等于花建网的成本换一个「全连接」结果,直接用 Reasoner.forward_chain 更实在。

两条路线各自的最小形态可以更省。 Datalog 那条,若只做可达性、不做通用规则,一个递归查询加一个 visited 集合就能顶替 derive_all 的半朴素循环。溯源那条,若不需要标准互操作,去掉 export_prov,只留 sequence_idprevious_checksumchecksum 三个字段和 verify_chain 一个方法,就已经是完整的抗删除审计。反过来,若一开始就上 PROV-O 全量字段加 SQLite 迁移列加哈希链,单原型阶段只会拖慢写代码的速度。抄的时候先问一句:审计的对手是谁,是误删还是恶意篡改,是内部看还是外部监管看。答案不同,最小形态就不同。

思考题

  1. compute_checksum 排除了 entity_id,这带来一个安全后果:攻击者可以把一行的 entity_id 换成另一个,只要其余字段不变,单行校验和仍通过。verify_chain 靠什么机制间接抓住这种「换名」?结合 manager.py 1450 行的注释说明。

  2. ReasoningEngineWithProvenance 引用了不存在的 reasoning_engine.ReasoningEngine。如果要把推理结论真正接进溯源,最小改动是改哪个文件、在哪一行加什么调用?指出 reasoning_provenance.py 需要替换的导入和被 infer 包装的引擎。

  3. 动手验证:在 semantica 仓库根目录起一个 Python 交互环境,运行下面这段,观察 Datalog 递归推导和溯源链校验:

python
from semantica.reasoning import DatalogReasoner
dl = DatalogReasoner()
dl.add_fact("supplied(delta3, gamma7)")
dl.add_fact("supplied(gamma7, apt29)")
dl.add_rule("reaches(X, Y) :- supplied(X, Y).")
dl.add_rule("reaches(X, Y) :- supplied(X, Z), reaches(Z, Y).")
print(dl.derive_all())

改哪一行可以让 reaches 只输出直接边?再在 semantica/provenance/manager.pyProvenanceManager(storage_path="t.db") 追踪两个实体,手动 DELETE FROM provenance WHERE ... 一行,然后跑 verify_chain(),观察 broken_links 里出现的 reason。

  1. verify_chain 的循环里,为什么单条 checksum_mismatch 之后仍要推进 expected_previousexpected_sequence?如果改成立即 return,会出现什么误报?结合 1510-1514 行的注释回答。