将博客打造成个人知识图谱
涛叔细心的读者可能会发现,在我的博客主页右上方和文章标题右下方多出来一个🕸️图标。点击就会打开我的个人知识图谱(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 布局:
.use(cytoscapeFcose); cytoscape
第二步是解析并组织数据。我把所有的顶点和边对象都保存到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))
.push({ data: {
elementsid: o.path,
title: o.title,
;
} })for (let ref of o.refs) {
.push({ data: {
elementsid: 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
事件来实现:
.on('dblclick', 'node', (e) => {
cyvar node = e.target;
let url = node.data('id')
window.open(url, '_blank').focus();
; })
单击选中某顶点后,该顶点会变成蓝色。我希望与之相连的边也变成蓝色,这样可以很直观地看到相互引用的文章顶点。这也可以通过注册事件回调函数来实现:
.on('select unselect', 'node', (e) => {
cyvar node = e.target;
.connectedEdges().style({ 'line-color': node.style('background-color') });
node; })
最后一个调整是页内链接。如果是从某篇文章跳转到知识图谱,我希望能自动选中对应的节点并且能放大对应的区域,方便查看引用关系。这个实现起稍微有点复杂,但并不困难。
我会注册一个特殊的回调函数,Cytoscape 完成绘图后会调用:
.ready(() => {
cylet h = location.hash;
if (h) {
let n = cy.$id(h.substring(1))
.select();
n.zoom({
cylevel: 1,
position: n.position()
;
}).center(n);
cy
}; })
在该回调中,我会提取 URL 里的锚点并用它作为 ID 来查询对应的节点。如果有节点,就调用select()
函数选中并调用zoom()
函数放大对应的区域,最后调用center()
函数来把该节点调整到画布中心位置。为了方便二次分享,我还在选中节点后自动更新 URL 锚点:
.on('select unselect', 'node', (e) => {
cyvar node = e.target;
if (e.type === 'select') {
.hash = '#' + node.id();
locationelse {
} .hash = '';
location
}; })
这样一来,URL 就会保存读者的选择状态。如果分享给他人,他打开后就会定位到刚才选中的节点,比较方便。
好了,以上就是本文的全部内容了。后续我会输出更多更系统化的内容给大家。也希望各位读者能开始自己的博客,开启自己的知识图谱。共勉🍻