📑 查看全课大纲(第 15 / 26 节)
- 1.知识图谱和语音技术概述
- 2.典型知识库项目简介
- 3.知识图谱技术概览
- 4.典型应用案例
- 5.早期知识表示简介
- 6.基于语义网的知识表示框架
- 7.典型知识库项目的知识表示
- 8.基于本体工具的知识建模实践
- 9.面向非结构化数据的知识抽取
- 10.面向结构化数据的知识抽取
- 11.面向半结构化数据的知识抽取
- 12.实践:基于百科数据的知识抽取
- 13.面向文本的知识抽取
- 14.知识挖掘
- 15.从一个例子开始
- 16.图数据库介绍
- 17.什么是知识融合
- 18.知识融合的基本技术流程
- 19.典型知识融合工具简介
- 20.典型案例简介
- 21.LIMES实战演练
- 22.知识推理
- 23.语义搜索
- 24.知识问答
- 25.IBM watson Lite
- 26.行业知识图谱应用
从一个例子开始
约 50 分钟
小象实战讲义 · 知识图谱
前面几讲我们解决了”知识怎么表示、怎么抽取”的问题:RDF 三元组是知识的语言,RDFS/OWL 是 schema 的语言,SPARQL 是查询的语言。但这些三元组最终要落在某个数据库里,才能被持久保存、被高效查询、被应用程序访问。本讲进入「知识存储与图数据库」,本节作为全章的开场,不急于堆概念,而是用一个贯穿始终的音乐知识图谱例子,完整走一遍”定义 schema → 随机造数 → 导入图数据库 → 写 SPARQL 查询 → 用 SPARQL 更新”的链路。课程演示以 Java 生态的 Apache Jena/Fuseki 为参照,本讲义把每一步都用 Python(rdflib、networkx)在本地复刻成可运行代码,让你不装任何服务也能亲手跑通。
💡 核心导读
- 先有 schema,再有数据:音乐图谱只有三类节点(歌手 artist、歌曲 track、专辑 album)和两类边(数据类型属性指向字面量、对象属性指向节点),把这张 schema 图看懂,后面的每条三元组、每条 SPARQL 都是在它上面”填空”。
- 人造数据是测试图数据库的起点:用一个带随机数的生成器按 schema 批量产出 N-Triples(
.nt)文件,并刻意制造”非均匀分布”(高产歌手、歌曲数不等的专辑),这样才能测出数据库在稠密图上的真实性能。 - 图数据库以”图”为存储与查询模型:节点、有方向且有名字的边、节点与边上的键值属性是它的四要素;Apache Jena 是语义 Web 领域一个功能完整、被广泛使用的开源 Java 框架(并非 W3C 官方”参照实现”),内部可分为导入、存储(SDB/TDB)、推理、表示 API 与 Fuseki 服务几层。
- 批量导入与逐条插入的差别在事务:批量加载(bulk loading)用一批数据一次提交,省掉逐条的事务原子性检查,换来数量级的导入提速;导入后磁盘上是按 SPO/POS/OSP 等多种排列组织的索引文件。
- SPARQL 查询的本质是”查询图”:一条三元组模式就是一条边,点号连接就是逻辑与;SELECT 锚定常量节点、用变量填空,再配合 DISTINCT、ORDER BY、LIMIT、COUNT、UNION、FILTER、regex、ASK,就能覆盖绝大多数检索需求;SPARQL 1.1 的 INSERT/DELETE 则让图可以在运行中动态加属性,这正是知识图谱 schema-less 灵活性的体现。
1. 先造一块数据:音乐知识图谱的 schema 与生成器
1.1 三类节点、两类边:把 schema 画出来
课程配套的压缩包里有三样东西:一份说明文档、一个 Python 数据生成器、一个汇总了所有 SPARQL 查询及其注释的文本文件。生成器负责”造数”,查询文件负责”用数”,它们与本节的五个动作(数据来源、数据描述、数据导入、数据查询、数据更新)一一对应。
为什么要自己造数据?因为学习图数据库时,我们希望能按需求构造足够大规模的数据,再用不同规模、不同稠密度的数据去测试各种图数据库的性能。真实数据又脏又难拿,而一个受控的人造生成器可以让变量完全掌握在自己手里。
课程选定的领域是音乐。先定义 schema(结合实例层画出来的示意图):
PREFIX m: <http://kg.course/music/>
m:artistID (歌手) m:trackID (歌曲) m:albumID (专辑)
节点(Node) 节点(Node) 节点(Node)
│ ╎ │ │ │ ╎ │
│ ╎ m:artist_name │ │ │ ╎ m:track_tag │ m:album_name
│ ╎ (虚线:初始不生成, │ │ │ ╎ (数据类型属性) │ (数据类型属性)
│ ╎ 后续用 SPARQL Update 加) │ │ │ ╎ ▼
▼ ╎ ▼ │ ▼ ╎ 字面量(Literal)
字面量(Literal) m:track_name xsd:string
xsd:string (数据类型属性→字面量)
▼
字面量 xsd:string
对象属性(Object Property,边指向节点):
m:trackID ──m:track_artist──▶ m:artistID (歌曲 → 演唱它的歌手)
m:trackID ──m:track_album───▶ m:albumID (歌曲 → 所属专辑)这张图里有几个 RDF 的关键构件,全部沿用第 2 讲的术语:
- 节点(Node):三类对象,用 URI 标识,分别是歌手
m:artistID、歌曲m:trackID、专辑m:albumID。 - 字面量(Literal):属性的取值,如歌曲名、专辑名,类型是
xsd:string。 - 数据类型属性(Datatype Property):蓝色的边,从节点指向字面量,包括
m:track_name(歌曲名)、m:track_tag(歌曲标签)、m:album_name(专辑名)、m:artist_name(歌手名)。 - 对象属性(Object Property):从节点指向节点的边,包括
m:track_artist(歌曲到歌手)和m:track_album(歌曲到专辑)。 - 命名空间前缀:
m是前缀(prefix),它的全名(namespace)是http://kg.course/music/,于是m:track_name展开后就是http://kg.course/music/track_name。
用 Turtle 把这份 schema 形式化,就是下面这段本体声明(rdfs:domain 限定主体类型、rdfs:range 限定取值类型):
@prefix m: <http://kg.course/music/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
# 三类节点先声明为类(课件示意图把它们标为 Node,这里补上类声明,
# 下面 domain/range 引用它们才符合 RDFS 语义;类名沿用课件 m:artistID 等写法)
m:artistID a owl:Class .
m:trackID a owl:Class .
m:albumID a owl:Class .
# 对象属性:边的两端都是节点
m:track_artist a owl:ObjectProperty ;
rdfs:domain m:trackID ;
rdfs:range m:artistID .
m:track_album a owl:ObjectProperty ;
rdfs:domain m:trackID ;
rdfs:range m:albumID .
# 数据类型属性:边的终点是字符串字面量
m:track_name a owl:DatatypeProperty ;
rdfs:domain m:trackID ;
rdfs:range xsd:string .
m:track_tag a owl:DatatypeProperty ;
rdfs:domain m:trackID ;
rdfs:range xsd:string .
m:album_name a owl:DatatypeProperty ;
rdfs:domain m:albumID ;
rdfs:range xsd:string .
# m:artist_name 也是 DatatypeProperty,但初始数据里刻意不生成(见第 5 节)
m:artist_name a owl:DatatypeProperty ;
rdfs:domain m:artistID ;
rdfs:range xsd:string .注意示意图中 artist_name 画成虚线:生成器在最初造数时故意不生成歌手名这个属性,也不生成任何 artist 到 artist_name 的三元组,留到后面用 SPARQL Update 语句动态补写。这个伏笔是为了让你直观看到——知识图谱的 schema 不是关系数据库里”建表时定死、改列要 ALTER TABLE”的硬结构,而是可以在构建与查询过程中动态调整的软约束。
1.2 数据生成器:用随机数造出”非均匀”的稠密图
课程的生成器脚本叫 kg_music_triples.py,用法很简单:
# 默认生成 1000 个三元组,在当前目录产出 music_1000_triples.nt
python kg_music_triples.py
# 传入参数 500,产出 music_500_triples.nt(500 行)
python kg_music_triples.py 500
# 传入 100000,即可产出十万行规模的数据,用于压测
python kg_music_triples.py 100000生成器里用了随机数,但目标不是”均匀”,恰恰相反,要刻意制造非均匀分布:不同歌手演唱的歌曲数量不同、不同专辑包含的歌曲数量不同、每首歌携带的标签数量也不同。这样生成的图里会出现”非常高产的歌手""收歌特别多的专辑""标签特别丰富的歌曲”,形成局部稠密的子图——真实的知识图谱(以及真实的查询负载)本来就是高度不均匀的,用均匀分布的假数据测出来的性能没有参考价值。
下面用 rdflib 复刻这个生成器(设置 random.seed(42) 保证结果可复现;为了便于阅读输出,默认参数调小,把三个数量参数调大即可复现课程里 1000 条乃至十万条的规模):
# 音乐知识图谱数据生成器(复刻课程 kg_music_triples.py 的思路,rdflib 实现)
import random
from collections import Counter
from rdflib import Graph, Namespace, Literal
from rdflib.namespace import XSD
random.seed(42) # 固定随机种子,保证每次运行结果一致
M = Namespace("http://kg.course/music/") # 命名空间,对应课件 PREFIX m:
def build_music_graph(n_artists=5, n_albums=8, n_tracks=30, n_tags=6):
"""按课件 schema 随机生成音乐图谱。
节点:artist/track/album;对象属性:track_artist、track_album;
数据类型属性:track_name、track_tag、album_name(初始不含 artist_name)。"""
g = Graph()
g.bind("m", M)
tags = [f"tag_name_{i:02d}" for i in range(1, n_tags + 1)]
# 每位歌手的“产量权重”1~5 不等,制造个别高产歌手(非均匀分布的来源之一)
weights = [random.randint(1, 5) for _ in range(n_artists)]
# 专辑节点:只有 album_name 一个数据类型属性
for al in range(1, n_albums + 1):
g.add((M[f"album_{al:04d}"], M.album_name,
Literal(f"album_name_{al:04d}", datatype=XSD.string)))
# 歌曲节点:歌名 + 所属歌手 + 所属专辑 + 1~4 个不等的标签
for t in range(1, n_tracks + 1):
track = M[f"track_{t:05d}"]
g.add((track, M.track_name,
Literal(f"track_name_{t:05d}", datatype=XSD.string)))
# 按权重抽歌手:权重高的歌手“唱到”的歌更多
a = random.choices(range(1, n_artists + 1), weights=weights)[0]
g.add((track, M.track_artist, M[f"artist_{a:02d}"]))
# 专辑均匀归属,但随机落点本身就会让各专辑歌曲数不等
al = random.randint(1, n_albums)
g.add((track, M.track_album, M[f"album_{al:04d}"]))
# 每首歌 1~4 个标签,标签多寡不一
for tag in random.sample(tags, k=random.randint(1, 4)):
g.add((track, M.track_tag, Literal(tag, datatype=XSD.string)))
return g
g = build_music_graph()
nt_lines = [ln for ln in g.serialize(format="nt").splitlines() if ln.strip()]
print("三元组总数:", len(g), "| N-Triples 行数:", len(nt_lines))
print("--- 前 6 行 .nt 数据 ---")
for ln in nt_lines[:6]:
print(ln)
# 统计每位歌手唱了多少首歌,验证分布确实不均匀
c = Counter(str(a).split("/")[-1] for _, _, a in g.triples((None, M.track_artist, None)))
print("--- 各歌手歌曲数(非均匀)---", dict(sorted(c.items())))
print("初始 artist_name 三元组数:", len(list(g.triples((None, M.artist_name, None)))))运行后可以看到三元组总数、每个歌手的歌曲数确实参差不齐,而 artist_name 的数量为 0——数据从出生起就带着第 5 节要补的”缺口”。
1.3 N-Triples:一行就是一个三元组
生成器的产出是 .nt 文件,nt 即 N-Triples,是 RDF 最朴素的序列化格式:一行一个三元组,纯文本,用任意文本编辑器都能打开,可读性很强。上面代码序列化出的行形如:
<http://kg.course/music/track_00011> <http://kg.course/music/track_tag> "tag_name_05"^^<http://www.w3.org/2001/XMLSchema#string> .
<http://kg.course/music/track_00024> <http://kg.course/music/track_album> <http://kg.course/music/album_0005> .
<http://kg.course/music/track_00011> <http://kg.course/music/track_name> "track_name_00011"^^<http://www.w3.org/2001/XMLSchema#string> .逐行解读一行 N-Triples,就是把 schema 图”走”一遍:
- 主体是一个 URI,如
.../track_00011,即m:track_00011(track 后接数字 id,这个 id 是生成器数字化编号后拼进 URI 的); - 谓词是属性 URI,如
m:track_name、m:track_album、m:track_tag; - 客体要么是带引号和类型标注的字面量(
"tag_name_05"^^xsd:string),要么是另一个节点 URI(m:album_0005); - 每行以一个点号结尾。
于是顺着文件一行行读,就能在脑中拼回上一页那张图:这首歌叫什么名字、属于哪张专辑(专辑又有专辑名)、由哪位歌手演唱、带有哪些标签。当成百上千行这样的三元组汇在一起,一张更大的音乐图谱就成型了。记住这种”主体 URI、谓词 URI、客体要么是 URI 要么是字面量”的形态——它决定了后面图数据库在磁盘上如何组织索引。
2. 存到哪里:图数据库与 Apache Jena
2.1 什么是图数据库:节点、有向命名边与属性
有了数据,紧接着的问题是:知识图谱应该怎么存储? 课程给出的答案是图数据库(Graph Database)。课件引用了维基百科的定义并翻译为中文:图数据库源起欧拉与图理论(graph theory),也称为面向/基于图的数据库,其基本含义是以”图”这种数据结构来存储和查询数据;数据模型主要以节点和关系(边)来体现,也可以处理键值对,长处是快速解决复杂的关系问题。
图具有四条特征:
- 包含节点和边;
- 节点上有属性(键值对);
- 边有名字和方向,并且总是有一个开始节点(start node)和一个结束节点(end node);
- 边也可以有属性。
第 3 点值得和超文本对比着理解:网页里的超链接(hyperlink)也是一条边,但它没有名字,只表达”从这页跳到那页”;而图数据库的边是命名的有向边,track_00001 ──track_artist──▶ artist_02 这条边明确说出了”由……演唱”这层语义,方向也固定(从歌曲指向歌手,不能反过来读)。
需要说明的是,维基百科这套”节点带属性、边带名字方向、边也能带属性”的表述,其实更贴近 Neo4j 那一类属性图(property graph)的口吻,和我们一直使用的 RDF 三元组模型并不完全同形(RDF 里要给”边”本身挂属性,需要借助具体化(reification,RDF 标准术语)或 RDF-star 等机制把一条边也建模成资源)。两套模型之间可以互相编码、互相转换,但语义与表达方式并不严格等价(例如属性图原生支持边属性,RDF 原生三元组没有这一层),本讲先用 RDF 三元组把例子走通,两种数据模型的系统对比留到本节之后的下一节展开。
无论哪种模型,图数据库要解决的核心问题只有两个:给定一个图结构的数据,怎么把它存下来;以及怎么在存储之上有效支持查询——而我们的查询语言,就是第 2 讲已经见过面的 SPARQL。
2.2 Jena 的分层架构:导入、存储、推理、表示,再加 Fuseki
课程的整个例子基于 Apache Jena。它是一个免费开源的 Java 框架,用于构建语义网络(Semantic Web)与数据链接(Linked Data)应用,最早由惠普实验室开发,后来完全开源并贡献给 Apache 基金会,官网是 jena.apache.org,同时支持内存存储与持久化存储。
结合课程展示的架构图,可以把 Jena 自下而上分成五层(本讲重点走存储与服务两层,推理层只做指认):
┌──────────────────────────────────────────────────────────────┐
│ 应用/服务层 Fuseki:HTTP + RESTful 的 SPARQL 端点服务 │
│ (/query 查询端点、/update 更新端点,供程序与浏览器访问)│
├──────────────────────────────────────────────────────────────┤
│ 表示/API 层 RDF API(三元组/图模型) │
│ RDFS / OWL 本体 API 查询 API(SPARQL) │
├──────────────────────────────────────────────────────────────┤
│ 推理层 内置规则推理;Rete 前向链式推理机;LP 后向链式推理; │
│ 可外接推理机(如 Protégé 内嵌、支持描述逻辑的 HermiT)│
├──────────────────────────────────────────────────────────────┤
│ 存储层 内存存储(in-memory) │
│ 持久化:SDB(架在关系数据库之上,负责把 SPARQL 改写 │
│ 成 SQL) / TDB(原生三元组库) │
├──────────────────────────────────────────────────────────────┤
│ 导入/解析层 RDF/XML、Turtle、N-Triples(.nt)、RDFa 等序列化格式 │
└──────────────────────────────────────────────────────────────┘- 导入/解析层(RIOT,RDF I/O Toolkit)负责把各种 RDF 序列化格式读进来,包括第 2 讲讲过的 RDF/XML、Turtle、N-Triples(即
.nt)、JSON-LD 等。需要澄清一点:RIOT 的内置默认解析器并不直接解析 RDFa——RDFa 是嵌在 HTML 网页里的,要解析它需额外引入 RDFa 解析依赖(基于 RDFa 库)并把该语言注册进 RIOT 的解析器注册表,并非开箱即用;所以别把”支持 RDFa”理解成 RIOT 默认就能直接读 HTML 页面。 - 存储层有两种持久化路线,这是本节要分清的第一组概念:
- SDB:架在关系数据库之上的存储方案,把 RDF 三元组物化(materialize)保存在关系表里,并把上层发来的 SPARQL 查询翻译成 SQL 再交给关系库执行(SPARQL→SQL 的查询改写发生在这一层,而不是推理层)。注意与第 3 讲讲过的 Ontop 一类 OBDA(基于本体的数据访问,Ontology-Based Data Access,配合 R2RML 映射)区分:OBDA 不物化 RDF,关系库里仍是原有的业务表,只在查询时通过查询重写给出一层虚拟的 RDF 视图;SDB 则是真把三元组落进了关系表。历史状态(务必知道):SDB 模块已被 Jena 团队退役(retired)并不再维护,最后一个随附它的版本是 Jena 3.17.0,此后的 Jena 不再带 SDB;Jena 官方明确建议新项目一律改用 TDB(性能与可扩展性明显更好)。今天再遇到 SDB 只需把它当历史方案理解,不必上手。
- TDB:Jena 自带的原生三元组库(native triple store),即原生图存储方案,性能比 SDB 高出不少,是课程主推、也是 Fuseki 持久化数据集默认使用的后端。原生三元组库在磁盘上到底怎么组织,本节第 2.4 小节先建立直觉,深入的六排列索引、字典编码与 JOIN 算法在本章下一节(存储内核)细讲。
- 推理层包含内置规则推理、基于 Rete 算法的前向链式(forward-chaining)推理机,以及 LP 风格的后向链式(backward-chaining)规则推理,还可以挂载外部推理机(例如 Protégé 里内嵌的 HermiT,一个支持描述逻辑的推理机)。课件口播在介绍这一层时顺带提到了”转换成 SQL 的查询改写”,那其实是上面 SDB 存储方案的能力,这里按职责归位。本讲主题是存储而非推理,这一层只做指认、不展开。
- 表示/API 层就是第 2 讲的 RDF 数据模型、RDFS/OWL 本体 API 与 SPARQL 查询 API。
- 最上面的 Fuseki 是 Jena 的 SPARQL 服务器:它把底层数据集通过 HTTP 协议、以 RESTful 接口暴露出来,让浏览器和各种语言写的应用程序都能用标准 HTTP 请求查询和更新图谱。
Jena 是 Java 生态的工具,本讲义不要求你安装 JDK 或部署服务;下面涉及 Jena/Fuseki 的命令与界面,我们只客观讲清它”在做什么”,而同样的动作(导入、查询、更新)都会给出 rdflib 的 Python 可运行版本,二者发出的 SPARQL 语句是完全一致的。
2.3 批量导入为什么快:bulk loading 与事务
把 .nt 装进 TDB 有两种途径:通过 Fuseki 的 Web 界面手工导入(第 3 节演示),或用 TDB 自带的命令行工具 tdbloader。课程给出的两条命令如下:
# 1) 批量导入:--loc 指定数据库在磁盘上的目录,filename 是 .nt 文件(可相对/绝对路径)
/jena-fuseki/tdbloader --loc=/jena-fuseki/data music_1000_triples.nt
# 2) 启动 Fuseki 服务:指定 TDB 数据路径,并以可更新模式挂载名为 music 的数据库
/jena-fuseki/fuseki-server --loc=/jena-fuseki/data --update /musictdbloader 做的事情叫 bulk loading(批量加载,也叫 data loading),它与”一条一条 INSERT”有本质区别,理解这一点是理解数据库导入性能的关键:
- 数据库都有事务管理(transaction management),事务具有原子性——一条插入要么完整提交(commit),要么因超时等错误整体回滚(rollback)。
- 如果逐条插入,每一条三元组都要走一遍原子性检查、等待 commit 确认,一千万条数据就是一千万次事务开销,大量时间耗在事务簿记上。
- 批量加载则把一整批数据一次性导入,不再为每条记录单独做事务检查与提交,以牺牲部分细粒度的原子性保证为代价,换来导入性能的大幅提升。这与关系数据库里逐条
INSERT慢、用LOAD DATA/import工具批量导文件快,是同一个道理。
2.4 磁盘上的索引直觉:从 SPO 到 GSPO 的排列
导入完成后,用 tree 之类的命令查看 --loc 指定的目录,会看到一堆与 .nt 文本完全不同的文件,它们就是 TDB 的索引文件。课程不要求掌握这些文件的二进制结构,但有几个概念必须建立:
- 一条三元组是 SPO:主体 subject、谓词 predicate、客体 object。
- 但在 RDF 数据集里,存储的逻辑单元是四元组:在 SPO 之外还留了一个图位置 G(graph slot)。按 RDF 1.1 规范,一个数据集由唯一一个默认图(default graph)和零到多个命名图(named graph)组成:命名图带图名 IRI,而默认图本身没有名称;未显式指定命名图的三元组都进入默认图,底层在 G 位置用一个固定的系统标记占位。课件口播把这个位置笼统称为”default graph name”,理解索引结构时不妨把它记作 G,但要知道默认图并没有真实的图名 IRI。所以底层视角是 GSPO。
- 索引的本质是”按某种顺序排好序的数据副本”。只按 SPO 一种顺序排序是不够的:查询”某歌手的所有歌”是给定客体 O(歌手)反查主体 S(歌曲),如果只有 SPO 一种顺序,就只能全表扫描。于是 TDB 维护了多种排列的索引:SPO、POS、OSP……三元组三个位置共有 3×2×1 = 6 种排列;带上图名 G 的四元组理论上有 4×3×2 = 24 种排列。不同的查询模式走不同排列的索引,就能把”大海捞针”变成”有序查找”。要注意:理论上 24 种并不意味着真的物化 24 份数据副本(那样磁盘与写入代价太大),TDB 实际只物化其中 6 种四元组索引子集——SPOG、POSG、OSPG(把 G 放在末位,由三元组索引派生)与 GSPO、GPOS、GOSP(把 G 提前),用这 6 种覆盖最常见的查询模式。
这一小节只需要建立”一份数据、多种排列、为不同查询模式服务”的直觉;至于为什么常见实现聚焦六排列(hexastore)、字符串 URI 如何先做字典编码压成整数、多个三元组模式之间如何做 JOIN,都属于存储内核话题,在本章下一节系统展开。
3. 把库跑起来:Fuseki、端点与 Python 访问
3.1 Docker 一行启动,界面里建库与导数据
课程演示了用 Docker 运行 Jena Fuseki 的方式。Docker 可以理解为一种容器技术,把软件连同它依赖的运行环境打包成镜像,拉下来即可运行,免去本机装 Java、配环境变量的麻烦(Docker 本身的安装参考 docker.com)。Docker Hub 上已经有打包好 Fuseki 的镜像 stain/jena-fuseki,两条命令即可就绪(不指定版本号拉取的就是最新版本):
# 1) 拉取镜像
docker pull stain/jena-fuseki
# 2) 启动容器(课程课件原命令)
docker run -d -p 3030:3030 -e "ADMIN_PASSWORD=test@jena" stain/jena-fuseki第二个命令的参数逐个拆解:
-d:让容器在后台以守护(daemon)方式运行服务;-p 3030:3030:把主机端口与容器端口做映射,Fuseki 默认监听 3030;-e "ADMIN_PASSWORD=test@jena":通过-e注入环境变量,设置管理员 admin 的登录密码为 test@jena;- 镜像内置了启动脚本,容器起来后会自动把 fuseki-server 拉起来。
随后在本机浏览器打开 http://localhost:3030,输入用户名 admin、密码 test@jena 即可进入 Fuseki 界面。建库与导数据的完整点击路径是:
- Manage datasets(管理数据集)→ Add one,跳到 Add new dataset 页面;
- Dataset name 填
music;存储类型二选一:- In-memory(内存):数据只在内存里,重启 Fuseki 就丢失,适合自己试玩;
- Persistent(持久化):数据落到磁盘(后端即 TDB),重启不丢,真正的生产(production)数据必须选持久化;现在 Fuseki 主推 TDB,SDB 在界面流程里基本不起作用;
- 点 Create dataset 完成建库,Existing datasets 列表里就出现了 music;
- 点 Upload data,选择生成器产出的
.nt文件(可以一次选一个或多个),点 Upload now / Upload all; - Fuseki 支持导入任意 RDF 格式:除了
.nt(N-Triples),还包括 RDF/XML、JSON-LD、Turtle 等第 2 讲讲过的全部序列化格式; - 导入完成后界面提示 Reload successful,1000 triples(数字随文件而变),可以直接看到灌进了多少三元组。
注意这是两种不同的导入机制,不要混为一谈:Fuseki Web 界面上传走的是 Fuseki 运行中的数据集 API,把文件解析后通过在线事务(transaction)写进 TDB,适合边跑边补的中小批量;而第 2.3 小节的 tdbloader 是离线命令行批量加载器,它直接在底层构建 TDB 的 B+ 树索引文件、要求该数据目录此时没有被运行中的 Fuseki 占用,适合大规模初次导入。二者目标都是把三元组装进 TDB,但一个是“服务在线、逐条事务写入”,一个是“停机离线、批量建索引”,并不是同一条命令——界面上传并不调用 tdbloader。
3.2 两个端点:query 与 update
服务起来后,Fuseki 通过两个 SPARQL endpoint(端点)对外提供能力,课件给出的地址是:
SPARQL Query : http://localhost:3030/music/query
SPARQL Update: http://localhost:3030/music/update这个 URL 的拼接方式是典型的 RESTful 风格:协议://主机(IP 或域名):端口/数据库名/操作。3030 是端口,music 是数据库名,最后一段 query 或 update 标明这次请求要做查询还是更新。Fuseki 图形界面里对应的就是 Query 与 Update 两个标签页:写 SELECT/ASK 时停在 query 端点,写 INSERT/DELETE 前要先切到 update 端点(第 5 节还会强调)。
3.3 用代码访问端点:SPARQLWrapper,以及本讲义的替代方案
除了点界面,应用程序更多是用代码访问端点。课程推荐 Python 的 SPARQLWrapper 包(对 SPARQL endpoint 的一层封装,文档在 rdflib.github.io/sparqlwrapper/)。它的使用要点(以下为结构示意,需先启动 Fuseki 才能真正运行,故不作为可执行代码):
# 查询:默认就是 query endpoint,无需额外指定
# setQuery(...) 放入 SPARQL SELECT/ASK 语句
# setReturnFormat(JSON) 指定返回格式(也支持 XML 等),常取 JSON
# query().convert() 发请求并把结果转换为 JSON 后遍历
# 查询请求协议允许 GET(语句拼在 URL 上)或 POST(语句放请求体,长查询适用)
# 更新:必须显式指定 update endpoint
# 按 SPARQL 1.1 Protocol,更新操作一律使用 POST(这是协议规定,与数据量大小无关;
# 课件从“更新携带的数据量通常较大”的角度解释了为什么习惯上走 POST);
# 更新操作不返回结果表,只反馈成功与否(不像 SELECT 那样返回结果集):
# 课件口播里“在响应中读到 success 即视为成功”说的就是这层意思;
# 工程实现上更稳妥的做法是以 HTTP 是否报错(状态码 / 是否抛异常)来判定,
# 而不是依赖响应体里的某一段文字。本讲义的实操不依赖任何外部服务:rdflib 的内存图本身就充当了一个”进程内的图数据库”,同样的 SPARQL 语句,发给 Fuseki 是走 HTTP 端点,在 rdflib 里是直接调用 g.query() / g.update(),语法一字不差。你在本地把语句调通后,原样贴进 Fuseki 界面或 SPARQLWrapper 就能对真正的服务生效。
4. 查询:把 SPARQL 当”查询图”来读
4.1 一条边起步:查某歌手唱的所有歌
Fuseki 的 Query 标签页里,数据集选 music,在编辑器中写入 SPARQL、点右上角执行按钮即可。课程的第一个查询是”给定一位歌手,查他唱的所有歌”:
PREFIX m: <http://kg.course/music/>
SELECT DISTINCT ?trackID
WHERE {
?trackID m:track_artist m:artist_01 .
}读 SPARQL 最正确的姿势是把它当成一张查询图(query graph):
- 花括号里的每条三元组模式对应图上的一条边:
?trackID ──m:track_artist──▶ m:artist_01; m:artist_01是常量节点(课件里用黑/蓝色标出的 URI),把图的一端锚死;?trackID是变量节点,表示”这里待填空”;- 引擎在数据图上做图模式匹配(子图同态,homomorphism):凡是能把查询图的边逐条对上的子图,变量就被绑定成具体节点,一组绑定就是一条结果。注意这里是”同态”而非”同构”——SPARQL 不要求不同变量绑定到不同节点,两个变量允许取到同一个值;
SELECT后面列出的变量,就是最终要返回的节点(课件示意图里用红框框出);DISTINCT表示去重。
课程视频里,这位歌手名下返回了 8 首歌(具体数字取决于随机数据,本节代码里是另一个固定种子,数字会不同,这是正常的)。另外说明一点:真实应用里用户记得的是歌手名字而不是 id,应当先用名字查到 artist_01 这个 URI 再查歌,课件直接拿 id 查询是为了演示做的简化。
4.2 两条边、三条边:从歌曲名一路走到专辑名
两条边:歌曲的 URI 与歌曲名一起返回。 在上面的基础上再加一条边,把歌名也取出来:
PREFIX m: <http://kg.course/music/>
SELECT ?trackID ?name
WHERE {
?trackID m:track_artist m:artist_01 .
?trackID m:track_name ?name .
}同一个主体 ?trackID 引出的两条三元组模式,用点号分隔,点号默认表示逻辑与(AND)——两条边必须同时成立。画成查询图就是一个两跳的小图:?trackID 一边连向常量歌手、一边连向变量歌名。如果只 SELECT ?name,就只返回 8 个歌名;把 ?trackID 也 SELECT 出来,则得到一张”URI 与名字一一对应”的表。由于生成器里 URI 的 local name(track_00006)与名字字符串(track_name_00006)是用同一个 id 做字符串拼接(string concat)得到的,两列必然一一对应、行数也相同——这反过来可以作为校验查询正确性的手段。
三条边:给定歌曲名,查它的专辑信息。 需求变成”我只知道一首歌的名字,想知道它收在哪张专辑”:
PREFIX m: <http://kg.course/music/>
SELECT ?trackID ?albumID ?name
WHERE {
?trackID m:track_name "track_name_00001" .
?trackID m:track_album ?albumID .
?albumID m:album_name ?name .
}这里有一个重要的方向性细节:歌曲名是数据类型属性的取值(字面量),不是节点,所以无法从名字直接出发,必须先用 ?trackID m:track_name "track_name_00001" 由名字反查到歌曲节点,再沿对象属性 track_album 走到专辑节点,最后经数据类型属性 album_name 取到专辑名——三条边里一条是对象属性、两条是数据类型属性,路径是”字面量 → 歌曲节点 → 专辑节点 → 字面量”。一首歌只属于一张专辑,所以结果只有一行专辑名。
中文变量名与 CONCAT。 SPARQL 的变量名支持中文,例如把变量写成 ?歌曲id ?专辑id ?专辑名,结果表头就直接显示中文(问号不显示)。如果还想给专辑名加一段描述性前缀,可以用内置字符串函数 CONCAT 配合 AS 给返回列起别名:
PREFIX m: <http://kg.course/music/>
SELECT ?歌曲id ?专辑id (CONCAT("专辑名", ":", ?专辑名) AS ?专辑信息)
WHERE {
?歌曲id m:track_name "track_name_00001" .
?歌曲id m:track_album ?专辑id .
?专辑id m:album_name ?专辑名 .
}返回的第三列就变成 专辑名:album_name_0002 这样的拼接串。顺带一提,查询结果里的实体(URI)在 Fuseki 中是可点击的:URI 由前缀(prefix)与本地名(local name)组成,点击后界面会查询并展示与该实体相关的全部三元组,课件把这一操作俗称为对 URI 的解引用(dereference)。严格按 Linked Data 的定义,解引用指通过 HTTP GET 直接访问 URI 本身、由服务器返回该资源的 RDF 表示;Fuseki 界面里的点击展开本质是界面代发了一次查询,工程上提供的是等价的”按 URI 取回实体信息”的体验。记住 URI 是图上每个实体的全局”地址”即可。
4.3 结果塑形:LIMIT、ORDER BY 与 COUNT
LIMIT 限制条数。 查”某张专辑里的所有歌”与前面同构;若只想看任意两首,加 LIMIT 2(语义同 SQL):
PREFIX m: <http://kg.course/music/>
SELECT ?trackID
WHERE {
?albumID m:album_name "album_name_0001" .
?trackID m:track_album ?albumID .
}
LIMIT 2需要特别注意:按 SPARQL 规范,没有 ORDER BY 时结果顺序是未定义的,由具体实现决定,规范并不保证按字典序返回;课件演示中恰好观察到按字典序(lexicographical order)取前两条,那只是该引擎当时的行为,不能依赖。要让”前两条”稳定可复现,正确做法是先 ORDER BY 再 LIMIT。
ORDER BY 排序。 ORDER BY ?tag_name 默认按字典序升序;写成 ORDER BY DESC(?tag_name) 则为降序(例如标签从 08 反向排到 02)。
COUNT 聚合,返回带类型的整数。 想直接知道一张专辑有多少首歌,不必把明细拉回来自己数,用聚合函数:
PREFIX m: <http://kg.course/music/>
SELECT (COUNT(DISTINCT ?trackID) AS ?num)
WHERE {
?albumID m:album_name "album_name_0001" .
?trackID m:track_album ?albumID .
}两个细节:其一,课件写的是 COUNT(?trackID),要表达”数不同的歌曲”更严谨的写法是 COUNT(DISTINCT ?trackID)。需要澄清的是,这条查询虽然包含两条三元组模式(专辑名锁定专辑、歌曲关联专辑),但一首歌只属于一张专辑,JOIN 之后每个解里 ?trackID 只被绑定一次,所以这里 COUNT 与 COUNT(DISTINCT) 的结果恰好相同;DISTINCT 真正发挥作用是在出现一对多 JOIN 时——例如查询里再连出标签(一首歌有多个标签),同一首歌会产生多个解,这时 DISTINCT 才保证每首歌只数一次。其二,聚合结果不是普通字符串,而是一个带类型的字面量,课件结果栏里显示的是 "9"^^xsd:integer——即取值 9、数据类型为 XML Schema 定义的整型 xsd:integer(视频里那张专辑恰有 9 首歌)。
4.4 集合运算与布尔查询:UNION、FILTER、regex 与 ASK
再把查询能力补齐一组,它们都能在音乐例子上找到直接落点:
- 去重看标签:查”某歌手唱过的歌都带哪些标签”,若不去重,同一标签会被多首歌反复带出(视频里返回 12 行、标签 02 反复出现);加
DISTINCT后收敛为 6 种标签。这正说明标签在歌曲间是高度同质化、重复的。 - UNION 联合查询:统计”带有标签 01 或标签 02 的歌曲有多少首”,用两个图模式的并集。注意 SPARQL 的 UNION 是多重集并集(不会自动去重),一首歌若同时挂着两个标签,会在两个分支各出现一次;因此统计”不同歌曲数”要用
COUNT(DISTINCT ?trackID)(课件原简写为COUNT(?trackID),统计的是绑定行数,重复命中的歌会被计两次):
PREFIX m: <http://kg.course/music/>
SELECT (COUNT(DISTINCT ?trackID) AS ?num)
WHERE {
{ ?trackID m:track_tag "tag_name_01" }
UNION
{ ?trackID m:track_tag "tag_name_02" }
}- FILTER 等价写法:同样的”或”条件可以用 FILTER 对属性值过滤,
||表示逻辑或;要的是不同歌曲数时同样加 DISTINCT,结果与 UNION 一致:
PREFIX m: <http://kg.course/music/>
SELECT (COUNT(DISTINCT ?trackID) AS ?num)
WHERE {
?trackID m:track_tag ?tag_name .
FILTER (?tag_name = "tag_name_01" || ?tag_name = "tag_name_02")
}- regex 模糊匹配:问”有没有歌名包含某段字符的歌曲”,用
regex做正则匹配,角色类似 SQL 里的LIKE。 - ASK 布尔查询:如果只关心”是否存在”而不需要明细,就不该用 SELECT,而用 ASK——它不返回结果表,只返回
true或false:
PREFIX m: <http://kg.course/music/>
ASK {
?trackID m:track_name ?track_name .
FILTER regex(?track_name, "0008")
}课件对 SELECT 家族的演示覆盖了单条边、多条边、COUNT 聚合、UNION、FILTER、FILTER+regex、ORDER BY+LIMIT(嵌套子查询未演示,可自行练习)。下面这个可运行代码块把上述查询模板在 rdflib 上一次跑通。注意:课件查询里字符串常量简写为裸字符串(RDF 1.1 中裸字符串等价于 xsd:string),代码里为了与带类型的字面量在任何引擎下都能精确匹配,统一显式写成 "..."^^xsd:string。
# 用 rdflib 在进程内复刻 Fuseki 的 query 端点:一次跑通课件全部 SELECT/ASK 模板
import random
from rdflib import Graph, Namespace, Literal
from rdflib.namespace import XSD
random.seed(42)
M = Namespace("http://kg.course/music/")
P = ("PREFIX m: <http://kg.course/music/>\n"
"PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n")
# 字符串常量显式标注 xsd:string(课件中简写为裸字符串,RDF 1.1 下二者等价)
TN = '"track_name_00001"^^xsd:string' # 歌曲名常量
AN = '"album_name_0001"^^xsd:string' # 专辑名常量
G1 = '"tag_name_01"^^xsd:string'
G2 = '"tag_name_02"^^xsd:string'
def build_music_graph(n_artists=5, n_albums=8, n_tracks=30, n_tags=6):
"""与数据生成器同源:随机造一个非均匀分布的音乐图谱(初始无 artist_name)。"""
g = Graph()
g.bind("m", M)
tags = [f"tag_name_{i:02d}" for i in range(1, n_tags + 1)]
weights = [random.randint(1, 5) for _ in range(n_artists)]
for al in range(1, n_albums + 1):
g.add((M[f"album_{al:04d}"], M.album_name,
Literal(f"album_name_{al:04d}", datatype=XSD.string)))
for t in range(1, n_tracks + 1):
track = M[f"track_{t:05d}"]
g.add((track, M.track_name, Literal(f"track_name_{t:05d}", datatype=XSD.string)))
a = random.choices(range(1, n_artists + 1), weights=weights)[0]
g.add((track, M.track_artist, M[f"artist_{a:02d}"]))
al = random.randint(1, n_albums)
g.add((track, M.track_album, M[f"album_{al:04d}"]))
for tag in random.sample(tags, k=random.randint(1, 4)):
g.add((track, M.track_tag, Literal(tag, datatype=XSD.string)))
return g
def show(title, q, ask=False):
"""执行查询并紧凑打印:ask=True 时按布尔查询处理。"""
res = g.query(P + q)
if ask:
print(title, "->", bool(res.askAnswer))
return
rows = [tuple(str(x).split("/")[-1].replace('"', "") for x in r) for r in res]
print(title, f"({len(rows)} 行):", rows[:5])
g = build_music_graph()
# Q1 一条边:某歌手唱的所有歌曲(返回 URI,DISTINCT 去重)
show("Q1 artist_01 的歌曲",
"SELECT DISTINCT ?trackID WHERE { ?trackID m:track_artist m:artist_01 }")
# Q2 两条边:歌曲 URI + 歌曲名(点号 = 逻辑与)
show("Q2 artist_01 歌曲的歌名",
"SELECT ?trackID ?name WHERE { ?trackID m:track_artist m:artist_01 . "
"?trackID m:track_name ?name }")
# Q3 三条边:由歌曲名反查专辑名(字面量→歌曲→专辑→字面量)
show("Q3 track_name_00001 的专辑名",
f"SELECT ?trackID ?albumID ?name WHERE {{ ?trackID m:track_name {TN} . "
"?trackID m:track_album ?albumID . ?albumID m:album_name ?name }")
# Q4 中文变量 + CONCAT + AS 给结果列加描述前缀
show("Q4 CONCAT 专辑信息",
f"SELECT ?歌曲id ?专辑id (CONCAT(\"专辑名\",\":\",?专辑名) AS ?专辑信息) WHERE {{ "
f"?歌曲id m:track_name {TN} . ?歌曲id m:track_album ?专辑id . "
"?专辑id m:album_name ?专辑名 }")
# Q5 LIMIT:某专辑前两首歌(未 ORDER BY 时顺序由实现决定,要稳定需先排序)
show("Q5 album_0001 前两首歌",
f"SELECT ?trackID WHERE {{ ?albumID m:album_name {AN} . "
"?trackID m:track_album ?albumID } LIMIT 2")
# Q6 COUNT 聚合:返回 xsd:integer 类型的计数(规范写法加 DISTINCT)
r0 = list(g.query(P + f"SELECT (COUNT(DISTINCT ?trackID) AS ?num) WHERE {{ "
f"?albumID m:album_name {AN} . ?trackID m:track_album ?albumID }}"))[0]
print("Q6 album_0001 歌曲数 =", r0.num, "| 字面量类型 =", r0.num.datatype)
# Q7 歌名反查歌手
show("Q7 track_name_00001 的歌手",
f"SELECT ?trackID ?artistID WHERE {{ ?trackID m:track_name {TN} . "
"?trackID m:track_artist ?artistID }")
# Q8 歌曲的标签
show("Q8 track_name_00001 的标签",
f"SELECT ?tag_name WHERE {{ ?trackID m:track_name {TN} . "
"?trackID m:track_tag ?tag_name }")
# Q9 歌手全部标签:不去重(有重复) vs DISTINCT + 降序
show("Q9a artist_03 的标签(不去重)",
"SELECT ?tag_name WHERE { ?trackID m:track_artist m:artist_03 . "
"?trackID m:track_tag ?tag_name }")
show("Q9b artist_03 的标签(DISTINCT + ORDER BY DESC)",
"SELECT DISTINCT ?tag_name WHERE { ?trackID m:track_artist m:artist_03 . "
"?trackID m:track_tag ?tag_name } ORDER BY DESC(?tag_name)")
# Q10 UNION:标签 01 或 02 的“不同歌曲”计数(UNION 是多重集并集,双标签歌曲会命中两次,
# 故统计歌曲数用 COUNT(DISTINCT);课件简写 COUNT(?trackID) 统计的是绑定行数)
n1 = list(g.query(P + f"SELECT (COUNT(DISTINCT ?trackID) AS ?num) WHERE {{ "
f"{{ ?trackID m:track_tag {G1} }} UNION "
f"{{ ?trackID m:track_tag {G2} }} }}"))[0].num
# Q11 FILTER + ||:等价写法,DISTINCT 后结果与 Q10 相同
n2 = list(g.query(P + "SELECT (COUNT(DISTINCT ?trackID) AS ?num) WHERE { ?trackID m:track_tag ?tag_name . "
f"FILTER(?tag_name = {G1} || ?tag_name = {G2}) }}"))[0].num
print("Q10 UNION 不同歌曲数 =", n1, "| Q11 FILTER|| 不同歌曲数 =", n2, "(两种写法等价)")
# Q12 ASK + regex:只问是否存在,返回布尔值
show("Q12a 是否存在歌名含 00008 的歌曲",
'ASK { ?trackID m:track_name ?n . FILTER(regex(?n, "00008")) }', ask=True)
show("Q12b 是否存在歌名含 99999 的歌曲",
'ASK { ?trackID m:track_name ?n . FILTER(regex(?n, "99999")) }', ask=True)5. 更新:SPARQL 1.1 Update 与 schemaless
5.1 为什么要切到 update 端点:SPARQL 1.0 与 1.1
SPARQL 是 W3C 的标准:2008 年成为 W3C Recommendation(推荐标准),此后成为正式的规范(specification)。但版本能力有别:
- SPARQL 1.0 只支持读:只有 SELECT 等查询形式,写数据只能靠外部 API 或第 2.3 小节那种批量数据加载(data loading)导入文件;
- SPARQL 1.1 才补上了更新:增加了 INSERT、DELETE、LOAD 等更新语句(合称 SPARQL 1.1 Update)。
这就是为什么在 Fuseki 里做更新前,必须先把端点从 http://localhost:3030/music/query 切换到 http://localhost:3030/music/update——查询端点只接受读请求,写请求发给它会被拒绝。
5.2 INSERT DATA 与 DELETE WHERE:artist_name 的增与删
还记得第 1.1 小节埋下的伏笔吗?生成的数据里没有 artist_name。现在就用更新语句把歌手名动态补进去(课件对 artist_01、artist_02、artist_03 各插一条):
PREFIX m: <http://kg.course/music/>
INSERT DATA {
m:artist_01 m:artist_name "artist_name_01" .
m:artist_02 m:artist_name "artist_name_02" .
m:artist_03 m:artist_name "artist_name_03" .
}INSERT DATA 表示直接把花括号里的三元组写入图中,执行后界面返回更新成功。随后把端点切回 query,用一条普通 SELECT 反查 artist 与 artist_name,就能看到刚插入的三条记录——写入即时可见、可反查,说明更新确实落了库。
删除则用 DELETE/WHERE 的组合(课件写法,WHERE 负责定位、DELETE 负责删除,分工与 SQL 的 DELETE ... WHERE 一致):
PREFIX m: <http://kg.course/music/>
DELETE { m:artist_02 m:artist_name ?x }
WHERE { m:artist_02 m:artist_name ?x }这条语句先在 WHERE 里把 artist_02 的 artist_name 三元组找出来绑定到 ?x,再由 DELETE 把它删掉。删完再做同样的查询,就只剩 artist_01 与 artist_03,artist_02 的名字已经消失。
这一增一删恰好点题了知识图谱的 schema-less(无固定模式 / schema-later)特性:给歌手”加一列”歌手名,在关系数据库里要先 ALTER TABLE 改表结构、再回填数据;而在图数据库里,新增一个属性不过就是多写一批三元组,不需要事先在任何表里声明列,schema 随数据自然演化。这正是第 1.1 小节把 artist_name 画成虚线、生成器刻意留白的用意。
5.3 用 Python 完成增删,并从”图遍历”视角再看一眼
下面的代码块用 rdflib 执行等价的 INSERT/DELETE(g.update() 对应 update 端点),再用 networkx 把对象属性边构成一张有向图,从图论视角演示”查询就是沿边走”:注意 track_artist 的方向是歌曲指向歌手,所以”找某歌手唱的歌”要沿入边回溯,而”歌曲属于哪张专辑”沿出边前进,两跳连起来就是”歌手 ← 歌曲 → 专辑”。
# 用 rdflib 复刻 update 端点(INSERT/DELETE),再用 networkx 演示图遍历视角
import random
import networkx as nx
from rdflib import Graph, Namespace, Literal, URIRef
from rdflib.namespace import XSD
from rdflib.plugins.sparql import prepareUpdate
random.seed(42)
M = Namespace("http://kg.course/music/")
P = ("PREFIX m: <http://kg.course/music/>\n"
"PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n")
def build_music_graph(n_artists=5, n_albums=8, n_tracks=30, n_tags=6):
"""与前两个代码块同源的音乐图谱生成函数(自包含,可独立运行)。"""
g = Graph()
g.bind("m", M)
tags = [f"tag_name_{i:02d}" for i in range(1, n_tags + 1)]
weights = [random.randint(1, 5) for _ in range(n_artists)]
for al in range(1, n_albums + 1):
g.add((M[f"album_{al:04d}"], M.album_name,
Literal(f"album_name_{al:04d}", datatype=XSD.string)))
for t in range(1, n_tracks + 1):
track = M[f"track_{t:05d}"]
g.add((track, M.track_name, Literal(f"track_name_{t:05d}", datatype=XSD.string)))
a = random.choices(range(1, n_artists + 1), weights=weights)[0]
g.add((track, M.track_artist, M[f"artist_{a:02d}"]))
al = random.randint(1, n_albums)
g.add((track, M.track_album, M[f"album_{al:04d}"]))
for tag in random.sample(tags, k=random.randint(1, 4)):
g.add((track, M.track_tag, Literal(tag, datatype=XSD.string)))
return g
g = build_music_graph()
# ---- SPARQL 1.1 Update:INSERT DATA 补写 artist_name ----
g.update(P + "INSERT DATA { "
'm:artist_01 m:artist_name "artist_name_01"^^xsd:string . '
'm:artist_02 m:artist_name "artist_name_02"^^xsd:string . '
'm:artist_03 m:artist_name "artist_name_03"^^xsd:string . }')
names = sorted((str(a).split("/")[-1], str(n))
for a, _, n in g.triples((None, M.artist_name, None)))
print("INSERT 后 artist_name:", names)
# ---- DELETE WHERE:删掉 artist_02 的歌手名(WHERE 定位、DELETE 执行)----
g.update(prepareUpdate(P + "DELETE WHERE { m:artist_02 m:artist_name ?x }"))
names = sorted((str(a).split("/")[-1], str(n))
for a, _, n in g.triples((None, M.artist_name, None)))
print("DELETE 后 artist_name:", names)
# ---- networkx 图遍历:只保留“节点→节点”的对象属性边 ----
DG = nx.DiGraph()
for s, p, o in g:
if isinstance(o, URIRef): # 客体是 URI 才是对象属性边(字面量边不入图)
DG.add_edge(str(s).split("/")[-1], str(o).split("/")[-1],
rel=str(p).split("/")[-1])
# track_artist 方向为 歌曲→歌手:歌手的歌在其“入边”上
tracks = sorted(u for u, v in DG.in_edges("artist_03"))
# 再沿每首歌的 track_album“出边”走到专辑,即两跳:歌手 ← 歌曲 → 专辑
albums = sorted({w for t in tracks for _, w, d in DG.out_edges(t, data=True)
if d["rel"] == "track_album"})
print("artist_03 唱过的歌:", tracks)
print("两跳可达的专辑:", albums)
# 忽略边方向求一条连通路径,直观看到“关系是图上走出来的”
UG = DG.to_undirected()
if nx.has_path(UG, "artist_03", "album_0001"):
print("artist_03 到 album_0001 的一条无向路径:",
nx.shortest_path(UG, "artist_03", "album_0001"))对比着看会很有收获:SPARQL 里写 ?trackID m:track_artist m:artist_03 . ?trackID m:track_album ?albumID,引擎在底层做的正是 networkx 演示的”先沿入边找到歌曲、再沿出边走到专辑”的遍历;关系数据库要用一连串 self-JOIN 才能拼出同样的多跳关系,而图数据库把”关系”存成了实实在在的边,多跳查询就是边上游走。这也是下一节存储内核要进一步解释的问题。
📝 动手练一练
练习 1(SPARQL 三跳查询):写出完整 SPARQL,根据专辑名 "album_name_0003" 查出该专辑下所有歌曲的歌曲名。提示:先分清三条边的边方向(数据类型属性都是节点→字面量:专辑节点→专辑名、歌曲节点→歌曲名;对象属性是歌曲节点→专辑节点),再按”思考(遍历)顺序”把它们串起来:先用专辑名反向定位专辑节点,再沿 track_album 反向找到歌曲节点,最后沿 track_name 正向取歌名。
👉 点击查看参考答案
PREFIX m: <http://kg.course/music/>
SELECT ?trackID ?歌曲名
WHERE {
?albumID m:album_name "album_name_0003" .
?trackID m:track_album ?albumID .
?trackID m:track_name ?歌曲名 .
}边方向上,album_name/track_name 都是节点指向字面量,track_album 是歌曲节点指向专辑节点;而查询的思考顺序与之部分相反:第一条模式用专辑名(字面量)反向锁定专辑节点,第二条模式沿 track_album 反向(专辑作客体、歌曲作主体)找到歌曲,第三条模式再沿 track_name 正向取出歌名。
练习 2(COUNT 聚合):统计歌手 m:artist_02 一共唱过多少首不同的歌曲,要求结果列名为 ?num,并说明为什么计数时应加 DISTINCT。
👉 点击查看参考答案
PREFIX m: <http://kg.course/music/>
SELECT (COUNT(DISTINCT ?trackID) AS ?num)
WHERE {
?trackID m:track_artist m:artist_02 .
}本题只有一条三元组模式,而 RDF 图是三元组的集合,COUNT(?trackID) 本就不会重复;这里仍写 DISTINCT,是把”数不同歌曲”的意图写明,并防御将来给查询追加模式(如再连标签、专辑)后,一对多 JOIN 让同一 trackID 产生多个解。返回值是 "N"^^xsd:integer 形式的整型字面量。
练习 3(ASK + FILTER + regex):分别写出两条 ASK 查询:①判断图中是否存在标签为 tag_name_99 的歌曲;②判断是否存在歌名中包含字符串 0003 的歌曲。思考这两种”存在性判断”为什么适合用 ASK 而不是 SELECT。
👉 点击查看参考答案
PREFIX m: <http://kg.course/music/>
# ① 标签是否等于 tag_name_99
ASK { ?trackID m:track_tag "tag_name_99"^^<http://www.w3.org/2001/XMLSchema#string> }PREFIX m: <http://kg.course/music/>
# ② 歌名是否包含 0003(regex 相当于 SQL 的 LIKE)
ASK { ?trackID m:track_name ?n . FILTER regex(?n, "0003") }只关心”有/没有”时,ASK 只回 true/false,不必把全部匹配行拉回客户端,网络与解析开销更小,语义也更直接。
练习 4(概念辨析):生成器为什么刻意不生成 artist_name?后续用 INSERT DATA 补属性的过程,体现了知识图谱相对于关系数据库的什么特性?请结合”虚线边”与 ALTER TABLE 作对比说明。
👉 点击查看参考答案
生成器刻意留白,是为了演示图谱可以在构建与查询过程中动态增加属性:新增 artist_name 只需 INSERT 一批三元组,无需修改任何预先定义的表结构。而在关系数据库中,给”歌手表”增加一个”歌手名”列需要先执行 ALTER TABLE 变更模式、再为存量行回填数据。这体现了知识图谱 schema-less(模式随数据演化、事后补充)的灵活性,也是课件把该属性画成虚线的原因。
本章小结
本节用一个音乐知识图谱把”知识存储”的全流程串了起来,要点如下:
- schema 先行:三类节点(artist/track/album)、数据类型属性(指向 xsd:string 字面量)与对象属性(指向节点)、统一命名空间
http://kg.course/music/,是后续所有数据与查询的地基。 - 人造数据服务于测试:
kg_music_triples.py按 schema 用随机数批量产出 N-Triples 文件,规模可用命令行参数控制(默认 1000,可至十万),刻意制造非均匀分布以形成稠密图、暴露真实性能;.nt文件一行一个三元组,主体/谓词是 URI,客体是 URI 或带类型字面量。 - 图数据库以图为模型:节点、命名有向边、节点与边上的键值属性是四要素,边有明确的 start/end 方向;这套表述与属性图更贴近;属性图与 RDF 可互相编码转换,但语义并不严格等价(如边属性在 RDF 中需具体化/RDF-star 表达),系统对比下一页展开。
- Jena 是功能完整的开源实现(Apache 开源 Java 框架,非 W3C 官方参照实现):自下而上分为导入解析 RIOT(内置 RDF/XML、Turtle、N-Triples 等;RDFa 需额外引入解析依赖注册、非默认开箱)、存储(内存;持久化的 SDB 把三元组物化进关系表并做 SPARQL→SQL 翻译,但 SDB 已被 Jena 退役、止于 3.17.0;TDB 为原生三元组库、性能更高,是官方推荐)、推理(内置规则、Rete 前向链式、LP 后向链式规则推理、外接 HermiT 等;SPARQL→SQL 属于 SDB 而非推理层)、表示 API(RDF/RDFS·OWL/SPARQL)与 Fuseki 服务(HTTP/RESTful 端点)。
- 导入与索引:bulk loading 以一批数据一次提交、省掉逐条事务原子性检查来换取高导入吞吐;磁盘上的索引按 SPO/POS/OSP 等多种排列组织(三元组 6 排列、带图名 G 的四元组理论 24 排列——这是讲师对着导入后的 TDB 文件树在本节建立的直觉;实际 TDB 只物化 6 种四元组索引子集 SPOG/POSG/OSPG/GSPO/GPOS/GOSP,六排列的字典编码、B+ 树与 JOIN 细节在下一节存储内核展开),让不同查询模式都能走索引。
- 服务与端点:Docker 一条命令拉起 Fuseki(3030 端口、admin/test@jena),界面建持久化数据集 music 并上传多格式 RDF;服务暴露
/query与/update两个 RESTful 端点,程序侧可用 SPARQLWrapper 访问(查询走 query 端点,GET 或 POST 均可、长查询建议 POST;按 SPARQL 1.1 Protocol,任何更新都必须发往 update 端点且使用 POST,与数据量无关)。 - SPARQL 即查询图:三元组模式是边、点号是逻辑与、常量锚定、变量填空、SELECT 决定返回节点;配合 DISTINCT、ORDER BY(ASC/DESC)、LIMIT、COUNT(返回 xsd:integer)、UNION、FILTER(
||)、regex、ASK 可覆盖绝大多数检索;变量名支持中文,CONCAT + AS 可塑形结果列。URI 在 SPARQL 中只是锚定实体的全局不透明标识,“HTTP 解引用 URI”属于 Linked Data 的发布实践,并非 SPARQL 查询语义本身。 - SPARQL 1.1 让图可演化:1.0 只读,写靠批量加载/API;1.1 增加 INSERT DATA 与 DELETE WHERE(WHERE 定位、DELETE 删除),更新走独立的 update 端点;运行中动态增删三元组即可增删属性,这就是 schema-less。
📋 行动清单
- 运行第 1.2 小节的生成器代码,把
n_tracks调到 1000,序列化成.nt并用文本编辑器打开,逐行指出主体、谓词、客体(URI 还是字面量)。 - 修改生成器的权重与标签数量区间,观察”各歌手歌曲数”分布如何变化,理解为什么非均匀分布更适合压测。
- 在第 4.4 小节末尾的代码块上把全部查询模板跑通,然后自己改写一条:查专辑名含
0002的专辑里所有歌曲名。 - 用第 5.3 小节代码完成一次 INSERT + 反查 + DELETE + 再查,把每一步 artist_name 的条数打印出来,验证”写入即时可见、删除即时生效”。
- 用 networkx 画出对象属性构成的有向图,手动指出一条”歌手 ← 歌曲 → 专辑”的两跳路径,对照等价的 SPARQL 图模式。
- (选做)安装 Docker,拉取
stain/jena-fuseki镜像,按 3.1 的步骤在浏览器里建库、上传.nt、切换 query/update 端点复现课件操作。 - 带着”为什么需要六种/二十四种排列索引、SPARQL 的多跳在底层如何执行”的问题,预习本章下一节的存储内核内容。
—— 小象教研组
领取《小象 11GB VIP 课件资料包与大厂真题手册》
包含全套实战 Jupyter 源码、清洗后数据集、大厂高频面试真题与专属学员答疑交流群。
- ✔完整 Python / 数据分析 Jupyter 实战源码
- ✔大厂真实业务数据集与练习题
- ✔微信扫码添加顾问免费领取;想学什么,直接告诉顾问
微信扫码添加顾问