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

第 7 章

本体生成与校验

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

场景还原

一家企业把三套遗留系统的数据合进一张知识图谱,全程没有写过一行本体。抽取阶段由 LLM 自由发挥,同一个「客户账户」在不同批次里被写成 CustomerAccountcustomer_accountcustomer account。下游想跑一句 SPARQL 统计账户数,三种写法各查到三分之一,还查不到第四批里被写成 client acct 的那部分。

团队决定亡羊补牢:把图谱反推成一份本体,再生成 SHACL 形状去校验实例。跑完 pySHACL,报告显示 conforms=True,一片绿。工程师松了一口气,直到有人抽查发现,形状其实一个目标节点都没匹配上。pySHACL 对「没有焦点节点的形状」视为平凡成立,于是全绿成了全瞎。这个坑在仓库的回归测试里被明确记录了下来,本意是提醒后来者:形状和目标命名空间是两回事。

tests/ontology/test_shacl_target_namespace.py1:19
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」。整条链路和两条出口如下。

flowchart TD A[杂乱数据\nentities 与 relationships] --> B[Stage 1 解析语义网络\nprocess_entity 归一化] B --> C[Stage 2 类定义\nClassInferrer 阈值过滤] C --> D[Stage 3 OWL 类型映射\n补 @type 与 IRI] D --> E[Stage 4 层次生成\nsubClassOf 推断] E --> F[Stage 5 TTL 生成\nOWLGenerator rdflib] F --> G[Stage 6 符号校验\nOntologyValidator] F --> H[SHACLGenerator\nNodeShape 与 PropertyShape] H --> I[pySHACL 实例校验\nrun_shacl_validation]
semantica/ontology/ontology_generator.py1:17
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 里只留了一个占位实现,这个落差后面单独展开。

semantica/ontology/ontology_generator.py49:67
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,它不阻断返回。

semantica/ontology/ontology_generator.py197:211
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 字典,里面放 entitiesrelationships 两个列表。这正说明本体的生成时机在图构建之后:它吃的是已经抽好的实体关系,不直接吃文本。generate_from_graph 是同一个方法的别名,签名注释写得直白,graph 就是 GraphBuilder 的输出。

semantica/ontology/ontology_generator.py226:239
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_charend_charconfidencegetattr 兜底带出来。

semantica/ontology/ontology_generator.py249:291
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)才升级成一个类,单次出现的杂音类型被丢弃。

semantica/ontology/class_inferrer.py151:162
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 的启发式朴素:把类名按空格切成词,从右往左逐个去掉末尾词,看剩下的词拼起来是否是一个已存在的类,命中就当作父类。

semantica/ontology/class_inferrer.py308:323
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

这套规则只对多词类名有效,单词类名拆不出前缀,最后落到 EntityThingResource 三个通用父类里找,找不到就返回 None。它对语义一无所知,Manager 能不能被识别成 Employee 的子类,取决于名字本身。

对象属性推断走的是同一套路:关系按 type 分组,达到 min_occurrences 的关系类型才成为对象属性,domain 和 range 从关系的 source_type/target_type 集合里收集。

semantica/ontology/property_generator.py133:172
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                )

domainsranges 用集合收集,天然去重,多个来源重复指向同一个类时不会产生重复约束。若关系里拿不到 source_typetarget_type,会走 _infer_class_from_entity 兜底,而它目前只返回 None,最终 domain 和 range 落到 ["owl:Thing"],这是本体里最宽泛的类型,等于「暂时说不清属于哪一类」。

数据属性走另一条路:看实体字典里除了 id/type/text 等保留键之外还带了哪些键,逐个推断 XSD 类型。布尔、整数、浮点、字符串依次匹配,字符串还要再过一遍日期和日期时间的正则。

semantica/ontology/property_generator.py269:286
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") 判空,直接检查值是否为真。

semantica/ontology/ontology_generator.py443:460
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 只做确定性后缀,不作安全用途。

semantica/ontology/namespace_manager.py113:126
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 三元组。

semantica/ontology/owl_generator.py215:250
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 失败时把 GraphRDFOWL 等置为 None,后续改走 _generate_basic 的字符串拼接路径。两条路径产出同样的 Turtle 结构,差别只在三元组由 rdflib 管理还是手写 f-string。导出前不强制安装 rdflib,本体生成的硬依赖因此保持在最小。

SHACL 生成器:本体的另一条出口

OWL 描述「世界长什么样」,SHACL 描述「什么形状算合格」。SHACLGenerator 住在 ontology_generator.py 后半段,它有自己的一套内部管线,文档里也写了六步。

semantica/ontology/ontology_generator.py794:805
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    """

生成与校验两段是分离的两次调用,先产形状再跑实例校验。

sequenceDiagram participant R as 调用方 participant E as OntologyEngine participant S as SHACLGenerator participant P as pySHACL R->>E: to_shacl ontology 参数 E->>S: generate ontology S->>S: 建类索引与节点形状 S->>S: 挂属性与继承传播 S-->>E: SHACLGraph E->>S: serialize graph turtle S-->>E: shapes 字符串 E->>P: run_shacl_validation data 与 shapes P-->>E: conforms 与违例列表 E-->>R: SHACLValidationReport

形状的内部模型是三个 dataclass。PropertyShape 对应 sh:PropertyShape,字段逐一对到 sh:datatypesh:classsh:minCountsh:maxCountsh:insh:hasValuesh:pattern

semantica/ontology/ontology_generator.py732:756
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_irisproperty_iris 两张分开的索引。注释讲清了为什么:类名和属性名可能合法地重名,如果共用一张名字到 IRI 的映射,属性会静默拿到类的 IRI。

semantica/ontology/ontology_generator.py778:791
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 的独立一步。

semantica/ontology/ontology_generator.py887:909
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_irisproperty_iris 在构造 SHACLGraph 时就按同一个 target_ns 展开好,序列化阶段只是查表,命名空间决策被集中在这一个方法里完成。

_resolve_target_namespace#1104 的正解所在。它的任务只有一个:决定 sh:targetClasssh:path 该在哪个命名空间里展开。优先级从显式 target_namespace 参数,到本体的 namespace.base_uri,再到术语自带 IRI 推导出的前缀,最后才轮到默认值。形状自己的 base_uri 只描述形状资源住在哪,不参与目标命名空间的推断。

semantica/ontology/ontology_generator.py977:1013
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 会把每个类的每个实例都判成违规。

semantica/ontology/ontology_generator.py1058:1102
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_valuespattern 只在 standard 或 strict 档进入形状,basic 档直接置空,档位在这里第二次起作用。

semantica/ontology/ontology_generator.py1104:1146
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

semantica/ontology/ontology_generator.py1148:1168
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,关闭态意味着实例上出现任何未声明的属性都算违规。三档之间的切换关系如下。

stateDiagram-v2 [*] --> basic basic --> standard: 默认 quality_tier standard --> strict: 显式传入 strict strict --> strict: 有属性的形状\nclosed 置 true standard --> standard: in_values 与 pattern\n才进入形状
semantica/ontology/ontology_generator.py1170:1175
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 参数在类索引和属性索引之间切换,这是让同名类属性各归各位的最后一道闸。

semantica/ontology/ontology_generator.py1182:1208
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 三元组而全体违规。

semantica/ontology/ontology_generator.py1234:1240
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 推理在这个提交里是没接线的。

semantica/ontology/ontology_validator.py340:354
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

它返回的结果对象本身是完整的:validconsistentsatisfiable 三个布尔加上错误和警告两个列表。

semantica/ontology/ontology_validator.py290:297
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 守卫,缺 pyshaclrdflib 就抛带安装提示的 ImportError,然后解析数据图和形状图,调用 pyshacl.validate。两个参数值得注意:inference="none" 关闭推理,abort_on_first=False 要求收集全部违例。

semantica/ontology/ontology_validator.py167:199
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」,说明解释这一步是确定性的模板填充。

semantica/ontology/ontology_validator.py82:113
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 的修复只动了 SHACLGeneratorOntologyEngine.to_shacl,六段生成逻辑一行没改。

形状从本体自动派生。 SHACL 形状不手写,由 SHACLGenerator 从 ontology 字典逐类生成。手写形状容易和本体漂移,自动派生保证本体每改一处,形状跟着重算,描述口径与校验口径同源。

OWL、SHACL、SKOS 三种标准各管一段。 命名空间管理器把三个前缀都注册了进去,其中 skos 指向 http://www.w3.org/2004/02/skos/core#。OWL 管描述:类、属性、subClassOf 层次,回答「有哪些概念」。SHACL 管约束:sh:minCountsh:datatypesh:closed,回答「什么算合格实例」。SKOS 管词表:概念方案和概念之间的对齐。build_concept_scheme_uri 把人类可读的名称转成 vocab/ 下的 ConceptScheme URI。

semantica/ontology/namespace_manager.py223:237
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 两套词汇:equivalentClasssameAs 来自 OWL,exactMatchcloseMatch 来自 SKOS。

semantica/ontology/namespace_manager.py249:259
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_classessemantica/ontology/class_inferrer.py:158if len(type_entities) >= self.min_occurrences 处把它挡在类列表之外,它不会出现在本体里,但第 1 段的 concepts 字典仍会保留它。下游若期望「所有出现的类型都是类」,会在这里失配。

如果属性没声明 domain 会怎样。_attach_property_shapeselse 分支(semantica/ontology/ontology_generator.py:1095),属性不挂到任何节点形状,只发 warning。若显式传 attach_domainless_properties=True,改走 elif 分支,把属性挂到全部节点形状,此时配上 minCount 1 会让每个实例都违规。这是 #1105 的核心教训。

如果类和属性同名会怎样。 一个叫 Account 的类和一条叫 Account 的数据属性同时存在,_term_irikind 参数分别查 class_irisproperty_irissemantica/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 把「至少有一个属性形状」的节点形状设为 closedsemantica/ontology/ontology_generator.py:1170),实例上任何未声明的属性都会被 ClosedConstraintComponent 判违规。basic 和 standard 档则完全不触发这个分支,形状保持开放。

如果数据图里的 IRI 前缀和形状默认命名空间不一致会怎样。 _resolve_target_namespace 的兜底链依次看显式参数、namespace.base_uri、术语自带 IRI、ontology.uri,最后落到 _DEFAULT_TARGET_NAMESPACEsemantica/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 后填进占位符。

packages/graphrag/graphrag/prompts/index/extract_graph.py6:15
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>)
packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py60:91
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 必须存在」这类结构约束。

packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py124:152
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。

packages/graphrag/graphrag/data_model/community.py12:29
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)."""
packages/graphrag/graphrag/data_model/community_report.py12:38
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)."""
packages/graphrag/graphrag/index/operations/summarize_communities/community_reports_extractor.py74:96
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.rankfull_content_embedding 是为检索排序服务的,Semantica 的 NodeShape.closedPropertyShape.min_count 是为判定合规服务的。一个挑摘要来答,一个判数据是否违规,两者不在同一个问题域里。所以「GraphRAG 没有本体层」这句,准确含义是它的输出形态用不上 schema 约束这一环,它和 Semantica 的差异在问题域,不在代码量。

互动演示设计

形态:格式实验台。 读者在一个输入框里给一段实体关系数据,右侧三个开关控制 quality_tierattach_domainless_propertiestarget_namespace,下方实时显示生成的 SHACL Turtle 和 pySHACL 的 conforms 结果。

一句话结论: 同一份本体,开关一拨,形状从「宽松到闭包」三档切换,而形状命名空间一旦与数据命名空间错开,校验会静默全绿。

舞台元素与比喻: 把 SHACL 形状想成机场安检门。base_uri 是安检门自己站的位置,target_namespace 是旅客证件的发证机构。门站在 A 厅,证件全是 B 机构的编号,门永远扫不到人,却报告「无人违规」。closed 是「除了清单上的物品,其他一律没收」的严格模式。

分步动画:

  1. 数据入场,实体按类型聚成 PersonOrganization 两个概念,出现一次的类型被阈值挡掉。
  2. 类与属性生成,worksFor 变成对象属性,domainPersonrangeOrganization
  3. 形状生成,每个类一个 NodeShape,每个有 domain 的属性挂进对应节点形状。
  4. 拨动 attach_domainless_properties,一条无 domain 的属性在「不挂任何形状」和「挂满所有形状」之间切换。
  5. 拨动 quality_tier=strict,有属性的形状变成 closed,非法属性开始被判违规。
  6. target_namespace 改成和数据不符的命名空间,conforms=True 出现,页面弹出一句「形状没有匹配到任何焦点节点」。

每步字幕文案:

  1. 「先数一数,出现两次以上的类型才配当类。」
  2. 「关系连着谁,domain 和 range 就从谁那里来。」
  3. 「一个类一个 NodeShape,有 domain 才挂 PropertyShape。」
  4. 「无 domain 的属性,默认哪都不挂,别替本体发明约束。」
  5. 「strict 档,形状关闭,未声明属性一律拦截。」
  6. 「门站错了地方,全绿等于全瞎。」

读者可操作项:min_occurrences 看类数变化;给某条属性去掉 domain 看 warning;把 target_namespace 填成一个假前缀,对比 conforms 前后真假。

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

text
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:minCountsh:datatype 的序列化器,加一个现成的 pySHACL 调用,就能得到「可解释、可审计」的最小闭环。本体生成可以先手写几行 JSON 代替,形状校验是这一章真正不可省的部分。

哪些是过度设计: OntologyValidator 的推理占位、命名约定里靠词表猜动词名词的启发式、六段命名与五段实际执行的落差,这些在单仓库内部是留好的扩展点,但对只想「生成一份能用的形状」的团队是负担。SKOS 对齐谓词和 ReuseManager 属于企业多本体互操作场景才需要的量级,起步阶段可以先不抄。

跨语言的最小闭环。 阈值过滤、IRI 百分号编码、形状与目标命名空间分离、模板化解释,这四件事都不依赖 Python。用任何能拼 RDF 和调 pySHACL 绑定的语言,都能复刻同一个闭环:从实体关系统计里取出现次数够高的类型当类,把 domain/range 写成形状,再跑一次校验并给出模板化的白话解释。审计价值来自「约束可指回、违例可定位」,语言只是载体。

思考题

下面四题从前到后依次递进:先看懂占位推理的实际输出,再挑一个序列化细节,然后站到 GraphRAG 一侧对照,最后动手改测试。

  1. OntologyValidator.validatecheck_consistencycheck_satisfiability 两个分支都只是 pass,那么 generate_ontology 返回的 validation.consistentvalidation.satisfiable 实际值是多少?valid 又由什么决定?
  2. 一条对象属性的 range["Organization", "Person"] 两个类,_build_property_shape 只取 range_ 的第一个元素(semantica/ontology/ontology_generator.py:1108 附近)。这个取首元素的行为对多 range 属性意味着什么,会不会丢约束?
  3. GraphRAG 的社区摘要用 [Data: ...] 接地规则指回记录 id,Semantica 的 SHACL 违例用 focus_noderesult_path 指回违例位置。两种「可审计」各回答了什么不同的问题?
  4. 动手验证:把 tests/ontology/test_shacl_target_namespace.py_ontologycarry_class_uris=Falsedeclare_namespace=False 改成 declare_namespace=True,跑 pytest tests/ontology/test_shacl_target_namespace.py -k target_class,观察断言是否仍通过;再把 ONTOLOGY_NS 换成一个假前缀,跑 -k a_real_violation,记录 conforms 的变化。