📘 第2章 规则系统及其变体
📄 一句话概括

上一节的规则库只存了「怎么推」,没存「知道什么」。知识库要解决的是后半个问题: 事实用什么形式存、怎么查、查得动吗。 这一节把四种存法拉出来比一遍,再拿三元组真建一个小库, 量三个数:一跳查询扫多少条、多跳查询快多少倍、把派生关系物化会膨胀几倍。

📝 本节和前面几节的关系

上一节的规则库 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 秒实验

  1. 把 build_index(TRIPLES, 0) 换成 build_index(TRIPLES, 2)(按父亲建索引), 再写一个「查某人有哪些孩子」的查询。 两种索引服务两个方向的查询,这就是图数据库要同时维护正排和倒排的原因。
  2. 把 ancestors 改成返回「路径」而不是「节点」,重跑第三段。 n11 到 n01 的路径只有一条,但在有多条父系关系的图里会有多条, 那时 BFS 要先决定「最短的还是全部的」。
  3. 给这棵树加一条 (n11, 父亲, n09),让它有两个人当父亲。 再跑一次祖先查询,看 n11 的祖先数从 4 变成多少。你会看到「同一节点被访问两次」的问题—— 真实图数据库要在这加去重。

常见误区

  1. 以为三元组是「简化版的关系数据库」。关系数据库先定表结构再填数据, 三元组反过来——先有数据,结构随查询长出来。这是它能做知识图谱的原因, 也是它没法做事务和约束的原因。
  2. 以为有了索引就不需要关心复杂度。索引把「扫全表」换成「扫一个桶」, 但多跳查询的次数没变—— 跳十层就是十次索引查找,跟表多大无关,跟链多长有关。这是两种不同的成本,别混。
  3. 以为物化一定划算。祖先膨胀 3.4 倍还是小事, 真正的问题是「一改父子关系,所有派生的祖先条目都要跟着改」——一个写操作触发几十个更新, 事务一长就容易不一致。
  4. 以为知识库的价值在「存」。存是最便宜的一环。一百万人一百兆,随便一台机器都装得下。 贵的永远是「这条知识从哪来、凭什么信它」。
自测三问(点开对答案) 1. 三元组和关系数据库的表,最根本的差别是什么? —— 表要求先定模式(有哪些列、什么类型),然后往里填行; **三元组没有模式,加一种新关系就是加一个谓语字符串,不需要改任何结构。**代价是没有类型检查、 没有外键约束、也没有事务——**灵活性是从「不管」那里换来的。** 2. 索引为什么能让多跳查询快 19 倍?它改变了算法吗? —— 没有改变算法,还是广度优先。**它改变的是「每一步要读多少东西」:无索引时每走一步要扫全表,** **有索引时只读这个节点的那几条出边。**所以倍数约等于「表的大小」,表越大越值钱。 复杂度量级还是 O(V+E),变的是常数。 3. 「存还是推」这道题,工程上按什么标准判? —— 按「读多少次、写多少次」。 **读远多于写就物化**(比如关注数,每次展示都要读,一加一减时顺手改); **写远多于读就现算**(比如「你可能认识的人」,每次算一次就够)。 两边都多的时候折中:物化一层、其余现算。

规则有了、知识也存好了,接下来把它们拼成一个能跟专家对话的系统。 下一节讲专家系统,以及它怎么处理「不确定」。

相关笔记