将博客打造成个人知识图谱

2024-10-06 ⏳8.9分钟(3.6千字) 🕸️

细心的读者可能会发现,在我的博客主页右上方和文章标题右下方多出来一个🕸️图标。点击就会打开我的个人知识图谱(Knowledge Graph)。这是我在国庆期间鼓捣出来的。本文就跟大家分享知识图谱功能的来龙去脉。

跟很市面上常见的静态博客不同,我的博客是基于 make + bash + pandoc 自行搭建的,具体技术栈可以参考我专门写的文章1。因为是自己手工打造,功能没有那么完善。缺点之一便是不支持标签功能。标签功能的核心是反向索引。我们在写文章的时候指定的是文章到标签的所属关系。但要想支持标签功能,也就是根据标签列出相关的文章列表,则需要生成从标签到文章的关联关系,这需要遍历所有文章。因为实现逻辑比较复杂,也就一直没有去折腾。但期间也想了一个折中的办法,使用文件夹来模拟标签。比如我把所有 Git 相关的文章都放到/git目录,再给每个目录生成一个列表文章,这样也能实现类似标签的效果。不过此方案的缺陷也非常明显,一篇文章只能属于一个目录,没法实现多标签效果。有时候一篇文章不知道该放到哪个目录。比如我写过为 Git 配置代理的文章2,你说是该放到/net目录还是/git目录呢?很纠结。所以这个问题肯定要解决。

标签说到底是一种组织文章内容的手段。而写文章则是梳理知识总结经验的办法。我在之前的文章3中说写作本身就是目的,写是为了更好理解。而标签做为一种组织形式,应该成为我们理解和组织个人知识和经验的工具。那标签有没有缺点呢?肯定有,而且很明显。比如说,一篇文章设置多少标签合适呢?讲道理不标签不应该太多,不然文章的内容就会太松散。另外一个问题,如何确定要使用哪些标签呢?这个就很微妙了。如果标签过于宽泛,比如说「博文」,那么所有文章都应该打上这个标签,都有标签跟都没有是一个意思。如果标签太细分,就只有很少的内容,甚至只有一两篇文章属于该标签。这样一来就很难通过标签找到相关的内容,从而也就失去了设置标签的意义。所以说标签太多或者太少都有问题,怎么打标签同样也很难。那有没有更好的工具可以利用呢?

我最近受 Obsidian 启发,感觉它内置的知识图谱功能就非常适合取代标签来组织内容。知识图谱说白了就是将文章之间的引用关系用图的形式展示出来。理论上文章内容越相近,它们之间的引用也会越多,对应的网状图也会越密集。通过知识图谱,我们可以反复梳理已经发布的内容,查看这些文章之间的关系,再根据对应的知识体系调整或者新发布内容,从而不断完善自己的知识体系。跟标签相比,知识图谱还有一个显著的优点,那就是自然。文章根据内容相互引用,这是一个自然的过程,不需要绞尽脑汁想要打什么样的标签。再结合我之前介绍的实现 Markdown 相对引用4,创作过程非常丝滑~于是便萌发了实现知识图谱的想法💡

正好国庆假期闲来无事,我也不想去那些人山人海的旅游景点,就安心在家开发知识图谱功能。我之前说过,如果我的博客只有一位读者,那一定是我自己。有了知识图谱功能,个人博客也就升级成了第二大脑🧠有事没事都得梳理或者完善自己的知识体系,也不失为一件乐事😂

现在言归正传,开始介绍实现原理🔬

知识图谱的核心是文章引用关系。这可以通过 pandoc 的 lua-filter 实现。lua-filter 的基础知识请参考这篇文章

local refs = {}
return {{
  Meta = function (meta)
    --- ...
    if next(refs) ~= nil then meta.refs = refs end
    return meta
  end,
  Link = function (link)
    local t = link.target
    if t:sub(1,4) ~= "http" and t:sub(-3) == ".md" then
      local p = PANDOC_STATE.input_files[1]
      t = realpath(p, t)
      link.target = t:sub(1,-3).."html"
      table.insert(refs, link.target)
    end
  end,
}}

这里的return返回一个 filter 列表,里面只有一个 filter,而且定义了 Meta 和 Link 两个回调函数。pandoc 在解析 Markdown 的时候,只要碰到超链接,就会调用 Link 指定的回调函数。在该函数中,我们只处理站内的 md 文件引用,并将其转换成对应的 html 链接,而且把链接路径都追加到refs数组中。最后 pandoc 会处理元信息,也就是调用 Meta 指定的回调函数。如果refs变量不为空,我们就把它保存到全局的 meta 对象中。这样就能在 pandoc 的模板文件中引用refs变量的内容了。

我的博客系统使用 Makefile 实现增量构建。每个 Markdown 都会生成对应的 yml 文件来保存标题、时间、摘要等源信息。该 yml 文件本身也是通过 pandoc 模板来生成的,具体结构如下:

- { "date": "$date$", "path": "$path$", "title": "$title$", "desc": "$desc$" }

里面诸如$date$都是从 pandoc 的 meta 对象中读取的变量值。我们要做的是将前面的 refs列表内容插入到生成的 yml 文件中。所以需要用到以下模板片断:

"refs": [$for(refs)$"$refs$"$sep$, $endfor$]

因为是数组,所以整体结构是"refs": []。方括号里面的内容需要使用 pandoc 模板语法来生成,也就是这一段:

$for(refs)$"$refs$"$sep$, $endfor$

说实话,pandoc 模板有点晦涩,不直观。上面的代码又可以分解成$for(refs)$🟩$endfor$,这是一个 for 循环,意为遍历refs变量。🟩部分表示循环体,我们可以在其中引用循环变量的当前取值。假如我们写成$for(refs)$"$refs$",$endfor$,而且变量refs的值为 [1,2,3],那么对应生成的结果是["1","2","3",]。注意在循环中引用当前值的变量名跟被遍历的变量名是同一个,确实有点别扭。再看生成的结果,最后一个元素后面还有一个逗号,JSON 不允许这种写法,后续在浏览器里解析会报错。所以我们得生成类似["1","2","3"] 这种写法。为此 pandoc 还提供一种特定的语法,就是在循环体后面通过$sep$来指定分割符。比如上例中我用$seq$,来指定分割符为,,注意,是逗号+空格。$sep$$endfor$之间的所有内容都是分割符。所以最终生成的内容为["1", "2", "3"]。无论如何,pandoc 语法让人很难受☹️我看了好几遍文档才弄明白。

所有的 yml 文件数据最终都会被整合到 ./index.yml 文件,成为博客的总数据库,最早该文件是用来生成 Atom XML 文件的。现在整合了 refs 信息,可以用来生成知识图谱了。

以上是服务端的工作内容,说白了就是生成引用数据。接下来我们研究如何在浏览器端绘制知识图谱。

在开始画图之前,我们得先确定数据传输和解析方案。上面已经说过,所有引用信息都保存到了 index.yml 文件。但浏览器默认不支持解析 yml 格式,该如何是好呢?一种方案是引入第三方解析库。但我认为太重了。我观察 index.yml 的结构如下:

title: no_warn
site_title: 涛叔
articles:
- { "date": "2024-10-05", "path": "/dmit-2024-10-05.html", "title": "DMIT VPS 补货", "desc": "DMIT                            终于补货了,有需要的朋友快点下手。", "tags": [], "refs": ["/dmit-25.html"] }
- { ... }

有没有发现什么特点?对了,所有的源信息都以-开头,而且后面的内容是标准的 JSON 对象。所以我们可以先下载 yml 文件,找到每一个 JSON 对象,再使用浏览器内置的函数来解析。整个过程如下:

let r = await fetch('/index.yml');
let t = await r.text();
let ts = t.split("\n");

for (let i = 0; i < ts.length; i++) {
    let l = ts[i];
    if (l.startsWith('- ')) {
        let o = JSON.parse(l.slice(2));
        console.log(o);
    }
}

这样就轻松完成解析过程,而且不需要依赖三方包。非常 nice 🎉

有了数据,我们就可以画图了。现在有很多开源库可以在 canvas 里绘制知识图谱。我选的是 Cytoscape.js5。其他库功能和用法应该大同小异,本文接下来的内容也有一定参考价值。这一部分内容我大量参考了这篇文章6,也很值得阅读。

知识图谱的本质是图,图包含顶点和边两部分,所以我们需要提供这两组数据。每篇文章就是一个顶点,文章之间的引用关系就是边。所以我们需要生成如下结构的数据:

[
    // 顶点数据
    {
        id: "node-1",
        title: "title-1",
    },
    {
        id: "node-2",
        title: "title-2",
    },
    // 边数据
    {
        id: "edge-1",
        source: "node-1",
        target: "node-2",
    },
    // ...
]

顶点和边都需要指定id字段,不能重复。顶点还需要指定title数据,用来显示标题。边对象需要指定源顶点source和目标顶点target,取值为顶点的id字段。Cytoscape.js 的数据结构可以很复杂,但上面的算是最小功能集,够用了,其他的可以先不深入研究。

有了数据,我们还需要确定图谱的布局算法。Cytoscape.js 内置了几种基础的布局,但功能太弱鸡。Cytoscape.js 也有很多第三方布局算法实现,我用的是 fcose7,比较接近 Obsidian 的实现。

现在准备画布页面,里面包含了基础的 js 引用和 html 结构,整个图谱会充满 html 页面:

<!doctype html>
<html>
  <head>
    <title>知识库🕸️</title>
    <script src='https://unpkg.com/cytoscape@3.30.2/dist/cytoscape.min.js'></script>
    <script src="https://unpkg.com/layout-base/layout-base.js"></script>
    <script src="https://unpkg.com/cose-base/cose-base.js"></script>
    <script src="https://unpkg.com/cytoscape-fcose/cytoscape-fcose.js"></script>

    <style>
    #cy {
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0px;
    left: 0px;
    }
    </style>
  </head>

  <body>
    <div id="cy"></div>
  </body>
  <script type="module">
    // 绘图代码...
  </script>
</html>

第一步是启用 fcose 布局:

cytoscape.use(cytoscapeFcose);

第二步是解析并组织数据。我把所有的顶点和边对象都保存到elements变量中,而且使用文章路径作为顶点的id

let r = await fetch('/index.yml')
let t = await r.text();
let ts = t.split("\n")

let elements = [];
for (let i = 0; i < ts.length; i++) {
    let l = ts[i];
    if (l.startsWith('- ')) {
        let o = JSON.parse(l.slice(2))
        elements.push({ data: {
            id: o.path,
            title: o.title,
        } });
        for (let ref of o.refs) {
            elements.push({ data: {
                id: o.path+'->'+ref,
                source: o.path,
                target: ref,
            } });
        }
    }
}

最后一步是绘制图谱:

var cy = cytoscape({
    container: document.getElementById('cy'),
    elements: elements,
    style: [
        {
            selector: 'node',
            style: {
                label: 'data(title)',
            }
        }],
    layout: {
        name: 'fcose',
        tile: false,
        nodeDimensionsIncludeLabels: true,
        animate: false,
    },
});

其中container指定绘图用的 DOM 容器对象,style 用来控制样式,layout 用来控制布局。这里我做了少许优化。比如在 style 中,我通过selector:'node'让所有顶点都显示title属性。在layout中,我通过过name:'fcose'指定布局算法,并且关闭了动画效果。tile表示将孤立顶点,也就是没有引用关系的顶点,集中排列,展示效果一般,我把它关掉了。nodeDimensionsIncludeLabels表示根据顶点标签的宽度来调整间隔,不然顶点的标题会挤到一起,非常难看。

好了,使用上述代码就能绘制出基本的知识图谱了。大家可以点击本文标题下方的🕸️图标感受使用效果。

以上只是最基础的版本,缺少实用价值。比如我们希望能通过双击顶点来打开对应的文章。为什么是双击呢?因为 Cytoscape 单击是选中顶点,进而查看引用关系。我们可以通过注册 dblclick事件来实现:

cy.on('dblclick', 'node', (e) => {
    var node = e.target;
    let url = node.data('id')
    window.open(url, '_blank').focus();
});

单击选中某顶点后,该顶点会变成蓝色。我希望与之相连的边也变成蓝色,这样可以很直观地看到相互引用的文章顶点。这也可以通过注册事件回调函数来实现:

cy.on('select unselect', 'node', (e) => {
    var node = e.target;
    node.connectedEdges().style({ 'line-color': node.style('background-color') });
});

最后一个调整是页内链接。如果是从某篇文章跳转到知识图谱,我希望能自动选中对应的节点并且能放大对应的区域,方便查看引用关系。这个实现起稍微有点复杂,但并不困难。

我会注册一个特殊的回调函数,Cytoscape 完成绘图后会调用:

cy.ready(() => {
    let h = location.hash;
    if (h) {
        let n = cy.$id(h.substring(1))
        n.select();
        cy.zoom({
            level: 1,
            position: n.position()
        });
        cy.center(n);
    }
});

在该回调中,我会提取 URL 里的锚点并用它作为 ID 来查询对应的节点。如果有节点,就调用select()函数选中并调用zoom()函数放大对应的区域,最后调用center()函数来把该节点调整到画布中心位置。为了方便二次分享,我还在选中节点后自动更新 URL 锚点:

cy.on('select unselect', 'node', (e) => {
    var node = e.target;
    if (e.type === 'select') {
        location.hash = '#' + node.id();
    } else {
        location.hash = '';
    }
});

这样一来,URL 就会保存读者的选择状态。如果分享给他人,他打开后就会定位到刚才选中的节点,比较方便。

好了,以上就是本文的全部内容了。后续我会输出更多更系统化的内容给大家。也希望各位读者能开始自己的博客,开启自己的知识图谱。共勉🍻


  1. ./unix/blog-internal.html↩︎

  2. ./git/socks-proxy.html↩︎

  3. ./why-blog.html↩︎

  4. ./unix/markdown-link.html↩︎

  5. https://js.cytoscape.org/↩︎

  6. https://ishan-mehta17.medium.com/visualize-knowledge-graphs-using-cytoscape-js-c640e8237d82↩︎

  7. https://github.com/iVis-at-Bilkent/cytoscape.js-fcose↩︎