上一节的规则库只存了「怎么推」,没存「知道什么」。知识库要解决的是后半个问题: 事实用什么形式存、怎么查、查得动吗。 这一节把四种存法拉出来比一遍,再拿三元组真建一个小库, 量三个数:一跳查询扫多少条、多跳查询快多少倍、把派生关系物化会膨胀几倍。
上一节的规则库 15 条、事实 7 条,都在内存里。 这一节把它放大:如果事实有一百万条,还能这么查吗? 本节的三元组就是今天知识图谱的骨架,第 12 章会接着讲数据库和检索。
一、同样是「知道」,有四种存法
知识表示这件事,教材列了四种主要形式。 它们的区别不在「能不能表达」,在「表达出来之后好不好用」。
| 形式 | 长什么样 | 擅长 | 不擅长 |
|---|---|---|---|
| 逻辑 | 谓词、量词、蕴含式 | 严格、可证明 | 写起来啰嗦,推理慢 |
| 产生式 | IF-THEN 规则 | 贴近人的判断习惯 | 结构化知识表达不了 |
| 语义网络 | 节点加带标签的边 | 关系一目了然 | 没有形式语义,容易歧义 |
| 框架 | 对象加一组属性槽 | 适合「一类东西有固定字段」 | 槽之间的约束不好写 |
| 这四种不是四个时代,是四种口味。今天做知识图谱用的是语义网络那一支(三元组), | |||
| 做业务规则用的是产生式那一支,做本体建模用的是逻辑那一支。它们同时活着。 | |||
| 教材把语义网络和框架归到「结构化表示」这一类, | |||
| 它们的共同点是:知识不是一条条孤立的句子,而是一张网,查一个东西会顺带把邻居带出来。 |
二、三元组:最偷懒也最通用的那种
三元组是语义网络的最小单位:主语、谓语、宾语。「n11 的父亲是 n07」写成 (n11, 父亲, n07),
「北京是中国的首都」写成 (北京, 是首都于, 中国)。
它偷懒到什么程度?任何一种关系都能塞进去,不需要预先定义表结构,
加一种新关系就是多一个谓语字符串。这一点在工程上极其重要——你永远不知道下一个需求要存什么关系。
代价是没有约束。
你可以写 (n11, 父亲, n07) 和 (n11, 父亲, n08),系统不会拦你。
约束得靠外面的规则或者人工保证。
这套东西有两个标准名字:RDF(资源描述框架)规定了三元组的序列化格式,SPARQL 规定了怎么查。
今天说的「知识图谱」,底层就是一堆这样的三元组加一个图数据库。
三、查询就是图上走
有了三元组,查询就变成图上的路径问题。一跳是找边,多跳是走路径。 Demo 里 20 个人、19 条父子边。查「n11 的父亲」——无索引要扫全部 19 条,命中 1 条。 这就是 O(n):扫多少条跟你查谁没关系。 索引把这一步变成 O(1) 到 O(小常数)。做法很土:按主语建一张倒排表, 值是「包含这个主语的条目列表」。查 n11 就直接拿那张小表。 多跳的差距更明显。 n11 上面有四个人,无索引要扫 4 遍全表共 76 条;索引后只扫 4 条,差 19 倍。人越多、代数越深, 这个倍数越大。 这里有个容易忽略的点:索引换的不是算法,是「不用读跟自己无关的行」。BFS 还是 BFS, 复杂度还是 O(V+E),但常数从「全表」变成了「一个节点的出边」。
四、存还是推:一道没有通解的选择题
「兄弟」这个关系库里没写,但它能算:同父的其他人。Demo 里推出来 4 对——n02/n03、n04/n05、 n07/n08、n11/n12。如果直接存,要存 8 条有向三元组。 「祖先」更夸张:父子只有 19 条,可全部祖先加起来 65 条,膨胀 3.4 倍。而且一改父子关系, 所有派生的祖先条目都要跟着改。 这就是知识库设计里最老的一道选择题:物化(把派生关系也存下来)查得快, 代价是体积膨胀加一致性维护;现算省空间,代价是每次查询都要走一遍图。 工程上的答案通常是「热的那部分物化,冷的现算」。 比如社交产品里「关注数」会物化成一个计数字段(每加一次关注就 +1),而「你可能认识的人」每次现算。 判断标准是「读多少次、写多少次」——读远多于写就物化,写远多于读就现算。
五、放到真实规模
按 Demo 那棵树的实测比例(平均每人 3.25 个祖先)外推:一百万人对应九十五万条父子、 三百万出头条祖先。按每条 40 字节算,一百来兆——今天的硬盘随便装。 所以知识库的瓶颈从来不在存储。 在两个地方:一是「谁来录」,二是「录进来的对不对」。这两件事规则系统也躲不开, 它只是换了个名字,叫「知识获取」。 真实的知识图谱有多大?Wikidata 是百万级实体、亿级语句的量级; Google 2012 年发布的 Knowledge Graph 起步是 5 亿个对象、35 亿条关系。 这些数字听起来大,但按「每人每天新增几条」算,还是人在往里填。 所以这一节的真正结论是:知识表示的形式问题基本解决了,知识从哪来的问题一直没解决。 第 7 章讲机器学习,本质上就是在回答第二个问题——让机器自己从数据里长知识,别等人写。
六、动手 Demo:十九个三元组、二十个人:一跳、多跳、索引、存还是推
数据是一棵五代的家族树,20 个人、19 条父子关系。每条关系存成一个三元组 (孩子, 父亲, 大人)。
代码里 build_index 是全部「性能优化」的内容——二十行不到,后面所有查询快慢的差别都来自它。
TRIPLES = [(c, u'父亲', f) for c, f in FATHER] # 19 条父子关系
def build_index(kb, key=0):
"""按某一列建倒排索引:值 -> 包含它的那些三元组。"""
idx = {}
for t in kb:
idx.setdefault(t[key], []).append(t)
return idx
def ancestors(x, idx):
"""沿「父亲」往上走,一层一层扩散。"""
out, frontier = [], [x]
while frontier:
nxt = []
for y in frontier:
for s, p, o in idx.get(y, []):
if p == u'父亲':
out.append(o)
nxt.append(o)
frontier = nxt
return out
实跑输出:
--------------------------------------------------------------------
一堆三元组能干什么
--------------------------------------------------------------------
==============================================================
[1] 知识库最朴素的形态,就是一堆三元组
主语 谓语 宾语
n02 父亲 n01
n03 父亲 n01
n04 父亲 n02
n05 父亲 n02
n06 父亲 n03
n07 父亲 n04
…… 一共 19 条,20 个人,5 代。
每个三元组是「主语—谓语—宾语」三个位置,它本身没有任何结构,
就是一张三列表。它的全部力量来自「同一件事能拆成很多小句子」。
[2] 一跳:查一个人的父亲
无索引,扫 19 条,命中 1 条:n07
这一条对 19 条的表毫无压力。换成一百万条就不一样了——
扫一遍要读完整张表,这是 O(n),跟你查谁没关系。
[3] 多跳:往上找祖先
n11 往上走:n07 → n04 → n02 → n01
按主语建索引之后,一共只扫了 4 条条目。
不建索引也能算,但每往上走一人就得扫一遍全表 19 条:
n11 上面有 4 个人,就是 76 条;索引后只扫 4 条,差 19.0 倍。
索引换的不是算法,是「不用读跟自己无关的那些行」。
[4] 一条关系,存还是推
「兄弟」这个关系库里没写。但它完全能算出来:同父的其他人。
推出来的兄弟对:4 对 —— n02/n03、n04/n05、n07/n08、n11/n12
如果直接存,要存 8 条有向三元组。
「祖先」也一样:父子只有 19 条,可全部祖先加起来是 65 条。
物化(把派生关系也存下来)查得快,代价是体积膨胀 3.4 倍;
而且一改父子关系,所有派生的祖先条目都要跟着改。
这是知识库设计里最老的一道选择题:存冗余换查询速度,
还是省空间换一致性。这道题至今没有通解,只有取舍。
[5] 把它放大到真实规模
这棵树里平均每个人有 3.25 个祖先,下面按这个比例外推:
场景 父子条数 全部祖先条数
20 人 19 61 条
1000 人 950 3087 条
100 万人 约 95 万 约 308 万条
一百万人的父子关系九十五万条,全部祖先三百万出头。
按每条 40 字节算,一百来兆——今天的硬盘随便装。
所以知识库的瓶颈不在存储,在两个地方:
一是「谁来录」,二是「录进来的对不对」。
这两件事规则系统也躲不开,它只是换了个名字,叫「知识获取」。
第一段只是把三元组打出来。注意它没有任何结构——就是一张三列表。 它的力量来自「同一件事能拆成很多小句子」,而不是来自某种巧妙的数据结构。 第二段是一跳查询。无索引扫 19 条命中 1 条。 19 条的时候这毫无意义,一百万条的时候这就是「每次查询读一遍全表」。 第三段是多跳。n11 往上走:n07 → n04 → n02 → n01,四步到顶。索引后一共只扫了 4 条条目。 「差 19 倍」这个数要这么读:无索引要扫 4 遍全表(每往上走一人扫一遍),76 条;索引后扫 4 条。 倍数正好等于「表有多大」,所以表越大,索引越值钱。 第四段是这一节的中心。「兄弟」推出来 4 对,存起来要 8 条有向三元组。 「祖先」更狠:父子 19 条,全部祖先 65 条,膨胀 3.4 倍。 注意 65 这个数是算出来的,不是估的。每个人往上数到根,加起来正好 65。 而且这个数随树的深度长得比人数快——树越深,每个人的祖先越多。 第五段外推到一百万人。父子 95 万条、祖先 308 万条,一百来兆。 存储完全不是问题,这跟规则系统那边(一万个物种 732 KB)是同一个结论。 真正的瓶颈是「谁来录、录得对不对」。这个结论在第 2 章出现两次了, 第 12 章讲数据库和检索时还会再出现一次——它是整本书里最顽固的那堵墙。
30 秒实验
- 把
build_index(TRIPLES, 0)换成build_index(TRIPLES, 2)(按父亲建索引), 再写一个「查某人有哪些孩子」的查询。 两种索引服务两个方向的查询,这就是图数据库要同时维护正排和倒排的原因。 - 把
ancestors改成返回「路径」而不是「节点」,重跑第三段。 n11 到 n01 的路径只有一条,但在有多条父系关系的图里会有多条, 那时 BFS 要先决定「最短的还是全部的」。 - 给这棵树加一条
(n11, 父亲, n09),让它有两个人当父亲。 再跑一次祖先查询,看 n11 的祖先数从 4 变成多少。你会看到「同一节点被访问两次」的问题—— 真实图数据库要在这加去重。
常见误区
- 以为三元组是「简化版的关系数据库」。关系数据库先定表结构再填数据, 三元组反过来——先有数据,结构随查询长出来。这是它能做知识图谱的原因, 也是它没法做事务和约束的原因。
- 以为有了索引就不需要关心复杂度。索引把「扫全表」换成「扫一个桶」, 但多跳查询的次数没变—— 跳十层就是十次索引查找,跟表多大无关,跟链多长有关。这是两种不同的成本,别混。
- 以为物化一定划算。祖先膨胀 3.4 倍还是小事, 真正的问题是「一改父子关系,所有派生的祖先条目都要跟着改」——一个写操作触发几十个更新, 事务一长就容易不一致。
- 以为知识库的价值在「存」。存是最便宜的一环。一百万人一百兆,随便一台机器都装得下。 贵的永远是「这条知识从哪来、凭什么信它」。
自测三问(点开对答案)
1. 三元组和关系数据库的表,最根本的差别是什么? —— 表要求先定模式(有哪些列、什么类型),然后往里填行; **三元组没有模式,加一种新关系就是加一个谓语字符串,不需要改任何结构。**代价是没有类型检查、 没有外键约束、也没有事务——**灵活性是从「不管」那里换来的。** 2. 索引为什么能让多跳查询快 19 倍?它改变了算法吗? —— 没有改变算法,还是广度优先。**它改变的是「每一步要读多少东西」:无索引时每走一步要扫全表,** **有索引时只读这个节点的那几条出边。**所以倍数约等于「表的大小」,表越大越值钱。 复杂度量级还是 O(V+E),变的是常数。 3. 「存还是推」这道题,工程上按什么标准判? —— 按「读多少次、写多少次」。 **读远多于写就物化**(比如关注数,每次展示都要读,一加一减时顺手改); **写远多于读就现算**(比如「你可能认识的人」,每次算一次就够)。 两边都多的时候折中:物化一层、其余现算。规则有了、知识也存好了,接下来把它们拼成一个能跟专家对话的系统。 下一节讲专家系统,以及它怎么处理「不确定」。
- ☐ (本节完,下一节 2.3 专家系统)