使用lunr.js为Wiki系统增加全文搜索支持

搜索 Wiki 知识库的问题

今年早些时候我捣鼓了一个基于 Wikitten 和 MDwiki 的 个人知识库系统,我一般使用基于 PHP Wikitten 的 动态 Wiki 知识库,本地预览或者测试时可以用基于 MDwiki 的 静态 Wiki 知识库,两个配合使用并通过 BitTorrent Sync 与 VPS 进行数据同步,这样需要更新时也是很方便的。

我在实际使用中还是发现 Wikitten 的搜索功能比较薄弱,只支持通过文档或目录名称进行搜索(Wikitten 显示时是直接遍历 Wiki 文档的,出于效率考虑也不好直接进行目录遍历全文搜索);MDwiki 则由于是纯静态实现,根本没有搜索功能,通过我写的生成 Wiki 目录索引的脚本也只能一层层目录定位到 Wiki 文档,要想快速的搜索知识库内容暂时只能通过在本地运行 grep 等命令的方式。

关于 lunr.js

最近我在看到 lunr.js 这个轻量级 JavaScript 全文搜索引擎之后,搜索 Wiki 知识库的问题也迎来了曙光。目前有一些基于 Hexo 之类静态博客框架的站点就是使用 lunr.js 实现了站内搜索功能。因为我考虑将 lunr.js 加入到 Wikitten 和 MDwiki 系统中以支持直接在线全文搜索。

lunr.js 的官方版本目前仍然不支持中文的分词器,其目前支持的语言列表在 这里。开始我考虑过使用 wei song 博主写的基于 lunr.js 的改进版 Elasticlunr.js,作者介绍的 Elasticlunr.js 相比 lunr.js 可以实现更快的搜索速度和更小的索引文件,只是 Elasticlunr.js 同样也不支持中文搜索,还是暂且作罢。

最终我使用的是国内网友 codepaino 分享的支持中文的修改版 lunr.js,项目地址为:

https://github.com/codepiano/lunr.js

在 VPS 上安装修改版的 lunr.js 也还是比较简单的,首先 VPS 上需要有 node.js 环境(这里只介绍 Linux 版本的),下载修改版的 lunr.js:

root@zoserver:~# wget https://github.com/codepiano/lunr.js/raw/master/lunr.min.js

然后 npm 安装依赖的 nodejieba 库:

root@zoserver:~# npm install -g nodejieba
|
> nodejieba@2.2.4 install /usr/lib/node_modules/nodejieba
> node-gyp rebuild

gyp WARN EACCES user "root" does not have permission to access the dev dir "/root/.node-gyp/4.6.1"
gyp WARN EACCES attempting to reinstall using temporary dev dir "/usr/lib/node_modules/nodejieba/.node-gyp"
make: Entering directory `/usr/lib/node_modules/nodejieba/build'
  CXX(target) Release/obj.target/nodejieba/lib/index.o
  CXX(target) Release/obj.target/nodejieba/lib/nodejieba.o
  SOLINK_MODULE(target) Release/obj.target/nodejieba.node
  COPY Release/nodejieba.node
make: Leaving directory `/usr/lib/node_modules/nodejieba/build'
nodejieba@2.2.4 /usr/lib/node_modules/nodejieba
└── nan@2.3.5

生成 Wiki 搜索索引文件

在线的 Wiki 系统在实际使用搜索功能时需要在浏览器前端加载 lunr.js 的搜索索引文件,为了方便生成整个 Wiki 知识库目录的所有 Markdown 文档的索引,我写了一个生成 Wiki 搜索索引文件的 node.js 程序。

search.js 程序的功能非常简单,遍历 Wikitten 和 MDwiki 共用的 Wiki 文档目录(默认为 library),获取遍历到的 Wiki 文档的标题、关键字标签和文档内容(这三者搜索权重依次降低),然后使用 lunr.js 最终生成 JSON 格式的索引文件:

var fs = require('fs'), removeMd = require('remove-markdown'), lunr = require('./static/js/lunr.min.js');

var md_index = lunr(function () {
	this.field('title');
	this.field('tags');
	this.field('body');
	this.ref('url');
});

function walk_dir(wdir, path, callback) {
	var dirList = fs.readdirSync(wdir + path);
	dirList.forEach(function(item) {
		if(fs.statSync(wdir + path + '/' + item).isDirectory())
			walk_dir(wdir, path + '/' + item, callback);
		else
			callback(wdir, path + '/' + item);
	});
}

function process_markdown(wdir, path) {
	var pos = path.lastIndexOf(".");
	if (pos < 0) return;
	if (path.substr(pos) != ".md" && path.substr(pos) != ".markdown") return;
	var fpos = path.lastIndexOf("/");
	var title = (fpos >= 0 ? path.substring(fpos + 1, pos) : path.substr(0, pos));

	// ignore navigation menu document
	if (path == "/navigation.md") return;

	var has_front = false;
	var md_doc = {
		"url": path,
		"title": title,
		"tags": ""
	};

	var data = fs.readFileSync(wdir + path, "utf-8");
	// ignore auto generated index.md
	if (title == "index" && data.indexOf("Auto-index of") >= 0) return;

	// process front matter
	if (data.substr(0, 3) == "```") {
		pos = data.substr(3).indexOf("```");
		if (pos >= 0) {
			var front = JSON.parse("{" + data.substr(3, pos) + "}");
			has_front = true;
			if ("title" in front) md_doc.title = front.title;
			if ("tags" in front) md_doc.tags = front.tags.join(' ');
			md_doc.body = removeMd(data.substr(pos + 6));
		}
	}
	if (!has_front) md_doc.body = removeMd(data);
	md_index.add(md_doc);
}

if (process.argv.length < 4) {
	console.log("Usage:\n");
	console.log(process.argv[0] + " " + process.argv[1] + " Wiki-document-directory Search-index-file");
	console.log(process.argv[0] + " " + process.argv[1] + " Search-index-file Keyword");
	process.exit(1);
} else if (!fs.existsSync(process.argv[2])) {
	console.log("Invalid Wiki-document-directory or Search-index-file: " + process.argv[2]);
	process.exit(1);
}

if (fs.statSync(process.argv[2]).isDirectory()) {
	walk_dir(process.argv[2], "", process_markdown);
	fs.writeFileSync(process.argv[3], JSON.stringify(md_index.toJSON()));
} else {
	md_index = lunr.Index.load(JSON.parse(fs.readFileSync(process.argv[2])));
	console.log(JSON.stringify(md_index.search(process.argv[3])));
}

提示

为了方便 Wikitten 和 MDwiki 能同时使用 lunr.js,我把 search.js 程序引用的修改版 lunr.min.js 文件放到了 Wiki 程序主目录下的 static/js 文件夹,如需要的话可以自行修改。

上面的程序在遍历 Markdown 文档时忽略了我之前写的自动生成子目录索引文档脚本 generate-index.sh 产生的 index.md 文档,另外也忽略了 MDwiki 专用的 navigation.md Wiki 系统菜单文档。

search.js 程序使用了 remove-markdown 这个去掉 Markdown 格式的 npm 包,同样用 npm 安装即可:

root@zoserver:~# npm install -g remove-markdown

一般情况下直接在 Wiki 程序目录下运行 search.js 程序生成索引文件即可:

root@zoserver:/home/wiki# node search.js library search_index.json

最后上面的命令生成出来的 search_index.json 就是 lunr.js 格式的索引文件,我们可以直接放到 Wiki 程序主目录下。

经过实际测试目前我的 Wiki 目录一共包含 44 篇有效的 Markdown 文档,文件夹占用不到 500 KB,不过生成出来的 search_index.json 索引文件就已经接近 1.5 MB 了,浏览器访问索引文件时通过 gzip 压缩传输之后差不多 160 KB,还在我能接受的范围内。

另外你也可以使用 search.js 程序通过索引文件直接在本地进行关键字搜索:

root@zoserver:/home/wiki# node search.js search_index.json shell
[{"ref":"/技术/Android/Android Shell控制手机.md","score":0.21389728235059557},{"ref":"/技术/Linux/Shell/Shell进行TCP和UDP网络编程.md","score":0.2134705596450158},{"ref":"/技术/Linux/Shell/sed命令相关技巧.md","score":0.13650355812409723},{"ref":"/技术/Linux/Shell/vim操作技巧.md","score":0.1060635524797807},{"ref":"/技术/Windows/rundll32运行命令列表.md","score":0.035704080604386734},{"ref":"/技术/Windows/Windows注册表记录.md","score":0.0019103777768910307}]

将 lunr.js 整合到 Wiki 系统

有了正确的搜索索引文件之后,我们就可以修改 Wikitten 和 MDwiki 程序将 lunr.js 搜索功能整合进来了。

下面 Wikitten 和 MDwiki 支持全文搜索的修改我已经提交到 wikitten-and-mdwiki 项目,提交记录可以参考 这里

Wikitten 修改

由于 Wikitten 自带了按文档和目录名搜索的功能,我们只要给 Wikitten 增加 AJAX 加载 lunr.js 搜索索引文件并按关键字搜索的功能,最后使用 lunr.js 的搜索结果替换原来的即可。

Wikitten 默认只允许直接访问 static 子目录中的文件,我们需要修改对应的 Web 目录转发规则,这里只贴出 Nginx 服务器的配置(Apache 用户请自行修改 .htaccess 文件):

server
{
	gzip on;
	gzip_min_length 1000;
	gzip_buffers 4 8k;
	gzip_types text/plain application/x-javascript text/css application/json application/xml text/javascript;

	location ~* ^/(robots.txt|search_index.json|static/(css|js|img|fonts)/.+.(jpg|jpeg|gif|css|png|js|ico|html|xml|txt|swf|pdf|txt|bmp|eot|svg|ttf|woff|woff2))$ {
		access_log off;
		expires max;
	}
}

location 规则指定对 Wiki 主目录下的 robots.txtsearch_index.json 索引文件以及 static 子目录中的文件不经过 Wikitten 处理;另外为了提高索引文件的加载速度上面的配置中还开启了 gzip 压缩,MDwiki 也可以做同样的修改。

MDwiki 修改

MDwiki 原本没有搜索功能,为了方便页面上使用,我在其专用的 navigation.md Wiki 系统菜单文档里增加了一个搜索菜单,这样所有 Wiki 文档页面就都能使用了:


[🔍]()
 * # Search file name or content.
 * [<input id="search_input" type="text"/>](#)


搜索菜单显示效果如下:

MDwiki 搜索菜单

然后在 MDwiki 对应的 index.html 中增加搜索处理代码:

<script type="text/javascript" src="static/js/lunr.min.js"></script>
<script type="text/javascript">
var search_indexes = null;

function doSearch(query) {
	if (search_indexes == null) return;
	var s_res = search_indexes.search(query);
	var t_html = '<ul>';
	for (var i = 0; i < s_res.length; i++) {
		var t_pos = s_res[i].ref.lastIndexOf('/');
		t_html += '<li><a href="#!' + s_res[i].ref + '">' + (t_pos >= 0 ? s_res[i].ref.substr(t_pos + 1) : s_res[i].ref) + '</a></li>';
	}
	t_html += '</ul>';
	$('#search-result-body').html(t_html);
	$('#search-result-label').html('Search result for: ' + query);
	$('#search-result').modal('show');
}

function search_content(query) {
	if (search_indexes == null) {
		$.getJSON("search_index.json", function(data){
			if (data != null) search_indexes = lunr.Index.load(data);
			doSearch(query);
		});
	} else
		doSearch(query);
}

$(document).bind('DOMNodeInserted', function(e) {
	if ($('#search_input').length <= 0) return;
	$(document).unbind('DOMNodeInserted');

	$('#search_input').bind('click', function(event) {
		event.stopPropagation();
		return false;
	});
	$('#search_input').bind('keypress', function(event){
		if (event.keyCode == '13')
			search_content($(this).val());
	});
});
</script>

<div class="modal fade" id="search-result" tabindex="-1" role="dialog" aria-labelledby="search-result-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&#x274c;</button>
<h4 class="modal-title" id="search-result-label"></h4>
</div>
<div class="modal-body" id="search-result-body"></div>
</div>
</div>
</div>

上面对搜索输入框的点击事件增加了处理,防止点击时 Bootstrap 的搜索菜单项丢失,在输入框中输入关键字回车才加载索引文件并使用 lunr.js 进行搜索。

MDwiki 的搜索效果如下图所示:

MDwiki 搜索结果

点击搜索结果中的文档名称就会跳转显示对应 Wiki 文档内容。

总结

经过对 Wikitten 和 MDwiki 进行修改,目前 Wiki 知识库的显示和查询效果基本都能满足我的需求了。虽然还存在搜索索引文件体积稍大的隐患,后续也可以考虑使用 Elasticlunr.js 或者其它方式进行改进咯。

  1. zthxxx:

    基于分词的搜索也有不方便的地方,如果搜索的字并未被分成词,那么就搜不到结果,比如,有一篇文章中有“桌面图标”这个词,我在搜索栏中搜索“图标”,就没有任何结果。这样,尤其是在代码比较多的 wiki 中,很容易丢失一些信息。从使用方面来说,分词的搜索体验还不如直接提取文章全文本加正则的搜索方式。

  2. Uranus Zhou:

    目前用非官方的lunr修改版中文分词实现可能是有点问题了,后面估计可以想办法改进,总归还是不完美的。
    这个 Wiki 系统文档都是在不同子目录的独立文件,如果搜索时全部遍历提取再匹配那就太麻烦了。

  3. zthxxx:

    我的博客里就是先把不同目录下的所有文章内容都提取纯文本,根据文章名做成 json,所有的内容放在一个json文件里,搜索的时候就不用再遍历,而是直接读取这个文件就好。由于是纯文本,所以博文多文件也很小,应该是比分词词表小很多。遍历过程也只是每发布一篇文章更新一次,对文件本身来说也类似与增量式更新。至于遍历过程麻烦,那反正都是机器干的事。

  4. LiarOnce:

    运行node search.js library search_index.json这段命令时提示找不到nodejieba包,但我已经安装了这个包,请问如何解决。

    module.js:472
    throw err;
    ^

    Error: Cannot find module ‘nodejieba’
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at /mnt/x/Users/LiarOnce.DESKTOP-NRV42PI/Desktop/wikis/assets/lunr.min.js:7:1058
    at Object. (/mnt/x/Users/LiarOnce.DESKTOP-NRV42PI/Desktop/wikis/assets/lunr.min.js:7:15224)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)

  5. LiarOnce:

    目前nodejieba问题解决了,但执行时发生了另一个错误:
    Invalid Wiki-document-directory or Search-index-file: library

  6. Uranus Zhou:

    是不是 library 目录不在运行的当前路径下?也可以用绝对路径

  7. LiarOnce:

    明白了,但是为什么根目录不行

  8. Uranus Zhou:

    根目录是什么意思?Wiki 文档目录参数不用 library 而是直接用 MDwiki 程序目录?那样目录参数可以用 “.” 或者绝对路径吧

  9. LiarOnce:

    还有一个问题:
    如何对标题进行搜索?

  10. Uranus Zhou:

    直接就支持的哦,搜索权重默认按 标题->关键字->内容 的优先级来。

  11. bing:

    大佬,有demo吗

  12. bing:

    看到了,原来是隐藏技能





*