第 8 章
推理与溯源
场景还原
一家券商的风控团队把供应商关系抽进知识图谱,然后写了一条规则:供应商给高风险客户供货,就自动升为高风险供应商。系统跑出结论「DELTA-3 是高风险供应商」,采购部门据此冻结了它的订单。
三个月后审计来了。审计员问:这个结论谁推导的、用了哪些事实、按哪条规则、原始材料在哪里。团队翻遍日志,只找到一句「forward_chain 推导出 HighRiskSupplier(DELTA-3)」。更糟的是,运维上个月清理数据库时手滑删了一行溯源记录,没人发现,因为没有任何机制能察觉「少了一行」。
这就是本章的两个核心问题:推理结论的可信度靠什么保证,以及溯源记录凭什么在「被改、被删」之后还能被查出来。Semantica 的回答分成两半:推理层用确定性引擎保证结论可复现,溯源层用 W3C PROV-O 加哈希链保证记录可审计。两半之间还有一条裂缝,本章末尾会点出来。
逐行精读
先看推理层的两条确定性路线。Datalog 负责递归可达性,Rete 负责大规模规则匹配。两者都不碰 LLM。
Datalog:半朴素不动点
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:
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。
配套的 add_fact 接受字符串或字典两种输入:字符串走 _parse_fact_string,字典则按 subject/predicate/object、source/target、type/id 三种形状分别转成 DatalogFact,谓词和参数统一 lower(),避免图里的大写实体名撞上「大写即变量」的约定。query 用 ?var 或大写变量两种写法查已推导事实,内部把 ?actor 规范成首字母大写再走统一化。load_from_graph 把 ContextGraph 的边和节点直接灌进来,边转二元谓词、节点转一元类型谓词。这三件套让 Datalog 引擎能直接吃图谱数据,输入输出都走同一个事实结构。
Rete:alpha 与 beta 节点
Rete 面向的场景和 Datalog 相反:规则多、事实增量到达。它和上面那位共享同一套事实与规则数据结构,定义在 reasoner.py:
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 把条件列表和结论分开,confidence 和 priority 决定多条规则冲突时谁先谁后;Fact 是谓词加参数列表,__str__ 把它拼回 Person(John) 这样的字符串。ReteEngine 直接复用这个 Rule 和 Fact,没有另起炉灶。
Rete 的三种节点构成一条匹配流水线。先看 alpha 节点,它对单个条件做匹配:
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 节点同样:
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 节点负责收集激活:
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:
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:
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。这套网络是「编译一次、增量传播」的结构,代价与新增激活数成正比,规则集再大也不每回重扫。
溯源层:ProvenanceManager
推理层算出结论,可信度的一半靠「确定性、可复现」,另一半靠溯源。溯源层的入口是 _save_entry,它负责把每条记录写进哈希链:
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:
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 第二次被追踪时,会先把旧记录归档:
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,校验和与序号原样保留。然后新条目区分两种链接:修正同一条事实还是从别的实体推导:
394 entry.previous_version_id = archived_history_id395 if explicit_parent_supplied:396 entry.derived_from_id = parent_idprevious_version_id 表示「这条修正了同一条事实的旧版本」,derived_from_id 表示「这条是从另一个实体推导出来的」。两者都同时存在时,归档 id 也会被追加进 used_entities。这套区分把「改版」和「溯源推导」拆开,后面导出 PROV-O 时各映射到不同的三元组。
删除不硬删,用墓碑。invalidate 先把失效前状态归档,再写一条 invalidated 标记的记录:
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 三元组:
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:Person、prov:SoftwareAgent、prov:Organization 子类。qualifiedAssociation 带上 hadRole,同一个 agent 对同一个实体可以同时有「生成者」「审批者」等不同角色。空节点 association 是 PROV-O 里标准做法:用合格关联表达「谁以什么身份参与了这件事」。
链校验
最后是 verify_chain,审计时跑它就能发现被删的行:
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_lineage 从 entity_id 出发,沿 parent_entity_id 和 used_entities 做 BFS 一路往上找到源头,返回的 lineage_chain 里第一条永远是查询对象本身,祖先按深度排后。反向 trace_descendants 回答「某个实体错了,下游哪些事实用了它」这种事故复盘问题,它沿 parent_entity_id、previous_version_id、derived_from_id、used_entities 建反向邻接表再 BFS。审计问「从哪来」用前者,问「影响面多大」用后者。这两条路共用同一份存储,只是遍历方向相反。
裂缝:推理与溯源没接上
前面两半各自完整,但它们之间几乎没连。推理模块里唯一的溯源包装器是 reasoning_provenance.py,它的初始化直接导入一个不存在的类:
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 ReasoningEngine 抛 ImportError,且它没被 reasoning/__init__.py 导出。结论是:推理引擎本身不会自动写溯源,Datalog、Rete、Reasoner 里没有任何 track_entity 调用。推理结论要进溯源,得由调用方自己拿结论去调 ProvenanceManager.track_entity。这条裂缝是本章最重要的工程事实。
设计决策分析
为什么推理层优先确定性引擎。 项目自己的指南把推理和检索区分得很清楚:
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 形式还要再发一遍空节点结构,代码量不小。指南也提醒溯源不担保真相:
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_id、previous_checksum 全部原样保留。若重算校验和或重新分配序号,要么让后来链着它的记录断链,要么在链上留一个假缺口。compute_checksum 排除 entity_id 正是为这个设计服务的。
版本与推导为什么要拆成两条字段。 previous_version_id 和 derived_from_id 语义不同:前者回答「这条事实被修正过,旧版本是哪条」,后者回答「这条结论是从别的实体推出来的」。审计时问的问题不一样,前者对应「这个值改过几次」,后者对应「这个结论的前提链在哪」。若都塞进一个 parent_entity_id,两种追问就混在一起,导出 PROV-O 时也没法把修正关系和推导关系分开映射。compute_checksum 排除 entity_id 的取舍,正是为了让归档的纯改名不触发链断裂,这条决策链前后是咬合的。
为什么推理层几乎不写溯源。 这是本章反复强调的裂缝:reasoning_provenance.py 引用了一个不存在的 ReasoningEngine,而 Reasoner、DatalogReasoner、ReteEngine 内部都没有任何 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_chain 按 sequence_id 排序后会发现两个信号:被删行后面那条的 previous_checksum 对不上前一条的校验和,且 sequence_id 出现跳号。两种情况都落进 reason: "chain_break" 分支,见 manager.py 1493-1508 行。单行被原地改字段则是 verify_checksum 报 checksum_mismatch。删除和篡改都能被区分开。
如果给 Datalog 喂一个以大写开头的常量事实。 解析层把大写首字母一律当变量,add_fact 会显式拒绝:
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_alpha 用 alpha_node.matches 当前快照做左表去 join。第一个 alpha 节点还没有匹配时,新事实到达第二个 alpha 节点,join 左表为空,不产生激活;等第一个条件的事实到达,它作为新事实和已有事实配对,此时才激活。所以激活顺序取决于事实到达顺序,结论集合最终一致,因为 _matches 和 _can_join 恒真,任何顺序最终都会两两配对。
如果批量追踪时中间一条写失败。 track_entities_batch 按每 1000 条一批开启事务,批内每条再用 savepoint 包一层:单条失败会回滚到该条之前的保存点,except Exception: pass 后继续下一条,不拖垮整批。批事务成功提交后才把 batch_count 累加进返回值,中途块级失败则记录日志不累加。所以「追踪了多少条」这个数字是「实际落库并提交」的数量,失败条目静默跳过,调用方不会拿到一个虚高的计数。
横向对比
GraphRAG 既没有推理引擎,也没有溯源层。在它整个 packages/graphrag/graphrag 目录里检索 datalog、rete、forward chaining、provenance,均无匹配。它回答问题的可信度靠另一个机制:把检索到的原文片段塞进提示词,让模型在回答里引用片段 id。
上下文构建端,build_text_unit_context 把文本单元渲染成一张带 id 和 text 两列的表:
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]提示词端要求模型把每句论断挂上数据引用:
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 里没有对应物,检索关键词 datalog、rete、provenance、checksum 均无结果。代价是:它回答「为什么是这个结论」时只能指回检索片段,不能证明推导过程。
两边的可信度形态也不同。GraphRAG 的引用是「过程内」的:片段 id 只在这一次 prompt 往返里有意义,会话结束,引用就失去可查询的载体。Semantica 的溯源是「过程外」的:ProvenanceEntry 落进存储,带上校验和和序号,跨进程、跨时间都还能被 verify_chain 重新校验。前者便宜,因为不持久化任何东西;后者贵,但换来了「三个月后审计还能查」的能力。选择哪一边,取决于结论的保质期和问责强度,两条路线各有各的适用面。
互动演示设计
演示形态是格式实验台:读者在一侧输入 Datalog 事实和规则,另一侧实时看到推导结果,以及每条新事实对应的溯源链如何长出来。
一句话结论: 确定性引擎产出可复现的结论,溯源层把这些结论变成一条能被审计、被导出、被校验的链条。
舞台元素与比喻: 把推导看成一条流水线。事实是「原料」,Datalog 规则是「配方」,derive_all 是不动点搅拌机,ProvenanceManager 是给每批成品贴的防伪标签,标签之间用链条串着,撕掉一张就会被发现。实验台左侧是原料和配方,中间是搅拌机,右侧是标签墙,墙上一行标签就是一条溯源记录。
分步动画:
- 面板出现两个输入框:事实框和规则框。事实框预填
supplied(delta3, gamma7)、supplied(gamma7, apt29),规则框预填reaches(X, Y) :- supplied(X, Y).和递归句。 - 点击「推导」,搅拌机开始转:第一轮 delta 从两个事实出发,套规则产出
reaches(delta3, gamma7)和reaches(gamma7, apt29)。 - 第二轮用新事实再套,产出
reaches(delta3, apt29),delta 为空,停止。右侧逐行亮起derive_all的 while 循环。 - 每产出一条结论,左侧自动调
track_entity,溯源面板多一行,sequence_id递增,previous_checksum指向上一行。 - 读者点「删除中间一行」,再点「校验」,
verify_chain面板报chain_break,并高亮断点前后两条。
每步字幕文案:
- 「第一轮:原料事实全部进 delta,套配方产出直接可达关系。」
- 「第二轮:只有新事实参与,产出传递关系,这是半朴素的省算力点。」
- 「delta 空了,不动点到达,搅拌机停机。」
- 「每条结论都贴了标签,标签编号连续,链条不断。」
- 「删掉一张标签,后一张的指纹就悬空了,校验立刻报警。」
读者可操作项: 改规则为只保留非递归句,观察 reaches 只产出直接边;给事实加一个大写开头参数,观察 ValueError: Facts must be constants only;把 export_prov 的输出粘贴到 RDF 查看器,观察 prov:Entity 和 prov:qualifiedAssociation 三元组。
逻辑轨迹面板伪代码(右侧标真实行号):
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_id、source_document、previous_checksum。前两个回答「从哪来」,第三个回答「没被删改」。compute_checksum 里那串字段拼接可以按业务裁减,但「排除主键 id、纳入前链哈希」这两个取舍要保留,否则版本归档会误报。
哪些是过度设计: ProvenanceManager 里 agent_type、role、acted_on_behalf_of、informed_by_activities、bundle_id 这些 PROV-O 细化字段,单团队小项目里多数用不上。export_prov 里 qualified 系列的空节点结构,只有在需要「同一 agent 同一实体多种角色」时才值得。Rete 引擎的 alpha/beta 网络也是过度设计:_matches 和 _can_join 是恒真桩,网络结构搭好了但匹配逻辑没实现,当前用它等于花建网的成本换一个「全连接」结果,直接用 Reasoner.forward_chain 更实在。
两条路线各自的最小形态可以更省。 Datalog 那条,若只做可达性、不做通用规则,一个递归查询加一个 visited 集合就能顶替 derive_all 的半朴素循环。溯源那条,若不需要标准互操作,去掉 export_prov,只留 sequence_id、previous_checksum、checksum 三个字段和 verify_chain 一个方法,就已经是完整的抗删除审计。反过来,若一开始就上 PROV-O 全量字段加 SQLite 迁移列加哈希链,单原型阶段只会拖慢写代码的速度。抄的时候先问一句:审计的对手是谁,是误删还是恶意篡改,是内部看还是外部监管看。答案不同,最小形态就不同。
思考题
-
compute_checksum排除了entity_id,这带来一个安全后果:攻击者可以把一行的entity_id换成另一个,只要其余字段不变,单行校验和仍通过。verify_chain靠什么机制间接抓住这种「换名」?结合manager.py1450 行的注释说明。 -
ReasoningEngineWithProvenance引用了不存在的reasoning_engine.ReasoningEngine。如果要把推理结论真正接进溯源,最小改动是改哪个文件、在哪一行加什么调用?指出reasoning_provenance.py需要替换的导入和被infer包装的引擎。 -
动手验证:在
semantica仓库根目录起一个 Python 交互环境,运行下面这段,观察 Datalog 递归推导和溯源链校验:
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.py 用 ProvenanceManager(storage_path="t.db") 追踪两个实体,手动 DELETE FROM provenance WHERE ... 一行,然后跑 verify_chain(),观察 broken_links 里出现的 reason。
verify_chain的循环里,为什么单条checksum_mismatch之后仍要推进expected_previous和expected_sequence?如果改成立即 return,会出现什么误报?结合 1510-1514 行的注释回答。