第 7 章
本体生成与校验
场景还原
一家企业把三套遗留系统的数据合进一张知识图谱,全程没有写过一行本体。抽取阶段由 LLM 自由发挥,同一个「客户账户」在不同批次里被写成 CustomerAccount、customer_account、customer account。下游想跑一句 SPARQL 统计账户数,三种写法各查到三分之一,还查不到第四批里被写成 client acct 的那部分。
团队决定亡羊补牢:把图谱反推成一份本体,再生成 SHACL 形状去校验实例。跑完 pySHACL,报告显示 conforms=True,一片绿。工程师松了一口气,直到有人抽查发现,形状其实一个目标节点都没匹配上。pySHACL 对「没有焦点节点的形状」视为平凡成立,于是全绿成了全瞎。这个坑在仓库的回归测试里被明确记录了下来,本意是提醒后来者:形状和目标命名空间是两回事。
1"""2Regression tests for #1104 and #1105.34#1104: SHACLGenerator used one namespace for two different jobs. `base_uri`5names where the shape resources live, and it was also used to expand every6sh:targetClass and sh:path. With the default `https://semantica.dev/shapes/`7that made shapes target `.../shapes/Person`, while data carries8`.../ns#Person` or the ontology's own class IRI. The shapes matched nothing.9pySHACL then reported conforms=True, because a shape with no focus nodes is10vacuously satisfied, so the mismatch was invisible to the validator the11package ships with.1213#1105: a property with no declared domain was attached to every node shape,14which invents a constraint the ontology never stated. With minCount 1 that15makes every instance of every class invalid.1617These tests validate real data through pySHACL rather than reading the shapes18text, so a shape that targets nothing cannot pass by being ignored.19"""这一章跟完整流水线走一遍:杂乱数据怎么先变成一份 OWL 本体,本体又怎么变成一组 SHACL 形状,最后怎么用形状把实例卡住。全程只看 semantica/ontology/ 一个目录。
逐行精读
模块自述:六段管线
模块 docstring 是第一手规范材料,把整条管线压缩成六段,并点明输出形态是「structured OWL ontologies」。整条链路和两条出口如下。
1"""2Ontology Generation Module34This module handles automatic generation of ontologies from data and text using5a 6-stage pipeline that transforms raw data into structured OWL ontologies.67Key Features:8 - Automatic ontology generation (6-stage pipeline)9 - Class and property inference10 - Ontology structure optimization11 - Domain-specific ontology creation12 - Ontology quality assessment13 - Semantic network parsing14 - Hierarchy generation1516Main Classes:17 - OntologyGenerator: Main ontology generation class (6-stage pipeline)OntologyGenerator 的类 docstring 把六段名字逐个列了出来。注意第 5 段和第 6 段写的是 TTL 生成与符号推理,真正落地时第 5 段被 OWLGenerator 接手,第 6 段在 OntologyValidator 里只留了一个占位实现,这个落差后面单独展开。
49class OntologyGenerator:50 """51 Ontology generation handler with 6-stage pipeline.5253 6-Stage Pipeline:54 1. Semantic Network Parsing → Extract domain concepts55 2. YAML-to-Definition → Transform into class definitions56 3. Definition-to-Types → Map to OWL types57 4. Hierarchy Generation → Build taxonomic structures58 5. TTL Generation → Generate OWL/Turtle syntax using rdflib, triplet generation (subject-predicate-object)59 6. Symbolic Validation → HermiT/Pellet reasoning6061 • Generates ontologies from data and text62 • Infers classes and properties automatically63 • Creates domain-specific ontologies64 • Optimizes ontology structure65 • Validates ontology quality66 • Supports various ontology formats67 """入口:generate_ontology 怎么串起六段
generate_ontology 是主入口。它把六段依次调一遍,最后在第 6 段把 OntologyValidator.validate 的结果塞进返回字典的 validation 键。这里有两个值得记住的细节:第一,校验默认开启(options.get("validate", True)),第二,校验结果只被写回字典和打一条 warning,它不阻断返回。
197 # Stage 6: Symbolic Validation198 if options.get("validate", True):199 self.progress_tracker.update_tracking(200 tracking_id, message="Stage 6: Validating ontology..."201 )202 validation_result = self.validator.validate(ontology)203 ontology["validation"] = {204 "valid": validation_result.valid,205 "consistent": validation_result.consistent,206 "satisfiable": validation_result.satisfiable,207 "errors": validation_result.errors,208 "warnings": validation_result.warnings209 }210 if not validation_result.valid:211 self.logger.warning(f"Ontology validation failed: {validation_result.errors}")输入是一个 data 字典,里面放 entities 和 relationships 两个列表。这正说明本体的生成时机在图构建之后:它吃的是已经抽好的实体关系,不直接吃文本。generate_from_graph 是同一个方法的别名,签名注释写得直白,graph 就是 GraphBuilder 的输出。
226 def generate_from_graph(self, graph: Dict[str, Any], **options) -> Dict[str, Any]:227 """228 Generate ontology from a knowledge graph.229230 Alias for generate_ontology.231232 Args:233 graph: Knowledge graph dictionary (output from GraphBuilder)234 **options: Additional options235236 Returns:237 Generated ontology dictionary238 """239 return self.generate_ontology(graph, **options)六段共享同一个 tracking_id,每一段开始前更新一句状态消息,结束或失败时各写一次 stop_tracking。这是全仓统一的进度观测方式,本体的六段和 SHACL 的六步都套在这层壳里。另一个细节:第 5 段在 generate_ontology 里只有一行注释,正文写明 TTL 生成由 OWLGenerator 接手,主流程实际执行的是五段再加一个外部调用。
读这条链路的顺序和数据的走向一致:先看输入是什么形状,再看每一段把中间产物改造成什么,最后看两条出口各自产出什么。下面按这个顺序推进,每段先贴代码、再讲它吃进什么吐出什么。
第 1 段:把五花八门的实体归一成字典
_stage1_parse_semantic_network 干的第一件事是归一化。上游可能塞进来 dict、带 label/text 属性的 Entity 对象、[text, label] 二元组,甚至批量的列表套列表。嵌套函数 process_entity 把三种形状都折成同一种字典,Entity 对象分支还顺手把 start_char、end_char、confidence 用 getattr 兜底带出来。
249 raw_entities = data.get("entities", [])250 relationships = data.get("relationships", [])251252 # Normalize entities253 entities = []254255 def process_entity(ent):256 """Normalize entity to dictionary."""257 if isinstance(ent, dict):258 return ent259 # Handle Entity object (from NERExtractor)260 if hasattr(ent, "label") and hasattr(ent, "text"):261 return {262 "type": ent.label,263 "name": ent.text,264 "entity_type": ent.label,265 "text": ent.text,266 "start_char": getattr(ent, "start_char", 0),267 "end_char": getattr(ent, "end_char", 0),268 "confidence": getattr(ent, "confidence", 1.0),269 "metadata": getattr(ent, "metadata", {}),270 }271 # Handle list/tuple (legacy or raw format)272 if isinstance(ent, (list, tuple)) and len(ent) >= 2:273 # Assume [text, label, ...]274 return {275 "name": str(ent[0]),276 "text": str(ent[0]),277 "type": str(ent[1]),278 "entity_type": str(ent[1]),279 }280 return None281282 # Handle list of lists (batch output) or flat list283 for item in raw_entities:284 if isinstance(item, list):285 # Check if it's a list of entities (batch) or a single entity as list286 # If the first element is also a list or Entity object, it's a batch287 if len(item) > 0 and (288 isinstance(item[0], list) or hasattr(item[0], "label")289 ):290 for sub_item in item:291 processed = process_entity(sub_item)归一化之后,实体按 type 分组成 concepts,关系也补上 source_type/target_type。这一步的产物是后续所有推断的唯一输入口径。
关系的归一化同样要补齐类型:当一条关系的 source_type 缺失或等于默认的 Entity 时,第 1 段用 source 的名字去实体列表里反查。反查是两层循环按名字线性扫,本体生成阶段实体规模通常不大,所以没有建索引;数据量级涨上去后,这一环会先成为瓶颈。
第 2、3 段:类与属性推断
第 2 段把实体交给 ClassInferrer 推断类,第 3 段把关系交给 PropertyGenerator 推断属性。类推断的核心是一个出现次数阈值:只有某类型的实体数量达到 min_occurrences(默认 2)才升级成一个类,单次出现的杂音类型被丢弃。
151 # Infer classes from entity types152 self.progress_tracker.update_tracking(153 tracking_id,154 message=f"Inferring classes from {len(entity_types)} entity types...",155 )156 classes = []157 for entity_type, type_entities in entity_types.items():158 if len(type_entities) >= self.min_occurrences:159 class_def = self._create_class_from_entities(160 entity_type, type_entities, **options161 )162 classes.append(class_def)类推断之后还有一步层次构建。build_class_hierarchy 给每个还没有父类的类找一个父类,_find_parent_class 的启发式朴素:把类名按空格切成词,从右往左逐个去掉末尾词,看剩下的词拼起来是否是一个已存在的类,命中就当作父类。
308 # Simple heuristic: look for more general class names309 words = class_name.split()310311 # Try to find parent by removing words312 for i in range(len(words) - 1, 0, -1):313 parent_candidate = "".join(words[:i])314 if parent_candidate in class_map:315 return parent_candidate316317 # Check for common parent classes318 common_parents = ["Entity", "Thing", "Resource"]319 for parent in common_parents:320 if parent in class_map:321 return parent322323 return None这套规则只对多词类名有效,单词类名拆不出前缀,最后落到 Entity、Thing、Resource 三个通用父类里找,找不到就返回 None。它对语义一无所知,Manager 能不能被识别成 Employee 的子类,取决于名字本身。
对象属性推断走的是同一套路:关系按 type 分组,达到 min_occurrences 的关系类型才成为对象属性,domain 和 range 从关系的 source_type/target_type 集合里收集。
133 def _infer_object_properties(134 self,135 relationships: List[Dict[str, Any]],136 classes: List[Dict[str, Any]],137 **options,138 ) -> List[Dict[str, Any]]:139 """Infer object properties from relationships."""140 # Group relationships by type141 rel_types = defaultdict(list)142 for rel in relationships:143 rel_type = rel.get("type") or rel.get("relationship_type", "relatedTo")144 rel_types[rel_type].append(rel)145146 # Create class map147 class_map = {cls["name"]: cls for cls in classes}148149 properties = []150 for rel_type, rels in rel_types.items():151 if len(rels) >= options.get("min_occurrences", 2):152 # Infer domain and range153 domains = set()154 ranges = set()155156 for rel in rels:157 source_type = rel.get(158 "source_type"159 ) or self._infer_class_from_entity(rel.get("source_id"), classes)160 target_type = rel.get(161 "target_type"162 ) or self._infer_class_from_entity(rel.get("target_id"), classes)163164 if source_type:165 domains.add(source_type)166 if target_type:167 ranges.add(target_type)168169 # Normalize property name170 prop_name = self.naming_conventions.normalize_property_name(171 rel_type, "object"172 )domains 和 ranges 用集合收集,天然去重,多个来源重复指向同一个类时不会产生重复约束。若关系里拿不到 source_type 或 target_type,会走 _infer_class_from_entity 兜底,而它目前只返回 None,最终 domain 和 range 落到 ["owl:Thing"],这是本体里最宽泛的类型,等于「暂时说不清属于哪一类」。
数据属性走另一条路:看实体字典里除了 id/type/text 等保留键之外还带了哪些键,逐个推断 XSD 类型。布尔、整数、浮点、字符串依次匹配,字符串还要再过一遍日期和日期时间的正则。
269 def _infer_property_type(self, value: Any) -> str:270 """Infer property type from value."""271 if isinstance(value, bool):272 return "xsd:boolean"273 elif isinstance(value, int):274 return "xsd:integer"275 elif isinstance(value, float):276 return "xsd:double"277 elif isinstance(value, str):278 # Check if it's a date279 if self._is_date(value):280 return "xsd:date"281 elif self._is_datetime(value):282 return "xsd:dateTime"283 else:284 return "xsd:string"285 else:286 return "xsd:string"当同一个属性名在不同实体里撞出不同的 Python 类型时,_extract_data_properties 会调 _get_more_general_type 取更宽的一档。布尔、整数、双精度、日期、日期时间、字符串排成一个六级阶梯,数字越大越宽,冲突时选级别高的那个。这是用类型阶梯替代「报错或取首个」的折中,保证推断出的 range 能容纳所有已见取值。
第 3 段的收尾是给类和属性补 @type 与 IRI。这段注释直接引用了一个历史 bug 编号:ClassInferrer 在没拿到 namespace manager 时会设 "uri": None,键是存在的,于是 not in 判断永远不触发,类带着空 IRI 一路流到导出器。修复手法是改用 cls.get("uri") 判空,直接检查值是否为真。
443 # Add types to classes.444 # ClassInferrer sets "uri": None when it was given no namespace manager,445 # so the key is present and a `not in` guard never fires: every class446 # then reached the exporters with no IRI at all (#1103).447 for cls in classes:448 cls["@type"] = "owl:Class"449 if not cls.get("uri"):450 cls["uri"] = self.namespace_manager.generate_class_iri(cls["name"])451452 # Add types to properties453 for prop in properties:454 if prop["type"] == "object":455 prop["@type"] = "owl:ObjectProperty"456 else:457 prop["@type"] = "owl:DatatypeProperty"458459 if not prop.get("uri"):460 prop["uri"] = self.namespace_manager.generate_property_iri(prop["name"])IRI 的生成由 NamespaceManager 完成。get_base_uri 在版本不是 1.0 时把版本拼进路径,generate_class_iri 默认开 speaking IRI,把类名转成 PascalCase 直接接到 base URI 后面;关掉后改用 md5 前八位,得到 class/ 加哈希的不可读形式。注释里带 nosec B324,声明这个 md5 只做确定性后缀,不作安全用途。
113 # Convert to PascalCase114 class_name = self._to_pascal_case(class_name)115116 # Generate speaking IRI if enabled117 if self.use_speaking_iris:118 iri = urljoin(self.get_base_uri(), class_name)119 else:120 # Use hash-based IRI121 import hashlib122123 hash_id = hashlib.md5(class_name.encode()).hexdigest()[:8] # nosec B324 - deterministic IRI suffix, not security-sensitive124 iri = urljoin(self.get_base_uri(), f"class/{hash_id}")125126 return iri第 5 段:rdflib 把字典变成 Turtle
第 5 段由 OWLGenerator 完成。有 rdflib 时走 _generate_with_rdflib,没有时走字符串拼接的 _generate_basic 兜底。下面这段是 rdflib 路径里建本体资源、写 label 和 version 的部分,输出是标准 rdf:type owl:Ontology 三元组。
215 gen_ns_manager = self._get_generation_namespace_manager(ontology)216217 # Set up namespaces218 ns_manager = RDFNamespaceManager(g)219220 # Register standard namespaces221 for prefix, uri in self.namespace_manager.get_all_namespaces().items():222 ns = Namespace(uri)223 ns_manager.bind(prefix, ns)224 g.bind(prefix, ns)225226 # Register ontology namespace227 base_uri = ontology.get("uri") or self.namespace_manager.get_base_uri()228 if isinstance(base_uri, str) and not base_uri.endswith(("/", "#")):229 base_uri = base_uri + "/"230 ont_ns = Namespace(base_uri)231 g.bind("", ont_ns)232233 # Create ontology resource234 ont_uri = URIRef(base_uri)235 g.add((ont_uri, RDF.type, OWL.Ontology))236237 # Add ontology metadata238 if ontology.get("name"):239 g.add((ont_uri, RDFS.label, Literal(ontology["name"])))240 if ontology.get("version"):241 g.add((ont_uri, OWL.versionInfo, Literal(ontology["version"])))242243 # Add classes244 classes = ontology.get("classes", [])245 for cls in classes:246 class_name = self._resolve_class_identifier(cls)247 class_uri = URIRef(248 cls.get("uri")249 or gen_ns_manager.generate_class_iri(class_name)250 )OWLGenerator 顶部有 HAS_RDFLIB 开关,try 导入 rdflib 失败时把 Graph、RDF、OWL 等置为 None,后续改走 _generate_basic 的字符串拼接路径。两条路径产出同样的 Turtle 结构,差别只在三元组由 rdflib 管理还是手写 f-string。导出前不强制安装 rdflib,本体生成的硬依赖因此保持在最小。
SHACL 生成器:本体的另一条出口
OWL 描述「世界长什么样」,SHACL 描述「什么形状算合格」。SHACLGenerator 住在 ontology_generator.py 后半段,它有自己的一套内部管线,文档里也写了六步。
794class SHACLGenerator:795 """796 Generates SHACL shapes from Semantica OWL ontology dicts.797798 6-stage internal pipeline:799 1. _build_class_index() — {class_name: class_dict} for O(1) lookup800 2. _generate_node_shapes() — one NodeShape per OWL class801 3. _attach_property_shapes() — map properties to their domain node shapes802 4. _propagate_inheritance() — copy parent shapes to children (iterative, cycle-safe)803 5. _apply_quality_tier() — strict tier: set closed=True on all shapes804 6. serialize() — Turtle / JSON-LD / N-Triples805 """生成与校验两段是分离的两次调用,先产形状再跑实例校验。
形状的内部模型是三个 dataclass。PropertyShape 对应 sh:PropertyShape,字段逐一对到 sh:datatype、sh:class、sh:minCount、sh:maxCount、sh:in、sh:hasValue、sh:pattern。
732@dataclass733class PropertyShape:734 """Internal model for a SHACL sh:PropertyShape."""735 path: str736 name: Optional[str] = None737 description: Optional[str] = None738 datatype: Optional[str] = None # sh:datatype739 class_: Optional[str] = None # sh:class740 min_count: Optional[int] = None741 max_count: Optional[int] = None742 in_values: Optional[List[str]] = None743 has_value: Optional[str] = None744 pattern: Optional[str] = None745 severity: str = "Violation"746747748@dataclass749class NodeShape:750 """Internal model for a SHACL sh:NodeShape."""751 target_class: str752 name: Optional[str] = None753 description: Optional[str] = None754 property_shapes: List[PropertyShape] = field(default_factory=list)755 closed: bool = False756 severity: str = "Violation"SHACLGraph 里最耐人寻味的是 class_iris 和 property_iris 两张分开的索引。注释讲清了为什么:类名和属性名可能合法地重名,如果共用一张名字到 IRI 的映射,属性会静默拿到类的 IRI。
778@dataclass779class SHACLGraph:780 """Internal model representing the complete SHACL shapes graph."""781 base_uri: str782 shapes_uri: str783 node_shapes: List[NodeShape] = field(default_factory=list)784 prefixes: Dict[str, str] = field(default_factory=dict)785 # Bare names mapped to the absolute IRI the data uses for them. Shapes are786 # indexed internally by name; this is what those names expand to at787 # serialisation time (#1104). Classes and properties are kept apart because788 # a property may legitimately share a class's name, and a single map would789 # silently give it the class's IRI.790 class_iris: Dict[str, str] = field(default_factory=dict)791 property_iris: Dict[str, str] = field(default_factory=dict)六步声明和六步执行对得上。generate 先解出 target_ns,把类名和属性名分别建索引塞进 SHACLGraph,再依次生成节点形状、挂属性、传播继承、套质量档,最后返回图对象;序列化是 serialize 的独立一步。
887 target_ns = self._resolve_target_namespace(ontology, base_uri)888 prefixes["ex"] = target_ns889890 graph = SHACLGraph(891 base_uri=base_uri,892 shapes_uri=self.shapes_uri,893 prefixes=prefixes,894 class_iris=self._build_term_index(classes, target_ns),895 property_iris=self._build_term_index(properties, target_ns),896 )897898 self.progress_tracker.update_tracking(tracking_id, message="Generating node shapes")899 class_index = self._build_class_index(classes)900 self._generate_node_shapes(graph, classes)901902 self.progress_tracker.update_tracking(tracking_id, message="Attaching property shapes")903 self._attach_property_shapes(graph, properties)904905 if self.include_inherited:906 self.progress_tracker.update_tracking(tracking_id, message="Propagating inheritance")907 self._propagate_inheritance(graph, class_index)908909 self._apply_quality_tier(graph)注意 class_iris 和 property_iris 在构造 SHACLGraph 时就按同一个 target_ns 展开好,序列化阶段只是查表,命名空间决策被集中在这一个方法里完成。
_resolve_target_namespace 是 #1104 的正解所在。它的任务只有一个:决定 sh:targetClass 和 sh:path 该在哪个命名空间里展开。优先级从显式 target_namespace 参数,到本体的 namespace.base_uri,再到术语自带 IRI 推导出的前缀,最后才轮到默认值。形状自己的 base_uri 只描述形状资源住在哪,不参与目标命名空间的推断。
977 def _resolve_target_namespace(self, ontology: Dict[str, Any], base_uri: str) -> str:978 """979 Decide which namespace sh:targetClass and sh:path are expanded in.980981 base_uri says where the shapes live. It is only the right answer here982 when the ontology declared it, meaning shapes and terms deliberately983 share a namespace. Falling back to the shapes namespace produces shapes984 that target terms no data graph uses.985 """986 if self.target_namespace:987 return self.target_namespace988989 namespace = ontology.get("namespace")990 if isinstance(namespace, dict) and namespace.get("base_uri"):991 return str(namespace["base_uri"])992993 # An IRI already carried by a term is the most reliable evidence of994 # where the data lives, so prefer it over any configured default.995 for terms in (ontology.get("classes"), ontology.get("properties")):996 for term in terms or []:997 if not isinstance(term, dict):998 continue999 for key in ("uri", "iri", "id"):1000 value = term.get(key)1001 if self._is_absolute_iri(value):1002 value = value.strip()1003 cut = max(value.rfind("#"), value.rfind("/"))1004 if cut != -1:1005 return value[: cut + 1]10061007 if ontology.get("uri") and self._is_absolute_iri(ontology["uri"]):1008 return str(ontology["uri"])10091010 if base_uri != self.base_uri:1011 return base_uri10121013 return self._DEFAULT_TARGET_NAMESPACE挂属性时,_attach_property_shapes 只认声明了 domain 的属性;无 domain 的属性默认不挂到任何形状,只打一条 warning。这个默认值对应的正是 #1105:把无 domain 属性挂到所有类,等于发明了一条本体从未声明过的约束,配上 minCount 1 会把每个类的每个实例都判成违规。
1058 def _attach_property_shapes(1059 self, graph: SHACLGraph, properties: List[Dict[str, Any]]1060 ) -> None:1061 shape_by_class = {ns.target_class: ns for ns in graph.node_shapes}10621063 for prop in properties:1064 pname = prop.get("name")1065 if not pname:1066 continue10671068 domain = prop.get("domain")1069 if isinstance(domain, list):1070 domains = [d for d in domain if d]1071 elif isinstance(domain, str) and domain:1072 domains = [domain]1073 else:1074 domains = []10751076 if domains:1077 for d in domains:1078 if d in shape_by_class:1079 shape_by_class[d].property_shapes.append(1080 self._build_property_shape(prop)1081 )1082 else:1083 self.logger.debug(1084 f"Property '{pname}' domain '{d}' has no matching node shape — skipped"1085 )1086 elif self.attach_domainless_properties:1087 self.logger.warning(1088 f"Property '{pname}' declares no domain and is being attached "1089 "to every node shape because attach_domainless_properties is "1090 "set. This states a constraint the ontology does not."1091 )1092 for node_shape in graph.node_shapes:1093 node_shape.property_shapes.append(self._build_property_shape(prop))1094 else:1095 # Attaching here would state a constraint the ontology does not.1096 # With minCount 1 that invalidates every instance of every1097 # class, so the property is left unattached (#1105).1098 self.logger.warning(1099 f"Property '{pname}' declares no domain, so it is not attached "1100 "to any node shape. Declare a domain, or pass "1101 "attach_domainless_properties=True to restore the old behaviour."1102 )_build_property_shape 是本体字典到 SHACL 形状的最后一道翻译。required 只在本体没给 cardinality.min 时补成 minCount 1,显式的 cardinality 优先。数据属性走 sh:datatype,对象属性走 sh:class,两条路互斥。in_values 和 pattern 只在 standard 或 strict 档进入形状,basic 档直接置空,档位在这里第二次起作用。
1104 def _build_property_shape(self, prop: Dict[str, Any]) -> PropertyShape:1105 ptype = prop.get("type", "")1106 range_ = prop.get("range", "")1107 if isinstance(range_, list):1108 range_ = range_[0] if range_ else ""11091110 cardinality = prop.get("cardinality") or {}1111 min_count = cardinality.get("min") if isinstance(cardinality, dict) else None1112 max_count = cardinality.get("max") if isinstance(cardinality, dict) else None11131114 if prop.get("required") and min_count is None:1115 min_count = 111161117 datatype = None1118 class_ = None1119 if ptype in ("datatype", "data", "DatatypeProperty"):1120 datatype = self._resolve_xsd(range_) if range_ else None1121 elif ptype in ("object", "ObjectProperty"):1122 class_ = range_ if range_ else None11231124 in_values = (1125 prop.get("one_of") or prop.get("enum") or prop.get("allowed_values")1126 )1127 if in_values and self.quality_tier in ("standard", "strict"):1128 in_values = list(in_values)1129 else:1130 in_values = None11311132 pattern = prop.get("pattern") if self.quality_tier in ("standard", "strict") else None11331134 return PropertyShape(1135 path=prop.get("name", ""),1136 name=prop.get("label") or prop.get("name"),1137 description=prop.get("description") or prop.get("comment"),1138 datatype=datatype,1139 class_=class_,1140 min_count=min_count,1141 max_count=max_count,1142 in_values=in_values,1143 has_value=prop.get("has_value"),1144 pattern=pattern,1145 severity=self.severity,1146 )继承传播用「最多 20 轮、稳定即停」的迭代法把父形状的属性拷贝给子形状,同时用 existing_paths 去重,避免反复追加同一条 sh:path。
1148 def _propagate_inheritance(1149 self, graph: SHACLGraph, class_index: Dict[str, Dict[str, Any]]1150 ) -> None:1151 shape_by_class = {ns.target_class: ns for ns in graph.node_shapes}11521153 for _ in range(20): # max 20 passes; stops early when stable1154 changed = False1155 for node_shape in graph.node_shapes:1156 cls_data = class_index.get(node_shape.target_class, {})1157 parent_name = cls_data.get("parent") or cls_data.get("parent_class")1158 if not parent_name or parent_name not in shape_by_class:1159 continue1160 parent_shape = shape_by_class[parent_name]1161 existing_paths = {ps.path for ps in node_shape.property_shapes}1162 for pps in parent_shape.property_shapes:1163 if pps.path not in existing_paths:1164 node_shape.property_shapes.append(dataclass_replace(pps))1165 existing_paths.add(pps.path)1166 changed = True1167 if not changed:1168 break质量档 strict 会把「至少声明了一个属性」的形状设为 closed,关闭态意味着实例上出现任何未声明的属性都算违规。三档之间的切换关系如下。
1170 def _apply_quality_tier(self, graph: SHACLGraph) -> None:1171 if self.quality_tier == "strict":1172 for node_shape in graph.node_shapes:1173 # Only close shapes that declare at least one property1174 if node_shape.property_shapes:1175 node_shape.closed = True序列化统一走 _term_iri,它用 kind 参数在类索引和属性索引之间切换,这是让同名类属性各归各位的最后一道闸。
1182 def _term_iri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str:1183 """1184 Resolve a class or property name to the absolute IRI the data uses.11851186 Every serializer goes through here. Turtle alone was corrected at first,1187 which left JSON-LD and N-Triples still pasting names onto the shapes1188 namespace, so the shapes they produced went on matching nothing (#1104).11891190 `kind` selects the index: a property may share a class's name, and the1191 two can carry different IRIs.1192 """1193 if local.startswith("http://") or local.startswith("https://"):1194 return local1195 index = graph.property_iris if kind == "property" else graph.class_iris1196 resolved = index.get(local)1197 if resolved:1198 return resolved1199 # Fall back to the other index before giving up: sh:class names a class,1200 # but a caller may pass a term only registered on the other side.1201 other = graph.class_iris if kind == "property" else graph.property_iris1202 resolved = other.get(local)1203 if resolved:1204 return resolved1205 if ":" in local:1206 return local1207 separator = "" if graph.base_uri.endswith(("#", "/", ":")) else "#"1208 return f"{graph.base_uri}{separator}{local}"序列化到 Turtle 时,closed 形状会额外输出一行 sh:ignoredProperties,把 rdf:type 放进去豁免。这样关闭态只拦本体未声明的属性,不会因为每个实例天生带着 rdf:type 三元组而全体违规。
1234 if node_shape.closed:1235 block.append(" sh:closed true ;")1236 block.append(" sh:ignoredProperties ( <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ) ;")12371238 for i, ps in enumerate(node_shape.property_shapes):1239 is_last = i == len(node_shape.property_shapes) - 11240 terminator = " ." if is_last else " ;"三种序列化各有形状。Turtle 直接拼 sh:property [ ... ] 的方括号匿名节点,JSON-LD 用 @context 加 @graph 包装,N-Triples 给每个属性形状分配一个 _:ps{i}_{j} 的空白节点标识。三条路最终都从 _term_iri 取 IRI,格式差异只发生在序列化层,语义图不变。
校验侧:占位推理与真实 SHACL
OntologyValidator 是第 6 段「符号校验」的载体。读完它的 validate 会发现,一致性检查和可满足性检查各留了一个 pass,注释直接写着占位。换句话说,OWL 层面的 HermiT/Pellet 推理在这个提交里是没接线的。
340 # Placeholder implementation for now341 # In a real implementation, this would load owlready2 or similar⋯ # ... 空行略 ...· try:· if isinstance(ontology, dict):· self._validate_structure(ontology, result)⋯ # ... 空行略 ...· # Simulate reasoning checks· if self.check_consistency:· # Logic to check consistency would go here· pass⋯ # ... 空行略 ...352 if self.check_satisfiability:353 # Logic to check satisfiability would go here354 pass它返回的结果对象本身是完整的:valid、consistent、satisfiable 三个布尔加上错误和警告两个列表。
290@dataclass291class ValidationResult:292 """Result of an ontology validation operation."""293 valid: bool = True294 consistent: bool = True295 satisfiable: bool = True296 errors: List[str] = field(default_factory=list)297 warnings: List[str] = field(default_factory=list)真正干活的校验是 run_shacl_validation。它先做可选的 import 守卫,缺 pyshacl 或 rdflib 就抛带安装提示的 ImportError,然后解析数据图和形状图,调用 pyshacl.validate。两个参数值得注意:inference="none" 关闭推理,abort_on_first=False 要求收集全部违例。
167 try:168 import pyshacl169 except ImportError as exc:170 raise ImportError(171 "pyshacl is required for SHACL validation. "172 "Install it with: pip install semantica[shacl]"173 ) from exc174175 try:176 import rdflib177 except ImportError as exc:178 raise ImportError(179 "rdflib is required for SHACL validation. "180 "Install it with: pip install rdflib"181 ) from exc182183 data_g = rdflib.Graph()184 data_g.parse(data=data_graph_str, format=data_graph_format)185186 _fmt_map = {187 "turtle": "turtle", "ttl": "turtle",188 "json-ld": "json-ld", "jsonld": "json-ld", "json_ld": "json-ld",189 "n-triples": "nt", "ntriples": "nt", "nt": "nt",190 }191 shacl_g = rdflib.Graph()192 shacl_g.parse(data=shacl_str, format=_fmt_map.get(shacl_format.lower().strip(), shacl_format))193194 conforms, results_graph, results_text = pyshacl.validate(195 data_g,196 shacl_graph=shacl_g,197 inference="none",198 abort_on_first=False,199 )校验报告不把原始英文塞给用户,explain_violations 内置七类约束组件的模板,用真实参数渲染成一句白话。注释特意强调「No LLM call」,说明解释这一步是确定性的模板填充。
82 def explain_violations(self) -> None:83 """Populate a plain-English explanation on every violation. No LLM call."""84 _TEMPLATES = {85 "MinCountConstraintComponent": (86 "Node <{focus_node}> is missing required property <{path}>. "87 "At least {min_count} value(s) are required."88 ),89 "MaxCountConstraintComponent": (90 "Node <{focus_node}> has too many values for <{path}>. "91 "At most {max_count} value(s) are allowed."92 ),93 "DatatypeConstraintComponent": (94 "Node <{focus_node}> has value '{value}' for <{path}> "95 "but the expected datatype is {datatype}."96 ),97 "ClassConstraintComponent": (98 "Node <{focus_node}> has value '{value}' for <{path}> "99 "but it must be an instance of {class_}."100 ),101 "InConstraintComponent": (102 "Node <{focus_node}> has value '{value}' for <{path}> "103 "which is not in the allowed set."104 ),105 "PatternConstraintComponent": (106 "Node <{focus_node}> has value '{value}' for <{path}> "107 "which does not match the required pattern."108 ),109 "ClosedConstraintComponent": (110 "Node <{focus_node}> has undeclared property <{path}> "111 "which is not allowed by the closed shape."112 ),113 }到这里,本体的两条出口就齐了:一条走向 OWL(描述世界),一条走向 SHACL(约束实例)。
设计决策分析
生成时机放在图构建之后。 证据有两条:generate_ontology 的输入是 entities/relationships 两个列表,generate_from_graph 的注释直接把 graph 标成 output from GraphBuilder。这意味着本体是「反推」出来的:先有实体和关系的统计分布,再决定哪些类型值得升级成类。这样做的直接好处是,本体与数据同源,不会出现「本体说有一套类,数据里从来没出现过」的空转。代价也明显,反推只反映已见数据,未见过的类型不会出现在本体里,这是推断,不是声明。
最小出现次数与两段式校验各兜一层底。 min_occurrences 默认值 2 是一个统计闸:出现一次的类型多半是 LLM 抽取的孤例或拼写变体,升成类只会把噪声固化进 schema。校验侧则分两层,OntologyValidator._validate_structure 只查「有没有 classes 和 properties 这两个键」,真正卡实例的是 run_shacl_validation。前者便宜且随时可跑,后者重但只在要验数据时跑。
生成与校验拆成两个类。 OntologyGenerator 管本体,SHACLGenerator 管形状,中间靠同一个 ontology 字典衔接。拆开的直接收益是校验可以独立演进:#1104 的修复只动了 SHACLGenerator 和 OntologyEngine.to_shacl,六段生成逻辑一行没改。
形状从本体自动派生。 SHACL 形状不手写,由 SHACLGenerator 从 ontology 字典逐类生成。手写形状容易和本体漂移,自动派生保证本体每改一处,形状跟着重算,描述口径与校验口径同源。
OWL、SHACL、SKOS 三种标准各管一段。 命名空间管理器把三个前缀都注册了进去,其中 skos 指向 http://www.w3.org/2004/02/skos/core#。OWL 管描述:类、属性、subClassOf 层次,回答「有哪些概念」。SHACL 管约束:sh:minCount、sh:datatype、sh:closed,回答「什么算合格实例」。SKOS 管词表:概念方案和概念之间的对齐。build_concept_scheme_uri 把人类可读的名称转成 vocab/ 下的 ConceptScheme URI。
223 def build_concept_scheme_uri(self, name: str) -> str:224 """225 Build a ConceptScheme URI anchored at the current base URI.226227 The scheme name is slugified (spaces → hyphens, lower-cased) so that228 ``"My Vocabulary"`` becomes ``<base>/vocab/my-vocabulary>``.229230 Args:231 name: Human-readable vocabulary name232233 Returns:234 ConceptScheme URI string235 """236 slug = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()237 return urljoin(self.get_base_uri(), f"vocab/{slug}")对齐谓词同时借用 OWL 和 SKOS 两套词汇:equivalentClass、sameAs 来自 OWL,exactMatch、closeMatch 来自 SKOS。
249 return {250 #OWL alignments251 "equivalentClass": f"{owl_ns}equivalentClass",252 "equivalentProperty": f"{owl_ns}equivalentProperty",253 "sameAs": f"{owl_ns}sameAs",254 #SKOS alignments255 "exactMatch": f"{skos_ns}exactMatch",256 "closeMatch": f"{skos_ns}closeMatch",257 "broadMatch": f"{skos_ns}broadMatch",258 "narrowMatch": f"{skos_ns}narrowMatch",259 "relatedMatch": f"{skos_ns}relatedMatch",形状命名空间与目标命名空间必须分离。 这是 #1104 沉淀出的设计:base_uri 说形状资源住在哪,target_namespace 说被校验的术语住在哪,两者是构造参数里分开的两个东西。文档里那句「shapes that target their own namespace match nothing, and pySHACL reports that as conforming」点破了不分离的后果。这个设计不依赖任何 Python 特性,换成任何能输出 RDF 的语言,只要生成 SHACL,都要回答「shape 住哪、target 指哪」这两个问题。
校验解释不调 LLM。 explain_violations 的模板填充把违例翻成白话,注释明确排除 LLM。这符合整本书的暗线:可审计的结论应当能指回来源,模板是确定性的,来源就是形状里的参数,审计时无需再信任一次模型。
边界条件剖析
如果某类实体只出现一次会怎样。 ClassInferrer.infer_classes 在 semantica/ontology/class_inferrer.py:158 的 if len(type_entities) >= self.min_occurrences 处把它挡在类列表之外,它不会出现在本体里,但第 1 段的 concepts 字典仍会保留它。下游若期望「所有出现的类型都是类」,会在这里失配。
如果属性没声明 domain 会怎样。 走 _attach_property_shapes 的 else 分支(semantica/ontology/ontology_generator.py:1095),属性不挂到任何节点形状,只发 warning。若显式传 attach_domainless_properties=True,改走 elif 分支,把属性挂到全部节点形状,此时配上 minCount 1 会让每个实例都违规。这是 #1105 的核心教训。
如果类和属性同名会怎样。 一个叫 Account 的类和一条叫 Account 的数据属性同时存在,_term_iri 用 kind 参数分别查 class_iris 和 property_iris(semantica/ontology/ontology_generator.py:1196),sh:path 拿到属性 IRI,sh:targetClass 拿到类 IRI。回归测试 test_a_property_sharing_a_class_name_keeps_its_own_iri 专门锁住了这个行为。
如果质量档调到 strict 会怎样。 _apply_quality_tier 把「至少有一个属性形状」的节点形状设为 closed(semantica/ontology/ontology_generator.py:1170),实例上任何未声明的属性都会被 ClosedConstraintComponent 判违规。basic 和 standard 档则完全不触发这个分支,形状保持开放。
如果数据图里的 IRI 前缀和形状默认命名空间不一致会怎样。 _resolve_target_namespace 的兜底链依次看显式参数、namespace.base_uri、术语自带 IRI、ontology.uri,最后落到 _DEFAULT_TARGET_NAMESPACE(semantica/ontology/ontology_generator.py:1013)。只要数据侧 IRI 与任一中间层对得上,目标就不会落到形状命名空间;全对不上时才会退回默认,此时形状匹配不到任何焦点节点,pySHACL 报平凡成立。
如果父类指针成环会怎样。 形状继承的传播循环被 for _ in range(20) 封顶(semantica/ontology/ontology_generator.py:1153),每轮若没有新增路径就提前退出。即便 parent 指针构成环,传播最多跑 20 轮就停,不会死循环;这层上限配合 existing_paths 去重,正常层次一两轮就稳定。
横向对比
GraphRAG 完全没有本体层。它的实体抽取靠一段 prompt,entity_types 是调用方塞进去的固定列表,抽取器只是把列表用逗号 join 后填进占位符。
6GRAPH_EXTRACTION_PROMPT = """7-Goal-8Given a text document that is potentially relevant to this activity and a list of entity types, identify all entities of those types from the text and all relationships among the identified entities.⋯# ... 空行略 ...10-Steps-111. Identify all entities. For each identified entity, extract the following information:12- entity_name: Name of the entity, capitalized13- entity_type: One of the following types: [{entity_types}]14- entity_description: Comprehensive description of the entity's attributes and activities15Format each entity as ("entity"<|><entity_name><|><entity_type><|><entity_description>)60 self, text: str, entity_types: list[str], source_id: str61 ) -> tuple[pd.DataFrame, pd.DataFrame]:62 """Extract entities and relationships from the supplied text."""63 try:64 # Invoke the entity extraction65 result = await self._process_document(text, entity_types)66 except Exception as e: # pragma: no cover - defensive logging67 logger.exception("error extracting graph")68 self._on_error(69 e,70 traceback.format_exc(),71 {72 "source_id": source_id,73 "text": text,74 },75 )76 return _empty_entities_df(), _empty_relationships_df()7778 return self._process_result(79 result,80 source_id,81 TUPLE_DELIMITER,82 RECORD_DELIMITER,83 )8485 async def _process_document(self, text: str, entity_types: list[str]) -> str:86 messages_builder = CompletionMessagesBuilder().add_user_message(87 self._extraction_prompt.format(**{88 INPUT_TEXT_KEY: text,89 ENTITY_TYPES_KEY: ",".join(entity_types),90 })91 )抽取器在拿到类型清单后还有一道后处理:_process_result 把 LLM 返回的 ("entity"<|>...<|>...) 字符串按分隔符切开,转成 DataFrame。实体名和类型都被 upper() 归一化,这本身是一种弱约束,保证同一实体不同大小写会收敛到同一个键。但这种约束只到字符串层面,约束不了「fullName 必须存在」这类结构约束。
124 def _process_result(125 self,126 result: str,127 source_id: str,128 tuple_delimiter: str,129 record_delimiter: str,130 ) -> tuple[pd.DataFrame, pd.DataFrame]:131 """Parse the result string into entity and relationship data frames."""132 entities: list[dict[str, Any]] = []133 relationships: list[dict[str, Any]] = []134135 records = [r.strip() for r in result.split(record_delimiter)]136137 for raw_record in records:138 record = re.sub(r"^\(|\)$", "", raw_record.strip())139 if not record or record == COMPLETION_DELIMITER:140 continue141142 record_attributes = record.split(tuple_delimiter)143 record_type = record_attributes[0]144145 if record_type == '"entity"' and len(record_attributes) >= 4:146 entity_name = clean_str(record_attributes[1].upper())147 entity_type = clean_str(record_attributes[2].upper())148 entity_description = clean_str(record_attributes[3])149 entities.append({150 "title": entity_name,151 "type": entity_type,152 "description": entity_description,两边的差异一句话:Semantica 的 ClassInferrer 从实体类型频次反推类型词汇,GraphRAG 把类型词汇当作外生配置喂给 LLM。GraphRAG 这样做的底气在于它的定位是「单源文档语料的问答系统」,类型清单可以靠人工列一次就复用;Semantica 面向「企业多源摄取的图谱」,类型必须先长出来再收敛。
GraphRAG 用社区摘要顶替了本体的部分职责。它的 Community 是有层次的节点分组,CommunityReport 是每个分组的 LLM 摘要,摘要里用 [Data: Entities (...); Relationships (...)] 的接地规则指回记录 id。
12@dataclass13class Community(Named):14 """A protocol for a community in the system."""1516 level: str17 """Community level."""1819 parent: str20 """Community ID of the parent node of this community."""2122 children: list[str]23 """List of community IDs of the child nodes of this community."""2425 entity_ids: list[str] | None = None26 """List of entity IDs related to the community (optional)."""2728 relationship_ids: list[str] | None = None29 """List of relationship IDs related to the community (optional)."""12@dataclass13class CommunityReport(Named):14 """Defines an LLM-generated summary report of a community."""1516 community_id: str17 """The ID of the community this report is associated with."""1819 summary: str = ""20 """Summary of the report."""2122 full_content: str = ""23 """Full content of the report."""2425 rank: float | None = 1.026 """Rank of the report, used for sorting (optional). Higher means more important"""2728 full_content_embedding: list[float] | None = None29 """The semantic (i.e. text) embedding of the full report content (optional)."""3031 attributes: dict[str, Any] | None = None32 """A dictionary of additional attributes associated with the report (optional)."""3334 size: int | None = None35 """The size of the report (Amount of text units)."""3637 period: str | None = None38 """The period of the report (optional)."""74 async def __call__(self, input_text: str):75 """Call method definition."""76 output = None77 try:78 prompt = self._extraction_prompt.format(**{79 INPUT_TEXT_KEY: input_text,80 MAX_LENGTH_KEY: str(self._max_report_length),81 })82 response = await self._model.completion_async(83 messages=prompt,84 response_format=CommunityReportResponse, # A model is required when using json mode85 )8687 output = response.formatted_response # type: ignore88 except Exception as e:89 logger.exception("error generating community report")90 self._on_error(e, traceback.format_exc(), None)9192 text_output = self._get_text_output(output) if output else ""93 return CommunityReportsResult(94 structured_output=output,95 output=text_output,96 )社区摘要顶替掉的是本体的「概念分组」职责:它把相关实体聚成社区,再给每个社区一段可读摘要,供 global search 做高层问答。它顶替不了的是本体的「schema 约束」职责:社区报告里没有任何东西能表达「每个 Person 必须有 fullName」这种硬约束,也没有任何东西能对实例做 SHACL 式的机器校验。GraphRAG 的答案可信度靠接地规则(摘要句必须带记录 id),Semantica 靠本体加形状加溯源三层。两者可以没有对方,因为一个回答「这段语料讲了什么」,另一个回答「这批数据合不合格」。
为什么 GraphRAG 可以没有这一层?它的实体类型清单在抽取前就由人定好,抽取后靠 upper() 和接地规则做弱收敛,问答时靠社区摘要加引用上下文兜底。这套链路里没有任何一步需要 schema 约束来保证答案质量,因为它输出的答案带引用,读者自己可以回查。Semantica 输出的是要进图库、要过审计的实例,光有引用不够,得先过形状校验。
把两者摆在一起看,GraphRAG 的 CommunityReport.rank 和 full_content_embedding 是为检索排序服务的,Semantica 的 NodeShape.closed 和 PropertyShape.min_count 是为判定合规服务的。一个挑摘要来答,一个判数据是否违规,两者不在同一个问题域里。所以「GraphRAG 没有本体层」这句,准确含义是它的输出形态用不上 schema 约束这一环,它和 Semantica 的差异在问题域,不在代码量。
互动演示设计
形态:格式实验台。 读者在一个输入框里给一段实体关系数据,右侧三个开关控制 quality_tier、attach_domainless_properties、target_namespace,下方实时显示生成的 SHACL Turtle 和 pySHACL 的 conforms 结果。
一句话结论: 同一份本体,开关一拨,形状从「宽松到闭包」三档切换,而形状命名空间一旦与数据命名空间错开,校验会静默全绿。
舞台元素与比喻: 把 SHACL 形状想成机场安检门。base_uri 是安检门自己站的位置,target_namespace 是旅客证件的发证机构。门站在 A 厅,证件全是 B 机构的编号,门永远扫不到人,却报告「无人违规」。closed 是「除了清单上的物品,其他一律没收」的严格模式。
分步动画:
- 数据入场,实体按类型聚成
Person、Organization两个概念,出现一次的类型被阈值挡掉。 - 类与属性生成,
worksFor变成对象属性,domain取Person,range取Organization。 - 形状生成,每个类一个
NodeShape,每个有 domain 的属性挂进对应节点形状。 - 拨动
attach_domainless_properties,一条无 domain 的属性在「不挂任何形状」和「挂满所有形状」之间切换。 - 拨动
quality_tier=strict,有属性的形状变成closed,非法属性开始被判违规。 - 把
target_namespace改成和数据不符的命名空间,conforms=True出现,页面弹出一句「形状没有匹配到任何焦点节点」。
每步字幕文案:
- 「先数一数,出现两次以上的类型才配当类。」
- 「关系连着谁,domain 和 range 就从谁那里来。」
- 「一个类一个 NodeShape,有 domain 才挂 PropertyShape。」
- 「无 domain 的属性,默认哪都不挂,别替本体发明约束。」
- 「strict 档,形状关闭,未声明属性一律拦截。」
- 「门站错了地方,全绿等于全瞎。」
读者可操作项: 改 min_occurrences 看类数变化;给某条属性去掉 domain 看 warning;把 target_namespace 填成一个假前缀,对比 conforms 前后真假。
逻辑轨迹面板伪代码(右侧为真实行号):
engine = OntologyEngine(base_uri=...) # engine.py:15
ttl = engine.to_shacl(ontology, quality_tier=tier) # engine.py:196
generator = SHACLGenerator(...) # engine.py:249
graph = generator.generate(ontology) # ontology_generator.py:854
_build_class_index(classes) # ontology_generator.py:1038
_generate_node_shapes(classes) # ontology_generator.py:1043
_attach_property_shapes(properties) # ontology_generator.py:1058
_propagate_inheritance(graph, class_index) # ontology_generator.py:1148
_apply_quality_tier(graph) # ontology_generator.py:1170
result = generator.serialize(graph, "turtle") # ontology_generator.py:928
report = engine.validate_graph(data, shacl=ttl) # engine.py:309
run_shacl_validation(...) # ontology_validator.py:148
pyshacl.validate(inference="none") # ontology_validator.py:194可迁移结论
值得抄的: 阈值过滤。一个类型或关系出现次数不够就不进本体,用统计先压掉 LLM 抽取的噪声,这是最低成本的质量闸。IRI 生成时对名称做百分号编码,_mint_term_iri 处理「Customer Account」带空格的名字,避免产出非法 IRI。形状命名空间与目标命名空间分离,这条在 RDF 生态里是通用原则,跟语言无关。校验解释走模板不走 LLM,审计时每条白话都能指回形状参数。
最小成本形态: 不需要六段管线,也不需要推理器。一份实体关系数据,加一个出现次数阈值推断类和属性,加一个把 domain/range 映射成 sh:minCount、sh:datatype 的序列化器,加一个现成的 pySHACL 调用,就能得到「可解释、可审计」的最小闭环。本体生成可以先手写几行 JSON 代替,形状校验是这一章真正不可省的部分。
哪些是过度设计: OntologyValidator 的推理占位、命名约定里靠词表猜动词名词的启发式、六段命名与五段实际执行的落差,这些在单仓库内部是留好的扩展点,但对只想「生成一份能用的形状」的团队是负担。SKOS 对齐谓词和 ReuseManager 属于企业多本体互操作场景才需要的量级,起步阶段可以先不抄。
跨语言的最小闭环。 阈值过滤、IRI 百分号编码、形状与目标命名空间分离、模板化解释,这四件事都不依赖 Python。用任何能拼 RDF 和调 pySHACL 绑定的语言,都能复刻同一个闭环:从实体关系统计里取出现次数够高的类型当类,把 domain/range 写成形状,再跑一次校验并给出模板化的白话解释。审计价值来自「约束可指回、违例可定位」,语言只是载体。
思考题
下面四题从前到后依次递进:先看懂占位推理的实际输出,再挑一个序列化细节,然后站到 GraphRAG 一侧对照,最后动手改测试。
OntologyValidator.validate里check_consistency和check_satisfiability两个分支都只是pass,那么generate_ontology返回的validation.consistent和validation.satisfiable实际值是多少?valid又由什么决定?- 一条对象属性的
range是["Organization", "Person"]两个类,_build_property_shape只取range_的第一个元素(semantica/ontology/ontology_generator.py:1108附近)。这个取首元素的行为对多 range 属性意味着什么,会不会丢约束? - GraphRAG 的社区摘要用
[Data: ...]接地规则指回记录 id,Semantica 的 SHACL 违例用focus_node和result_path指回违例位置。两种「可审计」各回答了什么不同的问题? - 动手验证:把
tests/ontology/test_shacl_target_namespace.py里_ontology的carry_class_uris=False且declare_namespace=False改成declare_namespace=True,跑pytest tests/ontology/test_shacl_target_namespace.py -k target_class,观察断言是否仍通过;再把ONTOLOGY_NS换成一个假前缀,跑-k a_real_violation,记录conforms的变化。