上一篇《本体论 Ontology 深度研究》回答的是「本体为什么回潮」;这一篇只做一件事——把本体讲明白。办法是用一个贯穿全文的信贷知识库,写五段可运行的代码,让机器自己推出客户归属、自己查出违规贷款、自己回答业务问题。所有代码在本机实测跑通(Python 3.13.5,rdflib 7.6.0 / owlrl 7.6.2 / pySHACL 0.40.2 / owlready2 0.51,Java 17,截至 2026-11-05),环境准备只有一行——四件套里只有 owlready2 调推理器时需要 Java,其余纯 Python:
pip install rdflib owlrl pyshacl owlready2
一、一切从三元组开始:RDF 与 Turtle
先换掉关系库的心智模型。关系库里每张表一种形状,RDF 只有一种:主谓宾三元组。每条知识都拆成「主体、谓词、宾语」——「张三是 VIP 客户」是一条,「VIP 客户是一种客户」也是一条。主体和谓词用 IRI 命名,http://example.com/credit#Customer 这样的 IRI 既是主键也是外键,全网络唯一,这就是上篇说的「全局命名」——不同系统交换数据时,IRI 相同即指同一事物,不需要再对字典。
本体分两层:术语层(TBox)存类、关系与规则,事实层(ABox)存实例,但两层用同一种语言写。RDF 是数据模型,Turtle 只是它最常用的文本语法(RDF 1.1 Turtle,2014-02-25 成为 W3C 推荐,同一张图也可以序列化成 RDF/XML 或 JSON-LD,语法可随时替换)。先看术语层:
@prefix ex: <http://example.com/credit#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
# ── 类与层级 ──────────────────────────────
ex:Customer a rdfs:Class ; rdfs:label "客户" .
ex:VIPCustomer a rdfs:Class ;
rdfs:subClassOf ex:Customer ;
rdfs:label "VIP 客户" .
ex:Account a rdfs:Class ; rdfs:label "账户" .
ex:Loan a rdfs:Class ; rdfs:label "贷款" .
ex:Branch a rdfs:Class ; rdfs:label "分支行" .
# ── 关系(属性):domain/range 是「自动归类」的钩子 ──
ex:holdsAccount a rdf:Property ;
rdfs:domain ex:Customer ;
rdfs:range ex:Account ;
rdfs:label "持有账户" .
ex:exposure a rdf:Property ;
rdfs:domain ex:Customer ;
rdfs:range xsd:decimal ;
rdfs:label "风险敞口" .
ex:partOf a owl:TransitiveProperty , rdf:Property ;
rdfs:domain ex:Branch ;
rdfs:range ex:Branch ;
rdfs:label "隶属于" .
# ── OWL 公理:客户与贷款是互斥概念 ──
ex:Customer owl:disjointWith ex:Loan .
三个埋伏值得圈出来:rdfs:subClassOf 定义了 VIP 客户是客户的子类;ex:holdsAccount 的 rdfs:domain 与 rdfs:range 声明「凡持有账户者必是客户、所持必是账户」,它们是后面机器自动归类的钩子;ex:partOf 被声明为 owl:TransitiveProperty(传递属性),owl:disjointWith 则声明客户与贷款是互斥概念。@prefix 把长 IRI 缩写成短名,分号表示「接着上一条的主语继续说」,这种紧凑感正是 Turtle 名字的由来(terse)。
事实层用同样的语言写:
@prefix ex: <http://example.com/credit#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:c001 a ex:VIPCustomer ; # 只声明了 VIP 客户,没声明客户
ex:exposure "8000000"^^xsd:decimal .
ex:c002 ex:holdsAccount ex:acc010 . # 一条关系带出的两个「隐形」实例
ex:acc010 a ex:Account .
ex:b001 ex:partOf ex:b010 . # 支行 → 分行 → 总行,只有两条边
ex:b010 ex:partOf ex:b100 .
请记住三处伏笔:c001 只声明了 VIP 客户、没声明客户;c002 一个类型都没写、只有一条关系;组织层级只有两条边。接下来发生的事,全部由机器完成。
为什么不直接用 JSON 或关系表
三种载体各有一道天花板。JSON 的形状由嵌套决定——「谁持有账户」写得下,「账户又被谁作为担保、担保方还关联着谁的授信」这种任意深度的关联图,嵌套会爆炸,且另一半关系永远没有落脚点。关系表能存(一切皆外键表),但 schema 先行:每来一类新关系就要改表加列,跨表查询靠人工写 join,join 的深度随问题变化——「客户的客户的行业分布」要么递归 SQL 要么多层子查询。三元组模型反过来:数据形状无关(任何节点连任何节点),类与规则以三元组的形式叠加在数据上,改规则不动存储。本体的价值不在「存」,在规则与数据同构——这也是第一节那些埋伏能在后面几节「自动生效」的前提。
顺带解释两个 Turtle 细节:代码里的小写 a 是 rdf:type 的缩写(「是一种」);同一主语的多条谓词用分号串联、同一谓词的多个宾语用逗号串联(如 ex:borrower ex:c001, ex:c002),长 IRI 靠 @prefix 缩短——没有这些糖,RDF/XML 时代的本体可读性劝退过一代工程师。
二、推理:让机器自己归类
上篇说过,OWL 推理器有三个标准任务:包含推理(子类归类)、实例检索(查询)与一致性检查。本节实测前两个——核心只有一行:
import owlrl
from rdflib import Graph, Namespace
EX = Namespace("http://example.com/credit#")
g = Graph()
g.parse("ontology.ttl", format="turtle")
g.parse("data.ttl", format="turtle")
print(f"推理前三元组:{len(g)}")
# RDFS / OWL 2 RL 推理:把所有可推导的事实物化回图
owlrl.DeductiveClosure(owlrl.OWLRL_Semantics).expand(g)
print(f"推理后三元组:{len(g)}")
运行输出(实测原文,下同):
推理前三元组:31
推理后三元组:181
现在查「谁是客户」——注意数据里没有谁显式声明过客户(c001 是 VIP 客户,c002 无类型),推理前这个问题返回空,推理后两个都出现了:
def ex_rows(graph, query):
rows = graph.query(query, initNs={"ex": EX})
return sorted(str(r[0]).split("#")[-1] for r in rows
if str(r[0]).startswith(str(EX))) # 过滤闭包噪声
print(ex_rows(g, "SELECT ?c WHERE { ?c a ex:Customer }"))
print(ex_rows(g, "SELECT ?t WHERE { ex:c002 a ?t }"))
print(ex_rows(g, "SELECT ?o WHERE { ex:b001 ex:partOf ?o }"))
['c001', 'c002']
['Customer']
['b010', 'b100']
三行输出对应三种推理。其一,c001 出现在客户名单里——它只声明过 VIP 客户,rdfs:subClassOf 让推理器补写了「c001 是客户」;其二,c002 的类型是凭空推断出来的——它只写过一条 holdsAccount 关系,rdfs:domain 生效了;其三,b001 直达 b100——数据里只有 b001→b010、b010→b100 两条边,owl:TransitiveProperty 让图里真的多出一条事实,之后的查询不需要任何递归语法。
把所有可推导事实提前算好写回图,叫物化(materialization)。代价是图会膨胀:31 条涨到 181 条,因为 RDFS/OWL 2 RL 闭包会补入大量 rdfs:Resource、owl:Thing 级的平凡三元组(代码里按 ex: 命名空间过滤正是为此)。写时物化、查询零推理,是最常见的取舍;数据频繁变更的场景则反过来,查询时再推理。对照上篇的三个标准任务,本节的 c001 自动归类是包含推理(subsumption),第五节的 SPARQL 是实例检索(instance retrieval),一致性检查(consistency checking)独立成下一节——三件事在真实工程里各有各的引擎,凑不到一个进程里省事。
三、互斥公理:RL 物化查不出,DL 推理器查得出
把术语层那条互斥公理变成一次真攻击:让同一个体 x 既是客户又是贷款,看两代推理器各是什么反应。
先测 owlrl:把 x 加进图重跑物化,实测结果是什么都没发生——不报错,也没有为 x 产生任何「不可能」标记(图里与 owl:Nothing 相关的只有三条平凡的自指三元组)。这不是 bug:OWL 2 RL 是一套为可扩展性设计的正向规则集,由互斥类冲突引发的一致性检查不在它的覆盖范围里——「以表达力换可计算性」的另一处具体代价。
换 DL 推理器 HermiT(经 owlready2 调用,背后起一个 Java 进程):
from owlready2 import *
onto = get_ontology("http://example.com/credit2#")
with onto:
class Customer(Thing): pass
class Loan(Thing): pass
AllDisjoint([Customer, Loan])
x = Customer("x_1")
x.is_a.append(onto.Loan) # 同一个体同时声明为客户与贷款
try:
sync_reasoner() # 调 HermiT 做 tableau 推理与一致性检查
except Exception as e:
print(type(e).__name__, "—", e)
* Owlready2 * Running HermiT...
OwlReadyInconsistentOntologyError —
HermiT 直接拒绝了这个本体:互斥类出现了共同实例,本体自相矛盾。一致性检查为什么重要?因为一个自相矛盾的本体会污染下游一切推理——错误不是「少推几条」,而是「推出来的每一条都不可信」。换推理器也有讲究:owlready2 里 sync_reasoner_pellet() 可换 Pellet(同样支持完整 OWL 2 DL 的 SROIQ(D) 表达力,还多一个解释生成能力——告诉你冲突的推导链条),而 ELK 只支持 OWL 2 EL profile、跑不了本例的完整表达——选型逻辑就是上篇那张 profile 表。三种能力至此分工清晰:RL 物化管日常增量推理,DL 推理器管本体本身是否自洽,SHACL 管数据是否合规——第三样马上到。
四、校验:把业务规则写成形状
「每笔贷款必须恰好关联一个客户」「金额必须低于 1000 万」这类规则写不成推理公理——它们要的是逐条检查并给出违规清单(校验语义),不是推导新事实(推理语义)。SHACL 把它们定义成形状,而形状本身也是 RDF——用数据描述约束,是这个标准最优雅的自指设计:
@prefix ex: <http://example.com/credit#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:LoanShape a sh:NodeShape ;
sh:targetClass ex:Loan ;
sh:property [
sh:path ex:borrower ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:class ex:Customer ;
sh:message "每笔贷款必须恰好关联一个客户" ;
] ;
sh:property [
sh:path ex:amount ;
sh:datatype xsd:decimal ;
sh:maxExclusive "10000000"^^xsd:decimal ;
sh:message "贷款金额必须低于 1000 万" ;
] .
放四条贷款进去:一条合法,三条各违反一条规则:
@prefix ex: <http://example.com/credit#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:loan_ok a ex:Loan ;
ex:borrower ex:c001 ;
ex:amount "500000"^^xsd:decimal .
ex:loan_2 a ex:Loan ; # 违规:两个借款人
ex:borrower ex:c001, ex:c002 ;
ex:amount "600000"^^xsd:decimal .
ex:loan_0 a ex:Loan ; # 违规:没有借款人
ex:amount "700000"^^xsd:decimal .
ex:loan_big a ex:Loan ; # 违规:金额超上限
ex:borrower ex:c002 ;
ex:amount "20000000"^^xsd:decimal .
import pyshacl
data = Graph()
data.parse("bad_data.ttl", format="turtle")
shapes = Graph()
shapes.parse("shapes.ttl", format="turtle")
conforms, _, text = pyshacl.validate(
data_graph=data, shacl_graph=shapes, inference="none"
)
print(f"conforms = {conforms}")
print(text) # 完整违规报告
输出(节选,实测原文):
SHACL 校验:conforms = False
Focus Node: ex:loan_2
Message: 每笔贷款必须恰好关联一个客户
Focus Node: ex:loan_big
Message: 贷款金额必须低于 1000 万
Focus Node: ex:loan_0
Message: 每笔贷款必须恰好关联一个客户
合法的 loan_ok 没有出现,三条违规精确到焦点节点与原因。注意 sh:maxCount 1 这种基数约束恰恰是 OWL 开放世界做不了的:OWA 下「图里没有第二条边」无法证明,而 SHACL 的校验语义是封闭的——没规则使真即为假,天生干这个。上篇那句「推理与校验一补一检」,到这里落了地。inference="none" 参数也值得一提:pySHACL 可以先跑 RDFS 物化再校验(inference="rdfs"),默认纯闭世界直接查——选哪种取决于你想不想让「推断出的类型」参与校验。
SHACL 还有第二个身份:数据契约。shapes.ttl 是自包含的 RDF 文件,可以直接发给上游系统、放进 CI 流水线,把「字段必填、值域上限」从口头约定变成机器执行的合同——它也是数据工程领域最先采纳 SHACL 的场景。违规报告的结构同样是标准化的:每个结果带 Focus Node(违规数据)、Result Path(违规路径)、Value Node(违规值)与 Message,机器可解析,不止给人看。
五、查询:能力问题变成五行 SPARQL
当初建模时写下的能力问题(Competency Question)——「哪些客户的风险敞口超过 500 万?」——现在直接问图。SPARQL 的查询体是一组三元组模式,像正则一样贴在图上匹配,模式之间的共享变量就是 join;没有固定 schema,图长什么样模式就怎么写:
q = """SELECT ?c ?e WHERE {
?c a ex:Customer ;
ex:exposure ?e .
FILTER(?e > 5000000)
}"""
rows = g.query(q, initNs={"ex": EX})
print([(str(r[0]).split("#")[-1], str(r[1])) for r in rows])
[('c001', '8000000')]
注意它能查出来,靠的是第二节的推理:查询条件是 ?c a ex:Customer,而 c001 只声明过 VIP 客户——没有物化闭包,这一行会静默返回空。推理不是炫技,是查询正确性的前提。查询还能反过来写图:SPARQL 1.1 附带 Update(INSERT/DELETE 同样是三元组模式),加一条事实与查一条事实用同一套语言——读与写留在同一个模型里,这正是上篇说 Palantir 把 action 纳入本体的同款思路。另外还有属性路径(ex:partOf+ 直接表达传递闭包,不用物化也能查)与联邦查询(SERVICE 关键字跨数据源联查),入口值得知道。
六、装进工程前的五句话
- 本文流水线(加载 → 物化 → 校验 → 查询)不到 40 行,验证想法够用;生产换 Apache Jena Fuseki 或 GraphDB 承担存储与推理,Python 端只留查询与校验。
- 物化前想清楚更新频率:写时物化换查询零延迟,但每次写图都要重算增量;OWL 2 RL 可物化,DL 推理(HermiT / Pellet)不可物化、只能按需调用。
- 一致性检查很贵,且只在「本体演化」时需要,别放进线上请求路径;SHACL 便宜,适合当写入闸门——LLM 抽取出的图谱数据先过形状再入库,正是上篇「LLM 抽取加人工审核」的工程落点。
- profile 选型按上篇那张表:超大术语库用 EL、查询重写进关系库用 QL、规则引擎路线用 RL;本文的 owlrl 即 RL 路线,代价是第三节演示的那个盲区。
- 继续深入的顺序:Protégé(可视化建模 + 挂推理器)→ OWL 2 Primer → SHACL 规范,比任何教程都细,但并不陡。
收尾
回看这五段代码:rdfs:subClassOf、rdfs:domain 与 owl:TransitiveProperty 是让机器补事实;owl:disjointWith 加 HermiT 是让机器守边界;SHACL 是让机器守纪律;SPARQL 是让人问问题。四件事合起来,就是「把领域共识固化成机器可推理资产」这句话的全部工程含义——本体不是一张概念图,而是一套能被执行的规则。什么时候值得上这套?判断框架不变:多方协作、高风险决策、长生命周期数据——从这 40 行开始不亏。
参考资料
- W3C RDF 1.1 Concepts and Abstract Syntax(2014-02-25)
- W3C RDF 1.1 Turtle(2014-02-25)
- W3C OWL 2 Primer(含开放世界假设的官方对比)
- W3C OWL 2 Profiles(EL / QL / RL 三个 profile)
- W3C SHACL(2017-07-20)
- owlrl(PyPI,OWL 2 RL / RDFS 物化的纯 Python 实现)
- pySHACL(GitHub)
- owlready2 文档(sync_reasoner 与 HermiT / Pellet)
- 本站:本体论 Ontology 深度研究(上篇)
读者留言
COMMENTS 暂无还没有留言,来说第一句?