第 6 章
图谱构建与双时态事实
场景还原
合规组周五下午接到审计问询:2023 年 6 月 30 日这天,供应商 Acme 的 ISO 9001 资质是否仍然有效。数据团队手上有两份合同文档和一张扫描件,抽取结果里出现了三个实体:Acme、Acme Corp.、Acme Corporation。同一家公司在图里被拆成了三个节点,资质关系 CERTIFIED_TO 只带 valid_until=2024-12-31,没有 valid_from。工程师用今天的日期去查,系统回答有效;审计问的是 2023 年 6 月,答案对不上。
更麻烦的是系统里还躺着一条旧记录,说这份证书 valid_until=2022-12-31,是后来被更正掉的。查询层分不清哪条时间线描述事实本身,哪条时间线描述数据进库,于是同一个问题在不同日期会给出不同答案,而且没人能解释为什么。这一章要回答的核心问题就是:抽出来的三元组怎么变成一张图,实体消解怎么做,双时态这两个时间维度怎么建模、怎么查询。
逐行精读
图的载体是一对普通列表。 先看 GraphBuilder 的自述,它把最终产出说得很直白:图就是 entities 和 relationships 两个列表,外加 metadata。
30class GraphBuilder:31 """32 Knowledge graph builder with temporal support.3334 • Constructs knowledge graphs from entities and relationships35 • Supports temporal knowledge graphs with time-aware edges36 • Manages node and edge creation with temporal annotations37 • Handles graph structure optimization38 • Supports incremental graph building39 • Enables temporal versioning and snapshots4041 Attributes:42 • merge_entities: Whether to merge duplicate entities43 • entity_resolution_strategy: Strategy for entity resolution44 • resolve_conflicts: Whether to resolve conflicts45 • enable_temporal: Enable temporal knowledge graph features46 • temporal_granularity: Time granularity (second, minute, hour, day, etc.)47 • track_history: Track historical changes48 • version_snapshots: Create version snapshots4950 Methods:51 • build(): Build knowledge graph from sources52 • add_temporal_edge(): Add edge with temporal validity53 • create_temporal_snapshot(): Create temporal snapshot54 • query_temporal(): Query graph at specific time point55 """这里没有出现一个 Graph 类。Semantica 把知识图谱建模成「节点字典列表 + 关系字典列表」,这是一个带时间属性的平面图(LPG 风格)的朴素表达。这决定了后续所有代码都能用 dict.get 直接读字段,也决定了双时态字段只是关系字典里的几个键。
构造器同时把三类可插拔组件挂上。 下面这段是 init 的核心:先落配置字段和抽取器缓存,再按开关决定要不要实例化实体消解器和冲突检测器。
57 def __init__(58 self,59 merge_entities=False,60 entity_resolution_strategy="fuzzy",61 resolve_conflicts=True,62 enable_temporal=False,63 temporal_granularity="day",64 track_history=False,65 version_snapshots=False,66 graph_store=None,67 **kwargs,68 ):69 """70 Initialize graph builder.7172 Args:73 merge_entities: Whether to merge duplicate entities (default: False, set to True to enable)74 Note: Entity resolution is typically done in conflict resolution step75 entity_resolution_strategy: Strategy for entity resolution ("fuzzy", "exact", "ml-based")76 resolve_conflicts: Whether to resolve conflicts77 enable_temporal: Enable temporal knowledge graph features78 temporal_granularity: Time granularity ("second", "minute", "hour", "day", "week", "month", "year")79 track_history: Track historical changes to entities/relationships80 version_snapshots: Create version snapshots at intervals81 graph_store: Optional GraphStore instance for persistence82 **kwargs: Additional configuration options83 """84 self.merge_entities = merge_entities85 self.entity_resolution_strategy = entity_resolution_strategy86 self.resolve_conflicts = resolve_conflicts87 self.enable_temporal = enable_temporal88 self.temporal_granularity = temporal_granularity89 self.track_history = track_history90 self.version_snapshots = version_snapshots91 self.graph_store = graph_store92 self.config = kwargs # Store additional config for extractors93 # Extractors are reused across texts: NERExtractor loads its spaCy model94 # eagerly in __init__, so constructing one per text would reload the95 # model on every source in a multi-document build.96 self._extractor_cache: Dict[Tuple[str, Any], Any] = {}97 # build() resets these per run; seed them here so _extract_from_text98 # is usable on its own instead of raising an AttributeError that the99 # broad except in the extraction path silently swallows.100 self._extraction_stats: Dict[str, int] = {101 "extracted_entities": 0,102 "extracted_relations": 0,103 "extracted_triplets": 0,104 }输入是一组配置开关,输出是一个持有这些开关和缓存字段的 builder 实例。注释里藏着一条性能决策:NERExtractor 在 init 里加载 spaCy 模型,所以抽取器被缓存在 _extractor_cache,多文档构建时不会每个 source 都重新加载一次模型。
开关决定组件是否装配。 只有 merge_entities 为真才创建 EntityResolver,只有 resolve_conflicts 为真才创建 ConflictDetector。
116 # Initialize entity resolver if entity merging is enabled117 # This helps deduplicate and merge similar entities118 if self.merge_entities:119 from .entity_resolver import EntityResolver120121 entity_resolution_config = kwargs.get("entity_resolution", {})122 self.entity_resolver = EntityResolver(123 strategy=self.entity_resolution_strategy, **entity_resolution_config124 )125 self.logger.debug(126 f"Entity resolver initialized with strategy: {self.entity_resolution_strategy}"127 )128 else:129 self.entity_resolver = None130 self.logger.debug("Entity merging disabled, skipping entity resolver")131132 # Initialize conflict detector if conflict resolution is enabled133 # This helps detect and resolve conflicting information in the graph134 if self.resolve_conflicts:135 from ..conflicts.conflict_detector import ConflictDetector136137 conflict_detection_config = kwargs.get("conflict_detection", {})138 self.conflict_detector = ConflictDetector(**conflict_detection_config)139 self.logger.debug("Conflict detector initialized")140 else:141 self.conflict_detector = None142 self.logger.debug("Conflict resolution disabled")注意默认值:merge_entities 默认 False,resolve_conflicts 默认 True。也就是说,默认构造的 builder 会做冲突检测,但不会主动合并重复实体,除非调用方显式打开 merge_entities。
build 先做鸭子类型分流。 真正消解之前,build 会遍历每个 source,_process_item 负责把五花八门的输入统一成实体字典或关系字典:字符串走文本抽取,带 text 和 label 的对象当成实体,带 subject、predicate、object 的对象当成关系,字典则原地补齐 source/target 别名。
144 def _process_item(self, item: Any, all_entities: List[Any], all_relationships: List[Any], **options):145 """Helper to process a single item and add to entities or relationships list."""146 if isinstance(item, str):147 # Treat string as text for extraction148 self._extract_from_text(item, all_entities, all_relationships, **options)149 return150151 if hasattr(item, "text") and (hasattr(item, "label") or hasattr(item, "type")):152 # It's likely an Entity object153 entity_dict = {154 "id": getattr(item, "id", getattr(item, "entity_id", item.text)),155 "name": item.text,156 "type": getattr(item, "label", getattr(item, "type", "UNKNOWN")),157 "confidence": getattr(item, "confidence", 1.0),158 "metadata": getattr(item, "metadata", {})159 }160 all_entities.append(entity_dict)161 elif hasattr(item, "subject") and hasattr(item, "predicate") and hasattr(item, "object"):162 # It's likely a Relation object163 subj = item.subject164 obj = item.object165 subj_id = getattr(subj, "id", getattr(subj, "text", str(subj))) if not isinstance(subj, str) else subj166 obj_id = getattr(obj, "id", getattr(obj, "text", str(obj))) if not isinstance(obj, str) else obj167 rel_dict = {168 "source": subj_id,169 "target": obj_id,170 "type": item.predicate,171 "confidence": getattr(item, "confidence", 1.0),172 "metadata": getattr(item, "metadata", {})173 }174 all_relationships.append(rel_dict)175 elif isinstance(item, dict):输入是一个 item 和两个累积列表,输出是把 item 规范化后追加进对应列表。id 的取值顺序是 id 优先、entity_id 兜底、再退到 text,这层容错是后面消解和重映射能吃下脏数据的底气。
build 对 sources 也做了同样的事:单个 dict 包装成单元素列表,多个带 entities/relationships 的 dict 合并成一个 source_dict。
493 source_dict = None494 if isinstance(sources, dict) and ("entities" in sources or "relationships" in sources):495 source_dict = sources496 sources = [sources] # Normalize for tracking497 elif isinstance(sources, list) and len(sources) > 0:498 # Check if first item is a dict with entities/relationships499 first_item = sources[0]500 if isinstance(first_item, dict) and ("entities" in first_item or "relationships" in first_item):501 # If all items are dicts with entities/relationships, merge them502 if all(isinstance(item, dict) and ("entities" in item or "relationships" in item) for item in sources):503 # Merge all sources into one dict504 source_dict = {505 "entities": [],506 "relationships": []507 }508 for item in sources:509 if "entities" in item:510 if isinstance(item["entities"], list):511 source_dict["entities"].extend(item["entities"])512 else:513 source_dict["entities"].append(item["entities"])514 if "relationships" in item:515 if isinstance(item["relationships"], list):516 source_dict["relationships"].extend(item["relationships"])517 else:518 source_dict["relationships"].append(item["relationships"])519 elif not isinstance(sources, list):520 sources = [sources]输入是 sources,输出是统一后的 source_dict 和 sources 列表。这段的意义在于调用方可以用任意形状喂数据,而消解、存储、查询永远面对同一种「实体列表加关系列表」的形态。build 的签名里还有个 second_arg 参数,文档标注是为向后兼容保留:传带 resolve 方法的对象就当 EntityResolver 用,传 list 就当显式关系列表。这是典型的鸭子类型向后兼容,新调用应该用 options 里的 entity_resolver 和 relationships。
字典格式走快路径。 build 处理实体列表前先抽查第一个元素,如果已经是 dict 且没有 dict,就走快路径:逐条补齐 name 和 id 后直接追加,跳过 _process_item 的鸭子类型判断;否则走慢路径逐条 _process_item。
588 # Check if entities are already in dictionary format589 sample_entity = entities_list[0] if entities_list else None590 is_dict_format = isinstance(sample_entity, dict) and (591 "id" in sample_entity or "entity_id" in sample_entity or 592 "name" in sample_entity or "text" in sample_entity593 ) and not hasattr(sample_entity, "__dict__") # Ensure it's not a class instance594 595 if is_dict_format:596 # Fast path: directly append dictionaries after normalizing597 batch_size = max(100, len(entities_list) // 20)598 for i in range(0, len(entities_list), batch_size):599 batch = entities_list[i:i + batch_size]600 for item in batch:601 # Normalize entity dict format602 if isinstance(item, dict):603 # Ensure required fields exist604 entity_dict = item.copy()605 if "text" in entity_dict and "name" not in entity_dict:606 entity_dict["name"] = entity_dict["text"]607 if "id" not in entity_dict and "entity_id" not in entity_dict:608 entity_dict["id"] = entity_dict.get("name") or entity_dict.get("text") or str(hash(str(item)))609 all_entities.append(entity_dict)610 else:611 # Fallback to _process_item for non-dict items612 self._process_item(item, all_entities, all_relationships, **options)613 614 processed = min(i + batch_size, len(entities_list))615 616 # Update progress with ETA617 self.progress_tracker.update_progress(618 entity_tracking_id,619 processed=processed,620 total=len(entities_list),621 message=f"Processing entities... {processed}/{len(entities_list)}"622 )623 else:624 # Slow path: use _process_item for complex objects输入是实体列表,输出是规范化后的实体字典。batch_size 取 100 和总数二十分之一的较大值,是为了让进度更新和批处理开销保持平衡。
build() 的收尾三步:消解、重映射、组装。 下面这段是 build 方法里图真正成型的地方。先调用 resolver 得到 resolved_entities,再判断有没有发生合并,有才重写关系端点,最后组装出带 metadata 的 graph 字典。
764 resolved_entities = resolver_to_use.resolve_entities(all_entities)765 resolution_time = time.time() - resolution_start766 self.logger.info(767 "Resolved to %d unique entities (%.2fs)",768 len(resolved_entities),769 resolution_time,770 )771 self.logger.info(772 f"Entity resolution complete: {len(all_entities)} -> {len(resolved_entities)} unique entities"773 )774775 # Relationships were collected before entity resolution. Rewrite776 # endpoints only when resolution produced merged entity IDs.777 if resolver_to_use:778 has_merged_entities = any(779 isinstance(entity, dict) and entity.get("merged_from")780 for entity in resolved_entities781 )782 if has_merged_entities:783 self._remap_relationship_endpoints(resolved_entities, all_relationships)784785 if input_relationships_count > 0 and len(all_relationships) == 0:786 warning_msg = (787 f"All relationships were dropped during graph building: "788 f"{input_relationships_count} input relationships, 0 in final graph"789 )790 self.logger.warning(warning_msg)791792 # Build graph structure793 self.logger.debug("Building graph structure...")794 structure_start = time.time()795 graph = {796 "entities": resolved_entities,797 "relationships": all_relationships,798 "metadata": {799 "num_entities": len(resolved_entities),800 "num_relationships": len(all_relationships),801 "temporal_enabled": self.enable_temporal,802 "timestamp": self._get_timestamp(),803 "entity_resolution_applied": resolver_to_use is not None,804 },805 }输入是 all_entities 和 all_relationships 两个列表,输出是 graph 字典。metadata 里 entity_resolution_applied 记录消解是否真的跑过,这是一个可审计信号:下游只凭这个字段就知道这团实体有没有被合并过。build 里还有一个保护:如果输入带关系但最终图里关系数为零,会打 warning,提示所有关系在构建过程中被丢弃。这个告警针对的是一种静默失败:抽取器跑了、关系也喂进去了,但归一化或消解环节把它们全丢了,只报 0 而不解释原因,下游很难排查。
这段收尾的调用关系画成时序图是这样:
端点重映射是消解后的补丁。 实体合并只保留一个 canonical id,把被合并的旧 id 记进 merged_from。关系是在消解之前收集的,仍然引用旧 id,不重写就会指向图里已经不存在的节点。
265 def _remap_relationship_endpoints(266 self,267 entities: List[Dict[str, Any]],268 relationships: List[Dict[str, Any]],269 ) -> int:270 """Rewrite relationship endpoints after entity resolution.271272 Entity merging keeps the canonical entity ID and records the IDs of all273 merged inputs in ``merged_from``. Relationships are collected before274 resolution, so without this remapping they can continue to reference an275 entity that is no longer present in the graph.276277 Returns:278 The number of relationship endpoints that were remapped.279 """280 endpoint_map: Dict[Any, Any] = {}281282 for entity in entities:283 if not isinstance(entity, dict):284 continue285286 canonical_id = entity.get("id")287 if canonical_id is None:288 canonical_id = entity.get("entity_id")289 if canonical_id is None:290 continue291292 # Keep canonical IDs stable and map every source ID retained by the293 # merge operation to the surviving entity.294 try:295 endpoint_map[canonical_id] = canonical_id296 except TypeError:297 # Invalid/unhashable IDs are left for graph validation to report298 # rather than making graph construction fail here.299 continue300301 merged_from = entity.get("merged_from") or []302 if isinstance(merged_from, (list, tuple, set)):303 for source_id in merged_from:304 if source_id is not None:305 try:306 endpoint_map[source_id] = canonical_id307 except TypeError:308 # Skip invalid aliases while preserving valid ones.309 continue310311 remapped_count = 0312 for relationship in relationships:313 if not isinstance(relationship, dict):314 continue315316 for endpoint, endpoint_alias in (317 ("source", "source_id"),318 ("target", "target_id"),319 ):320 endpoint_id = relationship.get(endpoint)321 try:322 canonical_id = endpoint_map.get(endpoint_id)323 except TypeError:324 # Invalid/unhashable endpoints are left for graph validation325 # to report rather than making graph construction fail here.326 continue327328 if canonical_id is None:329 continue330331 remapped = canonical_id != endpoint_id332 if remapped:333 relationship[endpoint] = canonical_id334335 if (336 endpoint_alias in relationship337 and relationship[endpoint_alias] != canonical_id338 ):339 relationship[endpoint_alias] = canonical_id340 remapped = True341342 if remapped:343 remapped_count += 1344345 if remapped_count:346 self.logger.info(347 "Remapped %d relationship endpoint(s) after entity resolution",348 remapped_count,349 )350 return remapped_count输入是消解后的实体列表和原始关系列表,输出是被重写的端点数。它同时处理 source/source_id 和 target/target_id 两组别名键,原因是不同上游产出用过不同的键名。两处 except TypeError 说明一个态度:无法哈希的 id 不在这里炸掉,留给图校验去报告,构建本身不因此中断。
EntityResolver 本体是一段自述加三步走。 完整类定义先看它的文档,说明三种策略。
32class EntityResolver:33 """34 Entity resolver for knowledge graph construction.3536 This class provides entity disambiguation and resolution by detecting37 duplicate entities and merging them based on similarity. It supports38 multiple resolution strategies and configurable similarity thresholds.3940 Resolution Strategies:41 - "fuzzy": Fuzzy string matching (default)42 - "exact": Exact string matching43 - "semantic": Semantic similarity matching4445 Example Usage:46 >>> resolver = EntityResolver(strategy="fuzzy", similarity_threshold=0.8)47 >>> entities = [{"id": "1", "name": "Apple Inc."}, {"id": "2", "name": "Apple"}]48 >>> resolved = resolver.resolve_entities(entities)49 """三种策略:fuzzy 模糊匹配是默认,exact 精确匹配,semantic 语义匹配。实际落地的三步流程在 resolve_entities 里。
136 try:137 # Step 1: Detect duplicate groups138 # Groups entities that are similar enough to be considered duplicates139 self.logger.debug(140 f"Detecting duplicate groups with threshold {self.similarity_threshold}"141 )142 duplicate_groups = self._detect_duplicate_groups(entities)143144 self.logger.debug(f"Found {len(duplicate_groups)} duplicate group(s)")145146 self.progress_tracker.update_tracking(147 tracking_id, message=f"Found {len(duplicate_groups)} duplicate group(s)"148 )149 # Step 2: Merge duplicates in each group150 merged_entities = []151 processed_entity_ids = set() # Track which entities have been merged152 processed_entity_objects = set()153154 for group in duplicate_groups:155 # Skip groups with less than 2 entities (not duplicates)156 if len(group.entities) < 2:157 continue158159 # Merge the duplicate group into a single canonical entity160 if self.resolution_strategy == "exact":161 merge_operations = [162 self.entity_merger.merge_entity_group(163 group.entities, **self.config164 )165 ]166 else:167 merge_operations = self.entity_merger.merge_duplicates(168 group.entities, **self.config169 )170171 # Process each merge operation172 for operation in merge_operations:173 merged_entity = operation.merged_entity174 merged_entities.append(merged_entity)175176 # Mark all source entities as processed177 for source_entity in operation.source_entities:178 entity_id = self._get_entity_id(source_entity)179 if entity_id is None:180 processed_entity_objects.add(id(source_entity))181 continue182 try:183 processed_entity_ids.add(entity_id)184 except TypeError:185 processed_entity_objects.add(id(source_entity))186187 # Step 3: Add non-duplicate entities (entities not in any duplicate group)188 for entity in entities:189 entity_id = self._get_entity_id(entity)190 if entity_id is None:191 is_unprocessed = id(entity) not in processed_entity_objects192 else:193 try:194 is_unprocessed = entity_id not in processed_entity_ids195 except TypeError:196 is_unprocessed = id(entity) not in processed_entity_objects197 if is_unprocessed:198 # This entity was not merged, add it as-is199 merged_entities.append(entity)200201 # Log resolution statistics202 original_count = len(entities)203 resolved_count = len(merged_entities)204 reduction = original_count - resolved_count205206 self.logger.info(207 f"Entity resolution complete: {original_count} -> {resolved_count} "208 f"({reduction} duplicate(s) merged)"209 )210211 return merged_entities输入是实体字典列表,输出是合并后的列表,长度小于等于输入。三步分别是:检测重复组、按组合并、把没被合并的实体原样追加。注意两组集合 processed_entity_ids 和 processed_entity_objects:id 可哈希时用 id 判重,id 缺失或不可哈希时退回到 Python 对象的 id() 判重。这是对脏数据的一条兜底。精确策略走的是另一条路:_detect_duplicate_groups 在 strategy 为 exact 时不做相似度计算,而是把实体名 strip 后 casefold,按大小写折叠后的名字直接分桶。这意味着 exact 策略零误报,但只能抓住字面完全一致的重复,只有大小写和首尾空格造成的差异才会被归并。
双时态事实是一个数据类,不是存储结构。 这是本章最关键的一段。BiTemporalFact 明确说自己是 relationship 字典的包装,事实仍然以普通字典形式躺在图里。
27@dataclass28class BiTemporalFact:29 """30 Backward-compatible wrapper around existing relationship dictionaries.3132 Design note:33 Facts continue to live as plain relationship dicts in the graph. This wrapper34 is only used internally for normalization so existing callers can keep35 reading and writing `valid_from` / `valid_until` directly.36 """3738 valid_from: Optional[datetime]39 valid_until: Optional[datetime | TemporalBound]40 recorded_at: datetime = field(default_factory=_default_recorded_at)41 superseded_at: datetime | TemporalBound = TemporalBound.OPEN4243 @classmethod44 def from_relationship(cls, relationship: Dict[str, Any]) -> "BiTemporalFact":45 valid_until_raw = relationship.get("valid_until", TemporalBound.OPEN)46 if valid_until_raw is None:47 valid_until_raw = TemporalBound.OPEN4849 valid_from = parse_temporal_value(relationship.get("valid_from"))50 recorded_at_raw = relationship.get("recorded_at")51 superseded_at_raw = relationship.get("superseded_at", TemporalBound.OPEN)5253 return cls(54 valid_from=valid_from,55 valid_until=parse_temporal_bound(valid_until_raw),56 recorded_at=parse_temporal_value(recorded_at_raw) if recorded_at_raw is not None else (valid_from or _default_recorded_at()),57 superseded_at=parse_temporal_bound(superseded_at_raw, default=TemporalBound.OPEN),58 )5960 def to_relationship_fields(self) -> Dict[str, Any]:61 return {62 "valid_from": serialize_temporal_value(self.valid_from),63 "valid_until": serialize_temporal_bound(self.valid_until),64 "recorded_at": serialize_temporal_value(self.recorded_at),65 "superseded_at": serialize_temporal_bound(self.superseded_at),66 }先看 OPEN 这个哨兵的出处,它是一个两行枚举:
17class TemporalBound(Enum):18 """Sentinel bounds for open-ended temporal intervals."""1920 OPEN = "OPEN"四个字段正好对应两条时间线:valid_from 和 valid_until 是事实时间,描述这条事实在现实世界里为真的区间;recorded_at 和 superseded_at 是记录时间,描述这条事实在系统里被登记和被取代的时刻。valid_until 缺省时落到 TemporalBound.OPEN,表示「至今仍有效」,这个哨兵值贯穿全文。OPEN 是这套时间模型的关键发明:序列化时 serialize_temporal_bound 把 OPEN 写回 None,反序列化时 parse_temporal_bound 又把 None 读回 OPEN,这样一个持续有效的状态在 Python 内部有明确的值,落到 JSON 里又变成普通的 null,两头都不含糊。
时间解析把一切字符串归一到 UTC。 parse_temporal_value 接收 datetime、时间戳、ISO 字符串三种输入,统一输出带时区的 UTC datetime。
83def parse_temporal_value(value: Any) -> Optional[datetime]:84 if value is None:85 return None86 if isinstance(value, datetime):87 dt = value88 elif isinstance(value, (int, float)):89 dt = datetime.fromtimestamp(value, timezone.utc)90 elif isinstance(value, str):91 normalized = _coerce_iso_like_string(value)92 if normalized.endswith("Z"):93 normalized = normalized[:-1] + "+00:00"94 try:95 dt = datetime.fromisoformat(normalized)96 except ValueError as exc:97 raise TemporalValidationError(98 "Invalid temporal value",99 temporal_context={"value": value},100 ) from exc101 else:102 raise TemporalValidationError(103 "Unsupported temporal value type",104 temporal_context={"value": value, "type": type(value).__name__},105 )106107 if dt.tzinfo is None:108 dt = dt.replace(tzinfo=timezone.utc)109 return dt.astimezone(timezone.utc)输出永远是 UTC。naive datetime 被当作 UTC 补上时区,带 Z 后缀的字符串先换成 +00:00 再解析。解析失败抛 TemporalValidationError,并带上原始值上下文,这是给审计留的钩子。
写入侧同样落在 GraphBuilder 上。 add_temporal_edge 负责造一条带时态的边:valid_from 缺省时取当前时间,valid_until 缺省为 None,表示持续有效。
980 # Parse temporal information981 valid_from = self._parse_time(valid_from) or self._get_timestamp()982 valid_until = self._parse_time(valid_until) if valid_until else None983984 # Create edge with temporal information985 edge = {986 "source": source,987 "target": target,988 "type": relationship,989 "valid_from": valid_from,990 "valid_until": valid_until,991 "temporal_metadata": temporal_metadata or {},992 **kwargs,993 }994995 # Add to graph996 if "relationships" not in graph:997 graph["relationships"] = []998 graph["relationships"].append(edge)输入是 graph、source、target、relationship 和可选时间参数,输出是追加进 relationships 列表的边字典。create_temporal_snapshot 则按时间点切快照,把 valid_from 晚于快照点的、valid_until 早于快照点的关系都跳过。
1065 # Filter relationships valid at snapshot time1066 if "relationships" in graph:1067 for rel in graph["relationships"]:1068 valid_from = self._parse_time(rel.get("valid_from"))1069 valid_until = self._parse_time(rel.get("valid_until"))10701071 # Check if relationship is valid at snapshot time1072 if (1073 valid_from1074 and self._compare_times(snapshot_time, valid_from) < 01075 ):1076 continue1077 if (1078 valid_until1079 and self._compare_times(snapshot_time, valid_until) > 01080 ):1081 continue10821083 relationships.append(rel)这两处是「写时态」的半边,配合查询侧的 active_at,双时态才闭环:写入时打时间戳,查询时按时间窗过滤。注意写入侧的 valid_until 和查询侧的 end 判定用的是同一种左闭右开约定,所以写入一条 valid_until=2024-12-31 的事实,在 2024-12-31 当天查询会判定为已失效。这个边界如果业务要含当天,就得在上游把 valid_until 写成次日零点。
查询引擎的入口把时间点转成一张重建的子图。 TemporalGraphQuery 的自述列出五种时间能力:时点查询、区间查询、模式检测、演化分析、时间路径。
41class TemporalGraphQuery:42 """43 Temporal knowledge graph query engine.4445 This class provides time-aware querying capabilities for knowledge graphs46 with temporal information, enabling queries at specific time points, within47 time ranges, and temporal pattern detection.4849 Features:50 - Time-point queries (filter relationships valid at specific time)51 - Time-range queries (filter relationships valid within range)52 - Temporal pattern detection (sequences, cycles, trends)53 - Graph evolution analysis54 - Temporal path finding (paths considering temporal validity)5556 Example Usage:57 >>> query_engine = TemporalGraphQuery()58 >>> result = query_engine.query_at_time(graph, query, at_time="2024-01-01")59 >>> range_result = query_engine.query_time_range(graph, query, start_time, end_time)60 >>> evolution = query_engine.analyze_evolution(graph)61 """query_at_time 本身不做过滤,它把活交给 reconstruct_at_time,然后包一层结果字典。
107 def query_at_time(108 self,109 graph: Any,110 query: str,111 at_time: Any,112 include_history: bool = False,113 temporal_precision: Optional[str] = None,114 time_axis: str = "valid",115 **options,116 ) -> Dict[str, Any]:117 """118 Query graph at specific time point.119120 This method filters the knowledge graph to only include relationships121 that are valid at the specified time point, based on valid_from and122 valid_until fields in relationships.123124 Args:125 graph: Knowledge graph to query (dict with "entities" and "relationships")126 query: Query string (currently unused, reserved for future query parsing)127 at_time: Time point (datetime object, timestamp, or ISO format string)128 include_history: Whether to include all relationships with temporal129 information (default: False, only valid relationships)130 temporal_precision: Precision for time matching (optional, unused)131 **options: Additional query options (unused)132133 Returns:134 dict: Query results containing:135 - query: Original query string136 - at_time: Parsed time point137 - entities: All entities (not filtered by time)138 - relationships: Relationships valid at specified time139 - num_entities: Number of entities140 - num_relationships: Number of valid relationships141 """142 self.logger.info(f"Querying graph at time: {at_time}")143144 # Parse time145 query_time = self._parse_time(at_time)146 reconstructed_graph = self.reconstruct_at_time(147 graph,148 query_time,149 time_axis=time_axis,150 )151152 # Get entities153 entities = reconstructed_graph.get("entities", [])154 relationships = reconstructed_graph.get("relationships", [])155156 # Include history if requested157 if include_history:158 # Add all relationships with temporal information159 relationships = graph.get("relationships", [])160161 return {162 "query": query,163 "at_time": query_time,164 "entities": entities,165 "relationships": relationships,166 "num_entities": len(entities),167 "num_relationships": len(relationships),168 }注意 query 参数文档里明确写「currently unused」,它是为将来查询解析预留的。include_history 为真时,关系列表整个退回原始图的关系,相当于关掉时点过滤。
reconstruct_at_time 保证子图自洽。 它不只过滤关系,还要求关系的两端实体在目标时刻都活着,否则整条关系被丢弃。
170 def reconstruct_at_time(171 self,172 graph: Any,173 at_time: Any,174 *,175 time_axis: str = "valid",176 ) -> Dict[str, Any]:177 """Return a self-consistent subgraph for a single point in time."""178 query_time = at_time if isinstance(at_time, datetime) else self._parse_time(at_time)179 reconstructed = copy.deepcopy(graph)180 entity_list = graph.get("entities", [])181182 if not entity_list:183 reconstructed["entities"] = []184 reconstructed["relationships"] = [185 copy.deepcopy(relationship)186 for relationship in graph.get("relationships", [])187 if self._relationship_active_at_time(relationship, query_time, time_axis=time_axis)188 ]189 return reconstructed190191 entity_index = {192 self._entity_id(entity): entity193 for entity in entity_list194 if self._entity_active_at_time(entity, query_time, time_axis=time_axis)195 }196197 relationships = []198 for relationship in graph.get("relationships", []):199 if not self._relationship_active_at_time(relationship, query_time, time_axis=time_axis):200 continue201 source = self._entity_id({"id": relationship.get("source")})202 target = self._entity_id({"id": relationship.get("target")})203 if source not in entity_index or target not in entity_index:204 continue205 relationships.append(copy.deepcopy(relationship))206207 reconstructed["entities"] = list(entity_index.values())208 reconstructed["relationships"] = relationships209 return reconstructed输入是图和时间点,输出是一个深拷贝的、两端实体都存在的子图。有两个分支:实体列表为空时只按时间过滤关系;否则先建「活跃实体索引」,再对每条关系同时查时间有效性和端点存在性。这正是「自洽」的含义,过滤后的图里不会有悬空边。查询引擎还提供 time_axis 参数,取值 valid、transaction 或 both。默认 valid 只看事实时间;切成 transaction 就只看记录时间;both 要求两条线同时覆盖查询点。这个开关让「2023 年 6 月资质是否有效」和「我们 2023 年 6 月时是否已经知道」两种问法都能用同一套代码回答。
时点查询的判定流程画成图是这样:
一致性校验把图里的时间病抓出来。 validate_temporal_consistency 逐关系检查时间字段能否解析、区间是否倒置、端点是否缺失。
211 def validate_temporal_consistency(self, graph: Any) -> TemporalConsistencyReport:212 errors: List[Dict[str, str]] = []213 warnings_list: List[Dict[str, str]] = []214215 entities = {216 self._entity_id(entity): entity217 for entity in graph.get("entities", [])218 }219 rel_groups: Dict[tuple[str, str, str], List[Dict[str, Any]]] = defaultdict(list)220221 for relationship in graph.get("relationships", []):222 rel_id = relationship.get("id") or self._relationship_key(relationship)223 rel_groups[224 (225 relationship.get("source", ""),226 relationship.get("type", relationship.get("relationship", "")),227 relationship.get("target", ""),228 )229 ].append(relationship)230231 try:232 start, end = self._get_axis_bounds(relationship, "valid")233 except TemporalValidationError as exc:234 errors.append(235 asdict(236 TemporalConsistencyIssue(237 message=f"Unable to parse temporal fields: {exc}",238 fact_id=rel_id,239 issue_type="invalid_temporal_fields",240 )241 )242 )243 continue244 if start and isinstance(end, datetime) and self._compare_times(start, end) > 0:245 errors.append(246 asdict(247 TemporalConsistencyIssue(248 message="Relationship has an inverted validity interval.",249 fact_id=rel_id,250 issue_type="inverted_interval",251 )252 )253 )254255 for endpoint in ("source", "target"):256 entity = entities.get(relationship.get(endpoint))257 if entity is None:258 errors.append(259 asdict(260 TemporalConsistencyIssue(261 message=f"Relationship references missing {endpoint} entity.",262 fact_id=rel_id,263 issue_type=f"missing_{endpoint}_entity",264 )265 )266 )267 continue输入是一张图,输出是 TemporalConsistencyReport,errors 和 warnings 两个列表分开。倒置区间落在 start > end 的判断上,缺失端点落在 entity is None 的判断上,每一条都带上 fact_id,审计时可以顺着 id 定位到具体关系。
时间推理引擎是纯 Python 的区间代数。 模块 docstring 把底线写得很清楚:零 LLM 调用,全部确定性计算。
1"""2Deterministic temporal reasoning primitives for Semantica.34This module is the single source of truth for interval math across temporal KG5features. It performs zero LLM calls: extraction may happen upstream, but all6temporal reasoning here is pure Python and fully deterministic.7"""Allen 区间代数有 13 种关系,relation 方法逐个枚举,最后兜底到 CONTAINS。区间本身是一个 frozen dataclass,关系是枚举:
20@dataclass(frozen=True)21class TemporalInterval:22 start: datetime23 end: datetime | TemporalBound24 label: Optional[str] = None252627class IntervalRelation(Enum):28 BEFORE = "before"29 AFTER = "after"30 MEETS = "meets"31 MET_BY = "met_by"32 OVERLAPS = "overlaps"33 OVERLAPPED_BY = "overlapped_by"34 STARTS = "starts"35 STARTED_BY = "started_by"36 DURING = "during"37 CONTAINS = "contains"38 FINISHES = "finishes"39 FINISHED_BY = "finished_by"40 EQUALS = "equals"frozen 保证区间对象不可变,这是做集合运算的前提;13 个枚举值正好覆盖 Allen 的完整关系集合。
43class TemporalReasoningEngine:44 """Pure-Python temporal reasoning engine with Allen interval algebra."""4546 SUPPORTED_GRANULARITIES = {"second", "minute", "hour", "day", "week", "month", "year"}4748 def relation(self, a: TemporalInterval, b: TemporalInterval) -> IntervalRelation:49 self._validate_interval(a)50 self._validate_interval(b)5152 a_end = self._end_value(a.end)53 b_end = self._end_value(b.end)5455 if a_end < b.start:56 return IntervalRelation.BEFORE57 if a.start > b_end:58 return IntervalRelation.AFTER59 if a_end == b.start:60 return IntervalRelation.MEETS61 if a.start == b_end:62 return IntervalRelation.MET_BY63 if a.start == b.start and a_end == b_end:64 return IntervalRelation.EQUALS65 if a.start == b.start and a_end < b_end:66 return IntervalRelation.STARTS67 if a.start == b.start and a_end > b_end:68 return IntervalRelation.STARTED_BY69 if a_end == b_end and a.start > b.start:70 return IntervalRelation.FINISHES71 if a_end == b_end and a.start < b.start:72 return IntervalRelation.FINISHED_BY73 if a.start < b.start and a_end > b.start and a_end < b_end:74 return IntervalRelation.OVERLAPS75 if a.start > b.start and a.start < b_end and a_end > b_end:76 return IntervalRelation.OVERLAPPED_BY77 if a.start > b.start and a_end < b_end:78 return IntervalRelation.DURING79 return IntervalRelation.CONTAINS8081 def overlaps(self, a: TemporalInterval, b: TemporalInterval) -> bool:82 relation = self.relation(a, b)83 return relation not in {84 IntervalRelation.BEFORE,85 IntervalRelation.AFTER,86 IntervalRelation.MEETS,87 IntervalRelation.MET_BY,88 }8990 def contains(self, outer: TemporalInterval, inner: TemporalInterval) -> bool:91 self._validate_interval(outer)92 self._validate_interval(inner)93 return outer.start <= inner.start and self._end_value(outer.end) >= self._end_value(inner.end)9495 def active_at(96 self,97 interval: TemporalInterval,98 timestamp: Any,99 *,100 granularity: Optional[str] = None,101 ) -> bool:102 self._validate_interval(interval)103 point = parse_temporal_value(timestamp)104 start = interval.start105 end = interval.end106107 if granularity is not None:108 point = self.normalize_timestamp(point, granularity)109 start = self.normalize_timestamp(start, granularity)110 if isinstance(end, datetime):111 end = self.normalize_timestamp(end, granularity)112113 return start <= point and (end is TemporalBound.OPEN or point < self._coerce_datetime(end))active_at 是时点查询的最终判定:一个时间点在区间内,当且仅当它晚于等于 start,并且要么 end 是 OPEN,要么严格早于 end。这里用「严格小于」处理 end,所以有效区间是左闭右开。这些判定没有模型参与,意味着同样的输入永远得到同样的答案,可复现是审计的前提。granularity 参数让比较先把时间截断到 day 或 month 再比,同一天内的先后差异被抹平,避免跨时区的秒级噪音污染区间判定。
设计决策分析
为什么图用普通字典列表,不封装成 Graph 类。 BiTemporalFact 的 docstring 把理由写在了明处:事实继续以普通 relationship 字典形式存在,包装类只用于内部归一化,已有调用方可以继续直接读写 valid_from 和 valid_until。这是一个向后兼容优先的决策。代价是字段没有类型约束,脏字段要等到查询或校验阶段才暴露;收益是 GraphBuilder、冲突检测、存储层、查询层之间不需要经过一个统一对象转换,任何环节都能用 dict.get 直接消费。
为什么要两条时间线。 事实时间描述「这件事在现实里何时为真」,记录时间描述「这条数据在系统里何时被写下、何时被取代」。企业知识图谱里两者经常打架:一份证书 2022 年就过期了,但纠正这条信息的报告 2024 年才录入。只存事实时间,你就丢了「我们什么时候才知道这件事」这个审计维度;只存记录时间,你就无法回答「2023 年 6 月 30 日资质是否有效」这种历史回放问题。Semantica 用四个字段 valid_from、valid_until、recorded_at、superseded_at 同时存两条线,这是双时态(bitemporal)的标准切法。
一条事实在这套模型里的生命周期是一个状态机:
为什么实体消解放在关系收集之后,再补一个端点重映射。 build 里关系的收集发生在 resolve_entities 之前,因为消解依赖完整的实体集合,而关系集合与实体集合是并行长出来的。合并会丢掉一批实体 id,所以合并完成后必须重写关系端点,_remap_relationship_endpoints 干的就是这件事。如果不重写,图里会出现指向已删除节点的悬空边,下游查询和存储都会静默失真。注释里还标了一个细节:只有真的产生了 merged_from 时才重写,避免无合并时白跑一遍。
为什么重映射同时处理两套键。 _remap_relationship_endpoints 的循环把 source/source_id 和 target/target_id 成对处理,因为上游抽取器在不同版本里用过不同键名。只处理 source 的话,历史数据里存 source_id 的关系会漏改,悬空边照样出现。兼容两套键的代价是每次循环多一次 get,换来的是旧数据不用迁移。
为什么时间推理必须确定性。 temporal_reasoning.py 的 docstring 说它是「single source of truth for interval math」,零 LLM 调用。LLM 擅长抽取,但让模型判断两个区间是否重叠,答案会随温度和采样抖动。审计场景要求同一个问题在任何时刻重问都得到同一个答案,所以区间算术必须落在纯 Python 上,LLM 只待在抽取那一环。
消解算法在精度和召回之间怎么选。 merge_strategy.py 的策略枚举里有 keep_first、keep_last、keep_most_complete、keep_highest_confidence、merge_all 五种,这是我实读到的。它们构成一条从保守到激进的刻度(推断):keep_first 只留一个代表,merge_all 把所有属性揉在一起,前者漏合并的风险高,后者错合并的风险高。模糊匹配的 similarity_threshold 默认 0.7,也是偏保守的取向。阈值往下调,召回上来了,但会把更多同名不同义的实体拉进同一组;往上调则相反。这个旋钮没有普适最优值,取决于数据里重名和拼写噪声的比例。
抽取器为什么要缓存。 init 里那段注释解释了 _extractor_cache:NERExtractor 在构造时加载 spaCy 模型,如果每个文本都新建一个抽取器,多文档构建会反复重载模型。缓存以 kind 和方法名为键,builder 生命周期内复用。
为什么冲突检测放在图组装之后。 build 里 graph 字典先组装完成,持久化之后才轮到 conflict_detector。它只接收 entities,检测和消解的结果只写日志,不回写 graph。
844 # Detect and resolve conflicts if conflict detector is available845 if self.conflict_detector:846 self.logger.debug("Detecting conflicts in graph")847 # Pass only entities to detect_conflicts as it expects List[Dict]848 detected_conflicts = self.conflict_detector.detect_conflicts(graph["entities"])849850 if detected_conflicts:851 conflict_count = len(detected_conflicts)852 self.logger.warning(853 f"Detected {conflict_count} conflict(s) in graph"854 )855856 # Attempt to resolve conflicts857 resolution_result = self.conflict_detector.resolve_conflicts(858 detected_conflicts859 )860 resolved_count = resolution_result.get("resolved_count", 0)这个顺序说明 builder 对冲突的态度:它负责把图建出来,冲突的完整消解策略在 conflicts 模块里,builder 这里只是触发一次诊断。图成型和冲突诊断解耦,诊断失败不会污染已经返回的图。
边界条件剖析
如果 valid_until 缺省或为 None,事实会怎样。 答案落在 OPEN 哨兵上。from_relationship 把 None 归一到 TemporalBound.OPEN,active_at 的判定是 end is TemporalBound.OPEN or point < ...,见 temporal_reasoning.py 第 113 行。也就是说一条只有 valid_from 没有 valid_until 的事实,从 valid_from 起永远活跃。场景还原里那张「只有 valid_until 没有 valid_from」的证书则相反:start 为 None,active_at 里 start <= point 永远成立,它从时间起点就活跃到 valid_until,这会让 2020 年的查询也返回「有效」。
如果实体消解合并了实体,但关系仍然引用旧 id,会发生什么。 答案在 graph_builder.py 第 778 到 783 行。has_merged_entities 检查 resolved_entities 里是否存在 merged_from,存在才调用 _remap_relationship_endpoints。该方法把 merged_from 里的每个旧 id 映射到 canonical id,见第 306 行 endpoint_map[source_id] = canonical_id。如果 resolver 存在但没有任何合并,重映射被跳过,因为端点本来就没变。
如果一条关系的 valid_from 大于 valid_until,会怎样。 validate_temporal_consistency 在 temporal_query.py 第 244 行判断 start > end,命中后追加 issue_type 为 inverted_interval 的错误。这条事实不会让构建崩溃,但会被标成倒置区间,审计时能直接揪出来。
如果关系引用的实体在目标时刻已经失效,会怎样。 reconstruct_at_time 先建活跃实体索引,实体失效就不进索引,见 temporal_query.py 第 191 行;关系的 source 或 target 不在索引里,整条关系被第 203 到 204 行的 continue 跳过。所以一条关系即使本身时间窗有效,只要一端实体当时不活跃,它也不会出现在结果子图里,子图因此保证无悬空边。
横向对比
对比对象是 GraphRAG 的图构建与社区检测。两侧都把「抽取结果变成图」这件事做了,但图的样子完全不同:Semantica 产出带时态属性的平面图,GraphRAG 产出按源和目标聚合、带权重的边表,然后跑分层 Leiden 找社区。
先看 GraphRAG 怎么合并关系。它对每个文本单元抽取出的关系按 source 和 target 分组,description 和 text_unit_ids 收集成列表,weight 求和。
118def _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 )这里没有实体消解,也没有时态字段。边表的最终列是 source、target、description 列表、text_unit_ids 列表、weight。它保留的是「这条边来自哪些文本单元」,这是 GraphRAG 自己的溯源方式,用引用上下文顶替了时态。
GraphRAG 的图下一步进入社区检测。cluster_graph 把关系表交给 _compute_leiden_communities,得到分层社区映射,再拼成 level、community、parent、nodes 四元组。
20def cluster_graph(21 edges: pd.DataFrame,22 max_cluster_size: int,23 use_lcc: bool,24 seed: int | None = None,25) -> Communities:26 """Apply a hierarchical clustering algorithm to a relationships DataFrame."""27 node_id_to_community_map, parent_mapping = _compute_leiden_communities(28 edges=edges,29 max_cluster_size=max_cluster_size,30 use_lcc=use_lcc,31 seed=seed,32 )3334 levels = sorted(node_id_to_community_map.keys())3536 clusters: dict[int, dict[int, list[str]]] = {}37 for level in levels:38 result: dict[int, list[str]] = defaultdict(list)39 clusters[level] = result40 for node_id, community_id in node_id_to_community_map[level].items():41 result[community_id].append(node_id)4243 results: Communities = []44 for level in clusters:45 for cluster_id, nodes in clusters[level].items():46 results.append((level, cluster_id, parent_mapping[cluster_id], nodes))47 return results底层调用的是 graspologic 的分层 Leiden 实现,参数固定 resolution=1.0、iterations=1。
11def hierarchical_leiden(12 edges: list[tuple[str, str, float]],13 max_cluster_size: int = 10,14 random_seed: int | None = 0xDEADBEEF,15) -> list[gn.HierarchicalCluster]:16 """Run hierarchical leiden on an edge list."""17 return gn.hierarchical_leiden(18 edges=edges,19 max_cluster_size=max_cluster_size,20 seed=random_seed,21 starting_communities=None,22 resolution=1.0,23 randomness=0.001,24 use_modularity=True,25 iterations=1,26 )GraphRAG 为什么可以没有时态和实体消解。它的输入是单源文档语料,图是一次性快照式构建,用途是给 global search 生成社区摘要、给 local search 提供邻居上下文。它回答的是「这批文档讲了什么」,这条问题不需要「2023 年 6 月这条事实是否成立」的历史回放,也不需要把 Acme 和 Acme Corp. 精确合并,因为检索时按文本相似度召回,重复实体只是多几个候选节点。我用检索关键词验证过这一侧没有时态实现:在 graphrag 仓库里搜 valid_from、valid_until、recorded_at、bitemporal,数据模型 schemas 和 data_model 目录均无命中。企业多源场景缺了这一层会怎样,第五章已经讲过:同一供应商的多个名称会散成多个节点,历史资质的答案会错到今天的时间点上。
GraphRAG 的实体合并是精确分组。_merge_entities 按 title 和 type 分组,description 和 text_unit_ids 收集成列表,frequency 计数。
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 )注意它是精确匹配:只有 title 和 type 都完全一致才并进一行。Acme 和 Acme Corp. 在 GraphRAG 里会保持两个实体,靠检索时的文本相似度在查询侧兜底;Semantica 则在构建侧用模糊相似度把两个名称合并成一个。两条路线的取舍正好对应各自下游,一个是检索召回,一个是图谱事实。
社区行还要挂回实体和文本单元。create_communities 把每个社区的实体 id、社区内关系 id、文本单元 id 都聚合出来,写成 community 表。
93 title_to_entity_id: dict[str, str] = {}94 async for row in entities_table:95 title_to_entity_id[row["title"]] = row["id"]9697 communities = pd.DataFrame(98 clusters, columns=pd.Index(["level", "community", "parent", "title"])99 ).explode("title")100 communities["community"] = communities["community"].astype(int)101102 # aggregate entity ids for each community103 entity_map = communities[["community", "title"]].copy()104 entity_map["entity_id"] = entity_map["title"].map(title_to_entity_id)105 entity_ids = (106 entity_map107 .dropna(subset=["entity_id"])108 .groupby("community")109 .agg(entity_ids=("entity_id", list))110 .reset_index()111 )112113 # aggregate relationship ids per community, limited to114 # intra-community edges (source and target in the same community).115 # Process one hierarchy level at a time to keep intermediate116 # DataFrames small, then concat the grouped results once at the end.117 level_results = []118 for level in communities["level"].unique():119 level_comms = communities[communities["level"] == level]120 with_source = relationships.merge(121 level_comms, left_on="source", right_on="title", how="inner"122 )123 with_both = with_source.merge(124 level_comms, left_on="target", right_on="title", how="inner"125 )126 intra = with_both[with_both["community_x"] == with_both["community_y"]]127 if intra.empty:128 continue129 grouped = (130 intra131 .explode("text_unit_ids")132 .groupby(["community_x", "parent_x"])133 .agg(134 relationship_ids=("id", list),135 text_unit_ids=("text_unit_ids", list),136 )这里的聚合对象是 id 列表和引用列表,没有时间字段。GraphRAG 的图价值最终落在社区摘要加引用上下文,用来回答这批文档讲了什么;Semantica 的图价值落在带时态的事实加可审计的区间,用来回答某个时间点的事实状态。两者是同一个「图构建」环节的两种终点。
互动演示设计
一句话结论: 双时态查询就是把「事实何时为真」和「数据何时进库」两条时间线分开存、分开查,回答任何一个时间点上的问题。读者在这个模拟器里看到的两条轨道,正好对应 valid 轴和 transaction 轴。
舞台元素与比喻: 把图想象成档案室。每个实体是一张名片,每条事实是一张卡片。卡片正面写事实时间窗 valid_from 到 valid_until,背面盖两个章:进库章 recorded_at、作废章 superseded_at。查询就是拿着一个日期走进档案室,只挑正面窗口覆盖该日期的卡片,再看背面章确认这条记录当时确实已经进库且没被作废。
分步动画与字幕:
- 画面出现三张名片:Acme、Acme Corp.、Acme Corporation,字幕:「三张名片其实是同一家公司,先合并成一张。」
- 名片合成一张,旧名字被划掉并写下 merged_from,字幕:「canonical id 留下,旧 id 记进 merged_from。」
- 关系卡片 CERTIFIED_TO 的端点从旧 id 被橡皮擦改成新 id,字幕:「端点重映射,边不能悬空。」
- 时间轴分成两条轨道:上方是事实时间,下方是记录时间,字幕:「一条事实,两条时间线。」
- 用户拖动查询日期到 2023-06-30,卡片正面窗口亮起,字幕:「只有窗口覆盖这一天的卡片被选中。」
- 用户切到记录时间轴,一张被作废的旧卡片变灰,字幕:「superseded_at 早于查询点的卡片被排除。」
读者可操作项: 在演示里改两个数,观察结果变化:把某条事实的 valid_until 删掉(变成 OPEN),看它是否从某时刻起永远亮起;把 valid_from 删掉,看它是否从时间起点就亮起。再加一个操作:把同一条事实的 recorded_at 改成晚于查询日期,观察它在「当时已知」模式下被排除;再把 superseded_at 设成早于查询日期,观察它在记录时间轴上变灰。这两个操作分别演示事实时间和记录时间如何独立影响结果。
逻辑轨迹面板伪代码(右侧标真实行号):
query_at_time(graph, at_time)
query_time = parse(at_time) # temporal_query.py:144
sub = reconstruct_at_time(graph, query_time) # :148
entity_index = {活跃实体} # :191
for rel in relationships:
if not active_at(rel, query_time): continue # :199
if source/target 不在 entity_index: continue # :203
sub.add(rel)可迁移结论
值得抄的是双时态字段的四件套:valid_from、valid_until、recorded_at、superseded_at,外加一个 OPEN 哨兵表示「至今有效」。这个哨兵的意义在于避免用魔法日期(比如 9999-12-31)表示永远有效,魔法日期会污染区间比较,哨兵则让比较逻辑显式分叉。最小成本形态不需要任何框架,一张表里四列就够:事实时间两列,记录时间两列,查询时加两个 WHERE 条件。这套建模不依赖 Python,换成关系数据库、电子表格、JSON 文档都一样成立。
其次是端点重映射这个补丁的思路:任何「先收集、后消解」的两阶段流程,都要在消解后回头修正引用,否则图会悄悄长出悬空边。Semantica 用 merged_from 记录被合并的旧 id,再统一重写关系端点,这个模式可以直接抄。实体合并时 EntityMerger 默认把 merged_from 写进 metadata.provenance,合并后还能反查每个节点由哪些源实体组成。这个习惯值得抄:任何自动合并都要留一份「谁被并进谁」的记录,否则合并本身就成了不可审计的黑箱。
最小成本形态可以是四列加两个 WHERE。假设一张 fact 表带 valid_from、valid_until、recorded_at、superseded_at,历史回放查询就是 valid_from <= ? AND (valid_until IS NULL OR valid_until > ?),再叠一层 recorded_at <= ? AND (superseded_at IS NULL OR superseded_at > ?) 就是「当时已知的事实」。这种两阶段过滤不需要图数据库,关系库和电子表格都扛得住。
过度设计要指出一处:TemporalGraphQuery 里 query 参数当前不参与任何过滤,文档自己标注「currently unused」,模式检测和演化分析的大部分选项也处于预留状态。如果你的场景只做历史时点回放,query_at_time 加 reconstruct_at_time 就够,不需要把整套 query_temporal_pattern 和 analyze_evolution 搬进去。TemporalVersionManager 的版本对比和 checksum 校验适合有合规留痕要求的场景,轻量使用时可以先不接。
思考题
- 场景还原里那张只有 valid_until 的证书,用 query_at_time 查 2020-01-01 会返回什么结果?结合 active_at 的判定条件说明原因,并指出正确的数据应该补哪个字段。
- 实体 A 的 id=1 被合并进实体 B,B 的 merged_from 里记着 1。有一条关系用键名 source_id 而不是 source 存端点 1,_remap_relationship_endpoints 会不会把它重写?如果这条关系改用键名 entity_id 存端点 1,又会怎样?对照 _remap_relationship_endpoints 的循环逻辑回答。
- 动手验证:在
semantica/kg/temporal_model.py里给 parse_temporal_value 传一个"2023/06/30"这样的斜杠日期字符串,跑一段脚本调用它,观察抛出的是 TemporalValidationError 还是别的异常,再对照 temporal_normalizer.py 的 normalize 方法看两者对斜杠日期的处理差异。