📑 查看全课大纲(第 6 / 26 节)

基于语义网的知识表示框架

约 80 分钟

📺 正在播放小象官方高清录播(支持倍速与清晰度调节)

RDF、RDFS、OWL 与 SPARQL:搭一套让机器共享与推理的知识表示框架

小象实战讲义 · 知识图谱

上一节我们知道,知识图谱把世界建模成一张 “实体为节点、关系为边” 的有向图。但不同站点、不同系统之间要交换、合并、查询这些图,必须先约定一套统一的 “表示语言”,否则你画的图机器读不懂、更没法和别人的图自动拼起来。本节就讲 W3C 为这件事制定的语义网技术栈:RDF 负责表示数据,RDFS/OWL 负责定义模式与本体约束,SPARQL 负责查询,再加上 JSON-LD/RDFa/Microdata 三种把语义嵌进网页的轻量写法。学完你会亲手用现代 Python 的 rdflib 建图、写本体、跑查询,并真正理解为什么业界说 “智能的数据(Smart Data)比聪明的代码更重要”。

💡 核心导读

  • 三层技术栈:RDF(数据模型,表示三元组)→ RDFS / OWL(模式层与本体,定义类型和约束)→ SPARQL(图查询语言),对应 “表示 — 约束 — 查询” 三件事。

  • 一切皆 URI:RDF 用 “主体 — 谓词 — 客体” 三元组描述万物,资源和属性都用 URI 全局标识;客体既可以是另一个资源,也可以是带数据类型 / 语言标签的字面量,还可以是匿名的空白节点。

  • 开放世界假设(OWA):图里查不到一条事实,不等于它不存在;知识还可以分布式定义、靠相同的 URI 自动合并。

  • 表达能力与推理复杂度的权衡:RDFS 能力弱但简单,OWL / OWL2 一层层加上等价、属性特性、基数、复杂类等约束,并派生出 QL / EL / RL 三个面向不同场景的子语言。

  • SPARQL 本质是子图匹配:用带变量的三元组模板拼成 “查询图”,在数据图上做匹配;OPTIONAL / FILTER / UNION / CONSTRUCT / 子查询足以表达复杂检索,还能跨知识库查询。

  • RDF 让关系显式存在数据里:数据粒度变更时同一条 SPARQL 不用改,这是它和 “关系隐式藏在 SQL 里” 的 ER 模型最本质的分野。


1. 语义网的技术蓝图:从 Tim 的第二个梦想到 W3C 标准栈

万维网之父 Tim Berners-Lee 说他有两个梦想:第一个是连接世界上的每一个人,这个梦想已经由万维网实现;第二个是连接世界上的每一个事物,这个使命交给了语义网(Semantic Web)。连接 “人” 靠的是网页之间的超链接,而连接 “事物” 靠的是一套让机器能读懂数据语义的标准。

W3C 为此推荐了一整套语义网标准栈,自下而上大致分为三层:最底层是数据的表示(用什么格式描述实体和关系),中间是模式与本体(如何定义类型、约束和推理规则),上层是查询与推理(如何把数据取出来、推出隐含结论)。对知识图谱而言,最主要的两项技术标准就是 RDF(表示)与 SPARQL(查询),而 RDFS / OWL 则承担本体建模。

与此配套的是 关联数据(Linked Data) 的发布理念:给每个事物一个全局唯一的 URI 标识、通过 HTTP 访问它、返回 RDF 等标准格式、并在数据里链接到其他事物的 URI。遵循这些原则相互链接的开放数据集汇聚成著名的 LOD(Linking Open Data)开放数据云图:图中每个圆圈代表一个数据集(圈的大小表示规模),圆圈之间的边表示两个数据集存在实体重合(可以互相链接),颜色则区分领域(地理、媒体、生命科学等)。

本节的学习路线正好沿着标准栈自下而上:

   查询层   SPARQL(三元组模板 / 子图匹配 / OPTIONAL·FILTER·UNION·CONSTRUCT)



  本体层   OWL / OWL2(等价、属性特性、基数、复杂类;QL / EL / RL 权衡)



  模式层   RDFS(Class / subClassOf / type / Property / domain / range)



  数据层   RDF(主体-谓词-客体三元组,URI / 字面量 / 空白节点,多种序列化)

2. RDF:用三元组描述世界上的一切

2.1 RDF 三个词的含义

RDF(Resource Description Framework,资源描述框架) 这个名字已经把它的设计说清楚了:

  • Resource(资源):页面、图片、视频,乃至任何一个被 URI 标识的对象;

  • Description(描述):资源的属性、特征,以及资源与资源之间的关系;

  • Framework(框架):承载这些描述的模型、语言与语法。

在 RDF 中,知识总是以 三元组(triple) 的形式出现,每一份知识都可以分解为 ( subject 主体, predicate 谓词, object 客体 )。课件的经典例子是一场 CCF ADL 学术讲座:

(CCF ADL, speaker, Haofen)     # CCF ADL 讲座的讲者是王昊奋(Haofen)

(CCF ADL, theme,   KG)         # CCF ADL 讲座的主题是知识图谱(KG)

RDF 同时也是一个图模型:每条三元组都可以看成图上的一条弧 (vertex 起点, edge 边, vertex 终点),主体和客体是两个节点,谓词是连接它们的、带语义标签的有向边。

2.2 一切皆 URI,客体可以是字面量或空白节点

RDF 里的资源和属性都用 URI 来标识。一个 URI 通常由 “域名 + 路径 + 本地名(local name)” 构成全局唯一的 id,例如 http://ex.org/ccf_adlhttp://ex.org/schema#speaker。为了书写简洁,RDF 引入命名空间前缀(namespace prefix),把一长串 URI 缩写成 前缀:本地名,如 ex:ccf_adlex-schema:speaker

三元组的客体有三种形态,这是最容易被忽略、却最关键的细节:

  1. 另一个 URI 资源:如 ex:ccf_adl ex-schema:speaker ex:haofen,客体 ex:haofen 本身也是一个可被继续描述的对象(于是图能连成网);

  2. 字面量(literal):一段字符串,如主题 "KG";字面量还可以用 ^^xsd:integer 这样的方式带上 XML 数据类型(如 "3"^^xsd:integer 表示时长 3 小时),或用 @en / @zh 带上语言标签

  3. 空白节点(Blank Node):记作 _:x,表示一个匿名、没有 URI 标识的资源。它常作为连接两个 URI 资源的 “桥梁”,用来表达需要二跳(two-hop)才能说清的复杂 / 多元关系 —— 例如 “Haofen 是某一次 KG 讲座的讲者”,这次讲座本身不必单独分配 URI,就用一个空白节点占位。

下面用 rdflib 把上述要点一次性建出来,并观察 RDF 的多种序列化写法和开放世界语义:

from rdflib import Graph, Namespace, Literal, BNode

from rdflib.namespace import XSD

EX = Namespace("http://ex.org/")

EXS = Namespace("http://ex.org/schema#")

g = Graph()

g.bind("ex", EX)

g.bind("ex-schema", EXS)

# 课件原例:CCF ADL 邀请 Haofen 作讲者,主题是知识图谱

g.add((EX.ccf_adl, EXS.speaker, EX.haofen))                          # 客体是 URI 资源

g.add((EX.ccf_adl, EXS.theme, Literal("KG")))                        # 客体是字面量 literal

g.add((EX.ccf_adl, EXS.nbHours, Literal(3, datatype=XSD.integer)))   # 带 xsd 数据类型

g.add((EX.ccf_adl, EXS.title, Literal("知识图谱前沿讲习班", lang="zh")))  # 带语言标签

# 空白节点:Haofen 是“某一次匿名讲座” _:talk 的讲者,该讲座主题是 KG

talk = BNode()

g.add((talk, EXS.speaker, EX.haofen))

g.add((talk, EXS.theme, Literal("KG")))

print("=== Turtle(同一主语的多条三元组用 ; 缩写,空白节点显示为 [])===")

print(g.serialize(format="turtle"))

print("=== N-Triples(每行一条完整三元组,空白节点为 _:N)===")

print(g.serialize(format="nt"))

# 开放世界假设:图里只查到 1 位讲者,含义是“至少有 1 位”,而不是“只有 1 位”

q_count = """

PREFIX ex-schema: <http://ex.org/schema#>

SELECT (COUNT(?s) AS ?n) WHERE { <http://ex.org/ccf_adl> ex-schema:speaker ?s }

"""

for row in g.query(q_count):

    print("CCF ADL 已知讲者数 =", int(row.n), "(至少有这么多位,而非只有这么多位)")

q_ask = """

PREFIX ex-schema: <http://ex.org/schema#>

ASK { <http://ex.org/ccf_adl> ex-schema:speaker ?x }

"""

print("CCF ADL 是否存在讲者(ASK):", g.query(q_ask).askAnswer)

运行后,Turtle 用分号 ; 把同一主体 ex:ccf_adl 的四条谓词缩写在一起、空白节点打印为 [];N-Triples 则每行写全一条三元组、空白节点打印为 _:N;统计结果为 “已知讲者数 = 1”,ASK 返回 True

2.3 RDF 是数据模型,不是序列化格式

必须强调:RDF 本质上是一个抽象数据模型(那张图),而不是某一种文件格式。同一张 RDF 图可以用多种序列化语法写出来,它们语义等价、可以互相转换:

序列化格式特点
RDF/XML最早的 W3C 推荐格式,用 XML 标签嵌套描述,机器友好但较冗长
Turtle最常用、最紧凑,用 ; 缩写同一主语、, 缩写同一谓词,可读性最好
N-Triples每行一条完整三元组,不做任何缩写,便于逐行处理与交换
N-Quads在三元组外再加一个 graph id 成为 “四元组”,用于区分同一事实来自哪个图(如 Freebase 还是 DBpedia),默认图称为 default graph
JSON-LD / RDFa面向 Web 开发者 / 网页嵌入的轻量写法,本节第 5 节专门讲

2.4 开放世界假设、分布式定义与带标注 RDF

开放世界假设(Open World Assumption, OWA) 与经典数据库的 封闭世界假设(CWA) 相对。在 RDF 看来,一条三元组的缺失并不是什么大不了的事:图中只有 (CCF ADL, speaker, Haofen),意味着这场讲座 “至少有一位讲者是 Haofen”,而不是 “只有 Haofen 一位讲者”。没写出来的事实,只是 “暂时不知道”,而非 “不存在”。这也是为什么后面 SPARQL 里学生没填年龄,并不代表他没有年龄。

RDF 还允许分布式地定义知识:关于 ex:haofen 的一条事实(他在 CCF ADL 讲过课)可以发布在讲座网页上,另一条事实(ex:haofen foaf:knows ex:guilin,他认识桂林)可以写在他的个人主页里。只要两处用的是同一个 URI,这些分散的三元组就能被系统自动逻辑合并成一张完整的图。如果现实中同一个对象被不同站点用了不同的 URI,则要靠第 6 章的 知识融合(实体对齐) 来归并。

此外还有带标注 RDF(annotated RDF):在标准的 “主谓宾” 之外再挂一个上下文标注 λ,记录这条事实的 来源、采集时间、时态、置信度(trust) 等。典型代表是 YAGO2 / YAGO3 对 YAGO 的时空扩展 —— 例如 “某人就任总统” 这条事实会附带生效时间段、信息来源与可信概率。这样既能表示 “何时为真、有多可信”,又能追溯出处。


3. RDFS 与 OWL:给数据装上模式层与本体约束

3.1 RDFS:为 RDF 提供类型系统

裸 RDF 只有三元组,却没有说明 “哪些谓词能用在哪些对象上”。RDFS(RDF Schema)在 RDF 之上提供了一套术语 / 概念的定义方式,也就是一个基本的类型系统(vocabulary)。它预定义的核心词汇包括:

  • rdfs:Class(类)、rdf:type(个体属于某个类)、rdfs:subClassOf(子类);

  • rdf:Property(属性)、rdfs:subPropertyOf(子属性);

  • rdfs:domain(定义域:谁能当主体)、rdfs:range(值域:客体应是什么类型)。

由此 RDF 数据被分成两层:数据层(data) 描述具体个体(如 haofen rdf:type Personccf_adl rdf:type Talk),模式层(schema) 描述类与属性的约束(如 speakerdomainTalkrangePerson)。RDFS 还能描述不同词汇集之间的关系,从而为网络上统一格式的元数据交换打基础。课件举的例子是”把自定义的 Author 声明为都柏林核心 Creator 的子类”。这里要做一个澄清:都柏林核心官方词汇中的 dc:creator 其实是一个属性(property),作用是把一份资源链接到创作它的主体,它本身不是一个类,所以严格来讲不能写成 Author rdfs:subClassOf dc:creatorsubClassOf 的两端都必须是类)。若要让自定义类与公共词汇对齐,更规范的做法是把 Author 声明为 foaf:Person(FOAF 中表示”人”的类)的子类,或在自己的命名空间下显式定义 Author 类。课件把”Creator”当作类来举例属于教学简化,读者理解其”跨词汇对齐”的意图即可。

3.2 RDFS 推理:语义感知的知识压缩

有了模式层,系统就能从显式三元组推出隐含结论。课件给了两个经典例子:

  • 已知 “谷歌 rdf:type 人工智能公司”,且 “人工智能公司 rdfs:subClassOf 高科技公司”,可推出 谷歌rdf:type高科技公司

  • 已知 “大卫·切瑞顿 投资 谷歌”,且 “投资” 的 domain 是投资人、range 是公司,可推出 大卫·切瑞顿rdf:type投资人、谷歌是一家公司。

这类推理在知识问答里非常实用:当问句问 “某投资人投资了哪些公司” 时,可以用 domain/range 推出的类型去约束候选答案的类型,显著提升答案的置信度。课件把推理概括为 “语义感知的知识压缩”—— 你不必把每个结论都手写一遍,规则会自动把它们算出来。

rdflib 的核心库不内置 RDFS 推理机,下面用 SPARQL CONSTRUCT 把三条 RDFS 规则显式写出来并应用,再顺带解析一段 OWL 本体,让你看清推理到底做了什么:

from rdflib import Graph, Namespace

from rdflib.namespace import RDF, RDFS

EX = Namespace("http://ex.org/")

g = Graph()

g.bind("ex", EX)

# ---- 数据层 ----

g.add((EX.Google, RDF.type, EX.AICompany))   # 谷歌 rdf:type 人工智能公司

g.add((EX.David, EX.investsIn, EX.Google))   # 大卫·切瑞顿 投资 谷歌

# ---- 模式层(RDFS 本体)----

g.add((EX.AICompany, RDFS.subClassOf, EX.HighTechCompany))  # 人工智能公司 ⊑ 高科技公司

g.add((EX.investsIn, RDFS.domain, EX.Investor))  # “投资”定义域是投资人

g.add((EX.investsIn, RDFS.range, EX.Company))    # “投资”值域是公司

# 用 CONSTRUCT 把三条 RDFS 推理规则显式写出来(rdflib 核心不内置推理机)

rule_subclass = """

PREFIX rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#>

PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

CONSTRUCT { ?x rdf:type ?b }

WHERE { ?x rdf:type ?a . ?a rdfs:subClassOf ?b }

"""

rule_domain = """

PREFIX rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#>

PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

CONSTRUCT { ?s rdf:type ?c }

WHERE { ?p rdfs:domain ?c . ?s ?p ?o }

"""

rule_range = """

PREFIX rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#>

PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

CONSTRUCT { ?o rdf:type ?c }

WHERE { ?p rdfs:range ?c . ?s ?p ?o }

"""

inferred = Graph()

for rule in (rule_subclass, rule_domain, rule_range):

    for triple in g.query(rule):   # CONSTRUCT 逐行产出新三元组

        inferred.add(triple)

g += inferred                     # 把推理结论并回原图

def types(node):

    return sorted(str(o).split("/")[-1] for o in g.objects(node, RDF.type))

print("谷歌 推理后的全部类型:", types(EX.Google))

print("大卫 推理后的全部类型:", types(EX.David))

# 解析一段 OWL 本体(Turtle 语法):词汇合法即可被 rdflib 解析

owl_ttl = """

@prefix ex:  <http://ex.org/> .

@prefix owl: <http://www.w3.org/2002/07/owl#> .

@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .

ex:ancestor   rdf:type owl:TransitiveProperty .            # 传递性:祖先

ex:descendant owl:inverseOf ex:ancestor .                 # 互反:后裔

ex:hasMother  rdf:type owl:FunctionalProperty .           # 函数性:每人只有一个母亲

ex:friend     rdf:type owl:SymmetricProperty .            # 对称性:朋友

ex:Athlete    owl:equivalentClass ex:SportsPlayer .       # 两个类等价

ex:Mother     owl:intersectionOf ( ex:Person ex:HasChildren ) .  # 母亲 = 人 ∩ 有孩子

"""

og = Graph()

og.parse(data=owl_ttl, format="turtle")

print("OWL 本体解析成功,三元组数:", len(og))

运行结果:谷歌的类型被补全为 AICompany、Company、HighTechCompany(子类规则推出高科技公司、值域规则推出公司),大卫被补全为 Investor;那段 OWL 本体被成功解析(10 条三元组)。

3.3 RDF (S) 的表达能力缺陷

RDFS 能表达简单语义,但在复杂场景下能力太弱,主要缺五类特征:

  1. 值域是全局的rdfs:range 对属性全局生效,无法声明 “同一属性用在某个具体类上时取特殊值域”(局部值域);

  2. 无法声明等价:不能说两个类、两个属性或两个个体其实是等价的;

  3. 无法声明不相交类:只能声明子类(男人、女人都是人的子类),不能声明二者互不相交;

  4. 没有基数约束:无法表达 “一个人恰有两位双亲”“一门课至少一名教师”;

  5. 无法描述属性特性:不能声明一个属性是传递的、函数的、对称的,或与另一属性互逆。

3.4 OWL 与三个子语言

为补上这些缺口,W3C 于 2002 年 7 月 31 日发布了 OWL(Web Ontology Language,Web 本体语言)工作草案,在 RDF (S) 之上扩展出表达本体的推荐语言。OWL 最初分为三个表达能力 / 可判定性不同的子语言:

子语言定位关键限制
OWL Lite只需要分类层次 + 简单属性约束的用户支持基数,但基数只能取 0 或 1
OWL DL包含 OWL 的全部约束,基于描述逻辑逻辑蕴涵可判定;一个类不能同时又是另一个类的实例
OWL Full允许在 RDF/OWL 预定义词汇上继续扩充,带二阶逻辑特点逻辑蕴涵通常不可判定;一个类可以同时是 “个体的集合” 和 “集合中的一个个体”,目前没有能完整支持它的推理系统

三者与 RDF 的关系是:所有 OWL 文档(Lite/DL/Full)都是 RDF 文档;反过来,所有 RDF 文档都是一个 OWL Full 文档,但只有一部分 RDF 文档是合法的 OWL Lite / DL 文档

OWL 新增的词汇正是为补齐 RDFS 的五个缺口,常用的有:

  • 等价性owl:equivalentClass(类等价,如 运动员 ≡ 体育选手)、owl:equivalentProperty(属性等价)、owl:sameAs(个体同一;早期 DAML+OIL 草案曾写作 sameIndividualAs,OWL 标准已统一为 owl:sameAs);

  • 属性特性owl:TransitiveProperty(传递,如 ancestor:小明→小林→小志,则小明→小志)、owl:inverseOf(互逆,ancestor 与 descendant)、owl:FunctionalProperty(函数性,如 hasMother、身份证号,取值唯一)、owl:SymmetricProperty(对称,如 friend);

  • 局部取值约束owl:allValuesFrom(全称,如 Person 的 hasMother 只能取 Women)、owl:someValuesFrom(存在,如语义网论文 “部分发表在” AAAI)、owl:cardinality / minCardinality / maxCardinality(基数);

  • 类的集合运算与枚举owl:intersectionOf(交集,如 Mother = Person ∩ HasChildren)、owl:unionOf(并集,如 歌手 ∪ 演员)、owl:disjointWith(不相交)、owl:oneOf(枚举)、owl:hasValueowl:InverseFunctionalProperty

3.5 OWL2 的 QL / EL / RL:在表达能力与推理代价之间取舍

OWL 的新版本称为 OWL2(老版本改称 OWL1)。它通过限制语法,定义了三个更易实现、面向不同应用的子语言。核心矛盾是:本体表达能力越强,推理的计算复杂度越高——OWL Full 不可判定;可判定的 OWL DL 复杂度还随版本上升:OWL 1 DL(描述逻辑 SHOIN (D))为 NEXPTIME-complete,OWL 2 DL(SROIQ (D))进一步升到 N2EXPTIME-complete(理论上都可判定,但最坏情况的计算代价极高),而工程上可用的子语言大多把复杂度压到多项式时间(PTime)甚至更低:

OWL2 子语言基于的描述逻辑复杂度最适合的场景
OWL2 QL(query language)DL-LiteAC0(数据复杂度极低,属常数深度电路复杂度类)概念层很、实例层极的大规模场景;通过 query rewriting(查询改写) 把带本体的查询改写成等价的普通查询,直接交给数据库执行,三者中最简单
OWL2 ELEL++PTime-Complete概念层(术语)极复杂的本体,典型如生物医疗术语本体 SNOMED CT;允许 someValuesFromintersectionOf、传递属性
OWL2 RL在 RDFS 上扩展(源于 ter Horst 2005 的工作,与描述逻辑无直接关系)PTime-Complete专为高效的实例数据推理设计,在 RDFS 上加入函数性 / 互反 / 对称 / 等价 / 局部约束;Apache Jena、Oracle 11g 的推理机都属于 RL 阵营

工具说明(不要求复现) :与这些子语言配套的是一批重型 Java 推理机 —— 描述逻辑(DL)方向有 FaCT++(曼彻斯特)、HermiT(牛津)、Pellet;EL 方向有 CEL、ELK;RL 方向有 Apache Jena、Oracle 11g;QL 方向有 QuOnto、Quill。它们是课程录制年代的主流工程,环境较重,本课程不要求搭建;本节及后续章节统一用现代 Python 的 rdflib ,通过 CONSTRUCT 规则或 SPARQL 属性路径把这些推理机制的 核心原理 演示清楚。


4. SPARQL:在知识图谱上做 “子图匹配” 查询

4.1 SPARQL 是什么、查询长什么样

SPARQL(SPARQL Protocol and RDF Query Language)是 RDF 的查询语言,基于 RDF 数据模型,可以对不同数据集撰写复杂的连接(joins),并被所有主流图数据库支持。如果说关系数据库对应 SQL,那么 RDF 图对应的就是 SPARQL。

一条 SPARQL 查询和 SQL 结构很像,也有 PREFIXFROMSELECTWHEREORDER BY 等部分,但有一个关键区别:SQL 的 FROM 后面是一张表(table),而 SPARQL 的 FROM 后面是一个 RDF 数据集(dataset,由一个默认图和若干命名图组成);三元组本身只有主、谓、宾三列,图名(graph name)属于数据集这一层,并不是每条三元组隐含自带的字段。三个最基本的概念是:

  • 变量:RDF 中的资源位置若用 ?$ 标记(如 ?student),就成了变量;

  • 三元组模板(triple pattern):写在 WHERE 子句里、允许包含变量的三元组,用来描述 “想找什么样的关系”;

  • SELECT 子句:声明最终要返回哪些目标变量。

最简单的查询 “找出所有选修 CS328 课程的学生” 如下(查默认图时不需要 FROM):

PREFIX exp: <http://www.example.org/>

SELECT ?student

WHERE {

  ?student exp:studies exp:CS328 .

}

4.2 OPTIONAL / FILTER / UNION / FROM 四个常用关键字

  • OPTIONAL(可选匹配,对应 SQL 的左连接 LEFT JOIN):查选修 CS328 的学生及其邮箱时,把邮箱放进 OPTIONAL 块,那么即使某学生没有邮箱,也会返回这条记录,只是邮箱处留空(未绑定)。

  • FILTER(过滤):对三元组模板里的某个变量追加约束,通常针对字面量或带数据类型的值,例如 FILTER (?age > 25)。年龄放在 OPTIONAL 里表示 “可以没填年龄,但填了就必须大于 25”—— 这正体现了开放世界假设:没填年龄不等于没有年龄,只是暂不知道

  • UNION(并集):查选修 CS328 CS909 的学生。要特别注意它和 OPTIONAL 的区别:如果邮箱模式写在 UNION 之外、又没用 OPTIONAL 包裹,那么邮箱就是必填项,没有邮箱的学生整条记录都不返回

  • FROM / FROM NAMED:指定要查询的默认图 / 命名图(一个 RDF 数据集可包含多个带名字的图),写在查询头部(WHERE 之前);进入 WHERE 后若要把某段三元组模板限定到某个命名图,需用 GRAPH 关键字,而不是在 WHERE 里再写 FROM

下面这段代码先在学生选课数据上演示 OPTIONAL / FILTER / UNION,再用课件的金融收购例子演示一次经典的 “两跳” 图查询:

from rdflib import Graph, Namespace, Literal

from rdflib.namespace import RDF, FOAF, XSD

EXP = Namespace("http://www.example.org/")

g = Graph()

g.bind("exp", EXP)

g.bind("foaf", FOAF)

for t in [

    (EXP.zhang, EXP.studies, EXP.CS328),

    (EXP.zhang, FOAF.mbox, Literal("zhang@example.org")),

    (EXP.zhang, EXP.age, Literal(27, datatype=XSD.integer)),

    (EXP.li,    EXP.studies, EXP.CS328),   # 小李:没有邮箱、年龄 23

    (EXP.li,    EXP.age, Literal(23, datatype=XSD.integer)),

    (EXP.wang,  EXP.studies, EXP.CS909),

    (EXP.wang,  FOAF.mbox, Literal("wang@example.org")),

]:

    g.add(t)

# OPTIONAL:选修 CS328 的学生及邮箱(无邮箱也返回,邮箱为空)

q_optional = """

PREFIX exp:  <http://www.example.org/>

PREFIX foaf: <http://xmlns.com/foaf/0.1/>

SELECT ?student ?email WHERE {

  ?student exp:studies exp:CS328 .

  OPTIONAL { ?student foaf:mbox ?email }

}

"""

print("--- OPTIONAL ---")

for r in g.query(q_optional):

    print("  ", r.student.split("/")[-1], "|", r.email)

# FILTER:选修 CS328 且年龄 > 25

q_filter = """

PREFIX exp: <http://www.example.org/>

SELECT ?student ?age WHERE {

  ?student exp:studies exp:CS328 .

  ?student exp:age ?age .

  FILTER (?age > 25)

}

"""

print("--- FILTER(age>25) ---")

for r in g.query(q_filter):

    print("  ", r.student.split("/")[-1], "|", int(r.age))

# UNION:选修 CS328 或 CS909,且必须有邮箱(无邮箱整条不返回)

q_union = """

PREFIX exp:  <http://www.example.org/>

PREFIX foaf: <http://xmlns.com/foaf/0.1/>

SELECT ?student ?email WHERE {

  ?student foaf:mbox ?email .

  { ?student exp:studies exp:CS328 } UNION { ?student exp:studies exp:CS909 }

}

"""

print("--- UNION(邮箱必填)---")

for r in g.query(q_union):

    print("  ", r.student.split("/")[-1], "|", r.email)

# 金融收购:8 条三元组构成的小型图谱

FIN = Namespace("http://ex.org/finance#")

fg = Graph()

fg.bind("fin", FIN)

for t in [

    (FIN["融创中国"], RDF.type, FIN["地产事业"]),

    (FIN["孙宏斌"], FIN.control, FIN["融创中国"]),

    (FIN["贾跃亭"], FIN.control, FIN["乐视网"]),

    (FIN["孙宏斌"], FIN.hold_share, FIN["乐视网"]),

    (FIN["王健林"], FIN.control, FIN["万达集团"]),

    (FIN["万达集团"], FIN.main_income, FIN["地产事业"]),

    (FIN["融创中国"], FIN.acquire, FIN["乐视网"]),

    (FIN["融创中国"], FIN.acquire, FIN["万达集团"]),

]:

    fg.add(t)

# 两跳查询:?P 掌控 ?c,?c 又收购 ?X —— 谁通过自己掌控的公司收购了谁

q_acq = """

PREFIX fin: <http://ex.org/finance#>

SELECT ?P ?X WHERE { ?P fin:control ?c . ?c fin:acquire ?X }

"""

print("--- 两跳子图匹配:control → acquire ---")

for r in fg.query(q_acq):

    print("  ", r.P.split("#")[-1], "→ 收购 →", r.X.split("#")[-1])

运行结果:OPTIONAL 下小李(li)依然返回、邮箱为 NoneFILTER 只留下 27 岁的 zhang;UNION 因邮箱必填而剔除了小李,只返回 zhang 和 wang;金融两跳查询锁定 孙宏斌 →(融创中国)→ 收购乐视网、收购万达集团 两条结果。

4.3 查询的本质:查询图在数据图上做子图匹配

上面的金融查询为什么能两跳命中?因为 SPARQL 的 WHERE 块本身也是一张图 —— 称为查询图?P -control→ ?c -acquire→ ?X,其中红色的 ?P、?XSELECT 的目标变量,?c 是中间桥梁。SPARQL 执行的过程,就是拿这张查询图到 RDF 数据图上去做子图匹配(subgraph matching),把能对上的节点绑定给变量返回。至于 “怎么匹配得快”,则是各类图数据库在存储与索引层面要解决的问题。

除了 SELECT,SPARQL 还提供多种查询形态:CONSTRUCT 返回一张构造出来的新图(前面做 RDFS 推理时用过)、ASK 只返回 yes /no、SPARQL 1.1 还支持 INSERT / UPDATE 写操作,以及 聚合、子查询(WHERE 中嵌套 SELECT)、属性路径(property path)、否定 等更复杂的构造。

4.4 为什么离不开本体层:从一个 “成员 + 亲戚” 查询说起

RDF 给了建模灵活性,但如果没有上层 schema,查询会变成噩梦。设想需求是 “找出这样的人:他是某机构的成员,而该机构由他的亲戚创办”。难就难在 —— 什么叫 “成员(member)”?它可能是普通成员、董事会成员、CEO、雇员、雇主、实习生;什么叫 “亲戚(relative)”?可能是兄弟、父亲、母亲、子女。如果没有本体,你只能在 SPARQL 里把这十几种谓词用一大堆 UNION 手工枚举,查询又长又脆。

有了本体就清爽了:在模式层把 worksFor / employee / boardMember / CEO … 都声明为 member子属性,把 father / mother / … 声明为 relative 的子属性(并可声明 relative 对称、parent 与 child 互逆)。于是自然语言问题只需映射成一条用 member / relative 书写的、非常简洁的 SPARQL,再由推理机把它等价展开成那条冗长但完整的查询 —— 这正是 OWL 2 QL 的 query rewriting(查询改写) 思想。要特别注意:SPARQL 本身只按图模式匹配求值,本体并不会让 SPARQL 引擎自动改写查询;改写是由推理机或 OBDA(基于本体的数据访问)层依据本体规则完成的。SPARQL 是一门声明式语言:你只描述 “想要什么”,在配置了相应推理机制(entailment regime)的引擎中,本体与规则才会把它等价展开。

4.5 业务规则推理与跨知识库查询

有些知识不适合写进本体,而是业务规则。课件的 “关联交易” 例子:数据里并没有 conn_trans(关联交易)这个属性,它由规则推出 ——

hold_share(X, Y) :- control(X, Y)                          # 掌控一家公司,即持有其股份

conn_trans(Y, Z) :- hold_share(X, Y), hold_share(X, Z)     # 同一方持股的两家公司构成关联交易

把规则展开,控制 / 持股两种关系两两组合共有 4 种情形(control-control、hold_share-hold_share、control-hold_share、hold_share-control),最初要用 4 个 UNION 块枚举;利用 SPARQL 1.1 的子查询(在 WHERE 里嵌套 SELECT)则可以大幅简化写法。

SPARQL 还擅长跨知识库(联邦)查询,这是它相对跨库 SQL 的显著优势。课件的 “新药发现” 案例:阿尔茨海默症(老年痴呆症)目前无法治愈,研究希望找到药物靶标—— 已知 “信号转导通路(signal transduction pathways)在药物靶标中很丰富(即对化学疗法有反应的蛋白)”,且 “CA1 锥体神经元在阿尔茨海默症中受损”,于是想找出 “既参与信号转导、又活跃在锥体神经元中的候选基因”。这需要同时跨四个数据源:医学主题词 MeSH(含锥体神经元)、文献库 PubMed、基因库 Entrez Gene、基因本体 GO(Gene Ontology,含信号转导通路)

这里要特别注意:这四个数据源是各自独立部署的远程 SPARQL 端点,并不在同一个本地 RDF 数据集里。因此不能FROM NAMED 把它们当成本地命名图来加载——FROM / FROM NAMED 只能声明本引擎已经持有的数据集里的图。SPARQL 1.1 为此专门引入了联邦查询(Federated Query)机制,用 SERVICE <端点IRI> { ... } 把要下发给某个远程端点执行的图模式包起来;查询引擎会自动把 SERVICE 子查询发往该端点、取回结果后再与其他图模式在本地做连接(join):

PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX go:   <http://example.org/ontology/go/>
PREFIX mesh: <http://example.org/mesh/>

SELECT DISTINCT ?gene
WHERE {
  # 把这段图模式下发到远程 GO 端点:参与"信号转导通路"的基因
  SERVICE <http://example.org/endpoint/go> {
    ?gene rdfs:subClassOf go:signal_transduction .
  }
  # 把这段图模式下发到远程 MeSH/文献端点:活跃在 CA1 锥体神经元中的基因
  SERVICE <http://example.org/endpoint/mesh> {
    ?gene mesh:associatedWith mesh:CA1_pyramidal_neuron .
  }
}

SERVICE 块内部仍可按需表达 partOfsubClassOf 等推理需求(由对应端点自身的 entailment regime 负责展开)。这样一条 SPARQL 即可同时向多个远程端点发问、在本地做连接,缩小候选基因的搜索空间 —— 这比在关系数据库里手工跨库连接要方便得多。


5. 把语义嵌进网页:JSON-LD、RDFa、Microdata,以及 RDF 与 ER 的本质分野

5.1 三种轻量级网页语义表示

很多程序员不懂 RDF,但几乎都用过 JSON / HTML。于是有了三种让普通网页也能承载语义的轻量写法,目标是让页面 “人看着正常渲染、机器能抽出三元组”。三者与 RDF 的关系并不完全相同:JSON-LD 与 RDFa 本身就是 RDF 的具体语法、直接采用 RDF 数据模型;HTML5 Microdata 则是一套独立的网页标记机制,需要通过 W3C 规范定义的 Microdata-to-RDF 映射,才会产出 RDF 三元组

(1)JSON-LD(JavaScript Object Notation for Linked Data):用 JSON 表示和传输关联数据、描述有向图,语法与普通 JSON 完全兼容。问题在于,普通 JSON 里的 namehomepageimage 这些 key 是人能猜、机器不懂的;JSON-LD 的做法是 把 key 统一成 Schema.org 等公共词汇的 URI,并对取值为资源(而非字符串)的字段用 @id 标注:

// 普通 JSON:机器不知道 name / homepage / image 是什么含义

{

  "name": "Manu Sporny",

  "homepage": "http://manu.sporny.org/",

  "image": "http://manu.sporny.org/images/manu.png"

}
// JSON-LD:key 用 schema.org 的 URI 标准化,资源型取值用 @id 标识为 URI

{

  "http://schema.org/name": "Manu Sporny",

  "http://schema.org/url":   { "@id": "http://manu.sporny.org/" },

  "http://schema.org/image": { "@id": "http://manu.sporny.org/images/manu.png" }

}

JSON-LD 呈现了语义网 “围绕某类知识提供共享术语、不为 name 重复发明概念” 的目标,却刻意没有采用 Turtle / SPARQL / 四元组存储这套较重的技术栈,而是以简单、面向普通开发者的方式推进 —— 能解析 JSON 的工具基本都能处理它,PostgreSQL、MySQL 等数据库也都能存储和查询。

(2)RDFa(Resource Description Framework in attributes):W3C 推荐的网页标记标准,通过扩充 XHTML 的几个属性把三元组直接嵌进网页标签。它用 about 指定主体 URI、用 property 指定谓词、标签内文本就是客体:

<div xmlns:dc="http://purl.org/dc/elements/1.1/"

     about="http://www.example.com/books/wikinomics">

  <span property="dc:title">Wikinomics</span>

  <span property="dc:creator">Mr right</span>

  <span property="dc:date">2006-09-02</span>

</div>

这就生成了 “该书标题 Wikinomics、创建者 Mr right、日期 2006-09-02” 三条三元组(词汇来自都柏林核心 Dublin Core),浏览器渲染照常、支持 RDFa 的搜索引擎却能直接解析。

(3)HTML5 Microdata(微数据):用带作用域的键值对给 DOM 打标记,通过 itemscope 划定实体范围、itemtype 指定实体类型、itemprop 标注属性,还允许自定义词汇表:

<section itemscope itemtype="http://data-vocabulary.org/Person">

  <h1 itemprop="name">Andy</h1>

  <p><img itemprop="photo" src="http://www.example.com/photo.jpg"></p>

  <a itemprop="url" href="http://www.example.com/blog">My Blog</a>

</section>

这定义了 “Andy 是一个 Person,具有 name /photo/url 属性” 共四条三元组(photo 复用 <img>srcurl 复用 <a>href 作为属性值)。

三者的共同价值是:网页在保持人可读的同时,让搜索引擎爬虫能解析出语义标注、形成一个个片段化的小知识图谱,再彼此关联、反过来扩充搜索引擎自身的知识图谱。

5.2 RDF + SPARQL vs ER + SQL:关系是显式的还是隐式的

关系数据库的逻辑基础是关系模型(Relational Model);而 ER(Entity-Relationship,实体 - 关系模型) 主要用于数据库的概念设计阶段,画好的 ER 图再映射成一张张关系表。语义网这边则是 RDF + SPARQL。课件用同一个问题 “腾讯位于哪个国家?” 把两者的差别讲得非常透彻。

在 RDF 语义模型里,关系是显式定义的(relationships are explicit in the model):数据是 Tencent located_in ShenzhenShenzhen located_in China 两条三元组,再把 located_in 声明为 owl:TransitiveProperty(传递属性),系统就能推理Tencent located_in China,直接回答 “In China”。关系就明明白白写在图里、对应用程序直接可用。

在 ER 关系模型里,关系是隐式声明的(relationships are implicit):你需要建公司表、城市表、公司 - 城市关联表共三张表,用外键(CO_IDCity_ID)连起来,再写一条三表 JOIN 的 SQL 才能取出国家:

-- 关系藏在表结构、外键和这条 JOIN 里;机器并不“理解” ID=CO_ID 是什么语义

SELECT Country

FROM Company_Table, City_Table, Company_City_Table

WHERE Company_Name = 'Tencent'

  AND ID = CO_ID AND City = City_ID;

那么 “关系到底存在哪?” 数据定义语句(DDL)应用程序并不使用、不具描述性、作用域还仅限单个数据库;数据字典 / 数据注册中心是给人看的;实际上关系藏在文档、SQL 代码和开发者的 集体记忆(collective memories) 里,应用程序拿不到。

5.3 数据变更时,谁更从容

最能说明问题的是数据粒度变更。假设地理信息要变细:不再直接写 “深圳位于中国”,而是 “深圳位于广东、广东位于中国”。

  • RDF 这边:只需删掉旧三元组、插入 Shenzhen located_in GuangdongGuangdong located_in China 两条新三元组。靠 located_in 的传递性,Shenzhen located_in ChinaTencent located_in China 会被重新推理出来,同一条 SPARQL 查询一个字都不用改,答案仍是 China

  • ER 这边:三张表不够用了,必须新增一张省份表(State Table)和城市 - 省份关联表(变成四张表),原来那条三表 JOIN 的 SQL 不改就只能查出空结果(Get No Answer),必须重写查询、改动应用,代价高昂,以至于业界对这类结构变更 “不惜一切代价也要避免”。

下面用 rdflib 的 SPARQL 1.1 属性路径 located_in+(在查询时计算一条或多条边的传递闭包)实证 RDF 这边 “查询不变”。要特别注意:属性路径是查询语言自身的特性,在查询时临时计算闭包,它既不要求、也不检查该谓词是否被声明为 owl:TransitiveProperty,因此无需推理机;这与「显式声明传递属性 + 启用推理机」是两条不同的技术路线:

from rdflib import Graph, Namespace

GEO = Namespace("http://ex.org/geo#")

g = Graph()

g.bind("ex", GEO)

g.add((GEO.Tencent, GEO.located_in, GEO.Shenzhen))

g.add((GEO.Shenzhen, GEO.located_in, GEO.China))

# 属性路径 located_in+ 在查询时直接计算“一条或多条 located_in 边”的闭包;

# 它是 SPARQL 查询语言特性,不要求谓词声明为 owl:TransitiveProperty,因此无需推理机。

# (若走 OWL 传递属性推理这条路线,则必须显式声明 TransitiveProperty 并启用推理机。)

q_path = """

PREFIX ex: <http://ex.org/geo#>

SELECT ?place WHERE { ex:Tencent ex:located_in+ ?place }

"""

def reachable():

    return [r.place.split("#")[-1] for r in g.query(q_path)]

print("变更前,腾讯沿 located_in 可逐级到达:", " → ".join(reachable()))

# 需求变更:地理粒度变细,插入“广东省”,去掉深圳直连中国

g.remove((GEO.Shenzhen, GEO.located_in, GEO.China))

g.add((GEO.Shenzhen, GEO.located_in, GEO.Guangdong))

g.add((GEO.Guangdong, GEO.located_in, GEO.China))

print("变更后,腾讯沿 located_in 可逐级到达:", " → ".join(reachable()))

print("(同一条 SPARQL 一字未改,China 始终在传递闭包中,答案不变)")

运行结果:

变更前,腾讯沿 located_in 可逐级到达: Shenzhen → China

变更后,腾讯沿 located_in 可逐级到达: Shenzhen → Guangdong → China

(同一条 SPARQL 一字未改,China 始终在传递闭包中,答案不变)

5.4 从 “哑巴数据 + 聪明代码” 到 “智能数据 + 统一推理”

对比至此,就能回答 “数据的智能性到底体现在哪”。今天绝大多数企业应用的基石仍是关系数据库,但关系数据库里的数据本身是哑巴数据(Dumb Data),智能性主要体现在应用层代码和 SQL 里(Dumb Data + Smart Application Code)。而知识图谱(尤其是带推理的知识图谱)把关系显式地表示在数据中,图天然是 schemaless 的 —— 增加节点和边非常容易,不必像关系表那样改字段、改表结构;再在其上叠加 RDFS / OWL 本体,这些词汇本身就携带推理能力。于是范式翻转为 Smart Data(RDF/OWL 本体)+ Uniform Inference Engine(统一推理引擎):数据自己带着语义和规则,推理则蕴含在后续的查询与问答过程之中。


📝 动手练一练

  1. 概念辨析(开放世界与查询关键字):判断下列说法是否正确并说明理由。

    ① “在一张 RDF 图里查不到某公司的 CEO 三元组,就说明这家公司没有 CEO。”

    ② “SPARQL 中,把邮箱模式写在 OPTIONAL 里,与写在 UNION 分支之外、不加 OPTIONAL,对 ‘没有邮箱的学生’ 返回结果相同。”

👉 点击查看参考答案

错误。RDF 遵循开放世界假设(OWA):三元组缺失只代表 “当前不知道 / 未录入”,不等于事实不存在。正确表述是 “图中已知没有记录该公司 CEO”,不能据此断言它没有 CEO。

错误,二者行为相反。OPTIONAL 对应左连接:即使学生没有邮箱,也会返回该学生、邮箱变量留空(未绑定);而邮箱模式写在 UNION 之外又不加 OPTIONAL 时,邮箱成为必填匹配项,没有邮箱的学生整条记录都不会返回。

  1. 动手构造与查询:用 Turtle 写出 “深圳位于广东、广东位于中国、腾讯位于深圳” 三条三元组,再用 SPARQL 1.1 属性路径 located_in+ 查出腾讯沿 located_in 能逐级到达的所有地点(属性路径在查询时计算闭包,无需声明传递属性、也无需推理机)。
👉 点击查看参考答案
from rdflib import Graph, Namespace

GEO = Namespace("http://ex.org/geo#")

g = Graph()

g.bind("ex", GEO)

ttl = """

@prefix ex: <http://ex.org/geo#> .

ex:Shenzhen  ex:located_in ex:Guangdong .

ex:Guangdong ex:located_in ex:China .

ex:Tencent   ex:located_in ex:Shenzhen .

"""

g.parse(data=ttl, format="turtle")

# 属性路径 located_in+ = 查询时计算一条或多条 located_in 边的闭包(无需推理机)

q = """

PREFIX ex: <http://ex.org/geo#>

SELECT ?place WHERE { ex:Tencent ex:located_in+ ?place }

"""

print(" → ".join(r.place.split("#")[-1] for r in g.query(q)))

预期输出:Shenzhen → Guangdong → China,即无需专门推理机,仅靠 SPARQL 属性路径在查询时计算闭包,就能从 “腾讯在深圳” 一路得到 “腾讯在中国”。


本章小结

本节沿着 W3C 语义网标准栈自下而上,完整搭起了知识表示与查询的框架:

  • RDF 是数据模型而非文件格式:用 “主体 — 谓词 — 客体” 三元组描述万物,主体可以是 URI 或空白节点(匿名资源、无需 URI),谓词必须是 URI,客体可以是 URI、字面量(带数据类型 / 语言标签)或空白节点;同一张图可用 RDF/XML、Turtle、N-Triples、N-Quads、JSON-LD 等多种语法序列化。

  • 开放世界与分布式:RDF 持开放世界假设(查不到≠不存在),知识可分布式定义、靠相同 URI 自动合并,带标注 RDF 还能附带来源、时间与置信度。

  • RDFS / OWL 是模式与本体层:RDFS 提供类型系统与子类、定义域 / 值域约束并支持简单推理;OWL / OWL2 补齐等价、属性特性、基数与复杂类,并在表达能力与推理复杂度之间派生出 QL / EL / RL 三个实用子语言。

  • SPARQL 是图查询语言:三元组模板拼成查询图、在数据图上做子图匹配,OPTIONAL / FILTER / UNION / CONSTRUCT / ASK / 子查询 / 属性路径 覆盖复杂检索与跨库查询;配合推理机或 OWL 2 QL 的查询改写(OBDA),本体层可让声明式查询等价展开、填补语义间隙(SPARQL 引擎本身不自动改写),业务规则可表达关联交易这类隐含关系。

  • JSON-LD / RDFa / Microdata 让语义低成本地嵌进网页;RDF+SPARQL 让关系显式化、数据变更时查询不用改,对应 “智能数据 + 统一推理引擎”,这正是它相对 ER+SQL“哑巴数据 + 聪明代码” 的根本优势。

📋 行动清单

  • 在本地用 uv run --with rdflib python 跑通本节 4 段代码,亲手建图、做一次 RDFS CONSTRUCT 推理、跑一条两跳 SPARQL。

  • 默画出 “RDF → RDFS → OWL/OWL2 → SPARQL” 的分层图,并说清每层各自解决什么问题、QL/EL/RL 分别适合什么场景。

  • 用开放世界假设向身边人解释一个 “查不到 ≠ 不存在” 的例子,再对比说明 OPTIONALUNION 对缺失值的不同处理。

  • 用 located_in 传递属性路径的例子,向自己复述一遍:为什么地理粒度变细时 SPARQL 不用改、而三表 JOIN 的 SQL 必须重写。

—— 小象教研组

配套学习资源与课件
  • 第2章课件:知识表示和知识建模
    下载
  • 第2章示例数据(kgexample.owl)
    下载
  • 知识图谱课程思维导图(KG_Centralized.xmind 全课程结构图)
    下载
🎁 免费学习资源

领取《小象 11GB VIP 课件资料包与大厂真题手册》

包含全套实战 Jupyter 源码、清洗后数据集、大厂高频面试真题与专属学员答疑交流群。

  • 完整 Python / 数据分析 Jupyter 实战源码
  • 大厂真实业务数据集与练习题
  • 微信扫码添加顾问免费领取;想学什么,直接告诉顾问
微信二维码:扫码添加课程顾问微信扫码添加顾问