博客建站以来,我使用过 Hexo 和 Hugo 两个框架,它们生成的博客在本质上都属于静态博客,对于「搜索」这个与数据库关系紧密的需求,显得有些力不从心——不过也并非没有办法:比如主流的解决方案(这里不考虑使用 Algolia、Swifttype 等第三方服务)就是预先生成一个文档(包括所有的博客数据),然后在浏览器端加载此文档再通过编写 JavaScript 代码进行搜索匹配,最后再输出结果。
这种方案有几个缺点:
当然也有优点:搜索不同的关键词不需要额外发送请求了,因此搜索的响应速度会更快……
但这总归算不上一个优雅的解决方案。
因此我很早(大约两三年前)就想为博客构建一个真正的搜索引擎,当时也研究过一些方案:使用 PostgreSQL 加上一些插件(因为当时已经在使用它作为一言的数据库了),后来觉得这些要是跑在我 512M 小内存的主机上实在是有点太为难它了,于是便搁置了;最近在新购置了一个大内存的 VPS 之后,也终于可以将这个想法实现了。
之前使用 PostgreSQL 作为解决方案,如今看来不是很满意:并非是 PostgreSQL 不好用,而是我想将数据库依赖从 API 系统 中删掉——因为数据库只是存放了一言(我还专门为随机读取一言写了一个 存储过程 ),而查询也只是简单的 SELECT 操作,嵌入式数据库也足够用了,减少了依赖的同时性能还更好;同理我将 Redis 的依赖也删掉了,只是限流操作,我完全可以使用 别的方案 来代替。究其原因,其实是我想在系统设计的复杂度上做减法,尽可能谨慎的为系统引入新的依赖——如果已经引入了,那就尽可能的去掉。
因此我将解决方案框定在了基于编程语言构建的搜索引擎上,这一次我没有选择自己造轮子:一是搜索引擎涉及的技术太复杂,我没有这么多业余的时间;二是业界已经有不少完善的解决方案,没必要自己造轮子了——这并非是我对搜索引擎背后的技术不感兴趣,等以后有空的话,我仍然会研究相关的技术。
我对博客的搜索引擎有以下几方面的要求:
综上,我选择了 Tantivy ——支持复杂的查询以及对搜索结果的高亮;虽然没有提供开箱即用的配置,需要集成到程序里使用,但这反而更方便我自定义 API 接口;Rust 的内存管理不依赖于运行时,少了 GC 的存在,会比 Golang 之流要更省内存,非常适合编写底层性能敏感的程序。
其实符合要求的搜索引擎并不只有 Tantivy,至少 Go 编写的 Bleve 和 C++ 编写的 Typesense 也算符合要求,不过出于私心,我仍然选择了 Tantivy——我想通过它来学习 Rust。
忘记之前在哪看到一句话:学习第 N+1 门编程语言的难度,会比学习第 N 门时要低一半。之前由于接触的编程语言太少了,所以也没什么感觉。如今在断断续续接触了 Python、JavaScript、Golang、Elixir(其他的诸如 SML、Lisp、Ruby 等虽然也用来写过作业,但没有完整的项目支撑,经验也不够,就不提了)之后,觉得这句话得加上一个前提条件:必须是具备相同编程范式或者类似语言特性得语言。
因为在学习不同范式的编程语言时,之前所具备的思维定势反而会误导你:比如当你在习惯了使用动态类型带来的便捷之后,在接触这类静态类型语言的时候发现居然变量、函数还需要声明类型才能使用;当你在习惯了命令式编程的顺序分支循环等语句之后,突然发现函数式编程里面的变量是不可更改的,以及最简单的迭代过程居然还需要使用函数递归的形式来实现……
说这么多,无非是想掩盖一下「我在学习 Rust 的过程中遇到了麻烦」的尴尬😅。
说两个我在使用 Rust 过程中觉得不爽的点吧。
在使用 Golang 要初始化数据库连接的时候,一般都会先声明一个全局变量 DB
,在读取配置文件的时候初始化这个变量,这样就可以在其他的模块里导入使用了;Python(Flask)则一般是在初始化 app 的时候,提前配置好数据库的信息,再调用函数完成初始化,再通过导入 Model 层定义的 ORM 类进行查询操作。
无论是 Golang 或是 Python 所采用的方法,其实关键点都在于全局变量,而 Rust 默认(防杠)不支持全局的变量(即在堆上直接分配),虽然有第三方的 Macro 可以使用,或者使用 unsafe
包一下,也能勉强为全局的变量赋值,可用着第三方的库不感觉别扭么?还有在一片整洁的代码上冒出一个 unsafe
,就好像时时刻刻提醒你「不要这么做,这么做不好」一样,可也没有更好的方法了。
Rust 为了内存安全性,在编译期间就已经决定好了变量何时回收,因此需要避免在堆上声明变量,因为这样变量的生命周期变得迷惑了起来,道理我都懂,不能这么做的原因我也明白、我也没有更好的方法,可用着不爽该吐槽还是得吐槽。
Rust 同样也不支持空指针,而是使用独有的 Option
类型来避免空指针的存在。可在我看来,这个解决方法并算不上好——或者说 Rust 本身做的还不够好。比如,某类型被 Option
包了一层之后,就会失去原类型所派生的属性:原类型派生了 Clone,但是套上了 Option
之后就没有派生 Clone 了。而在 Rust 里经常会遇到 .clone()
某一个变量的情况,因为变量的生命周期只能属于一个 Scope,所以在调用函数的时候往往会将变量 .clone()
一份之后传入参数,而由于 Option
没有派生 Clone,还得自己写一个 Option
类型的 Clone 实现。
虽然自己写一个 Clone 实现也不到十行代码,但这种编译器明显可以优化的着实没必要自己写。
把时间戳转化为时间类型都得借助第三方库,懂得都懂……
在花了大约两周的业余时间之后,我完成了博客搜索引擎的搭建( 源码 ),虽然吐槽归吐槽,但真上线运行的时候还是「真香」了,尤其是性能方面——一个 Tantivy 程序居然只占用了几兆字节的内存(是的,你没有看错),本机测试 QPS 也达到了 3000/S。
吐槽得再厉害,可就冲着 Rust 的性能、稳定和内存占用……也只能捏着鼻子忍了 : )
在之前提到,不采用分词的话,虽然召回率(Recall)一定能达到都是 100%,但精确率(Precision)却会大幅下降。比如搜索「新鲜」,如果不采用分词算法的话,一般会直接将它按 Unicode 分成单个字符,也就是会查出所有匹配到「新」和「鲜」的文档,其中虽然也包括了作为词语一起出现的文档,但还会包含有这两个字分别与其他字组成词语的文档。
为了解决精确率的问题,我使用了 Tantivy 提供的 PhraseQuery——它相比普通的 TermQuery 多了一个匹配单词序列的步骤。在「新鲜」这个词的搜索时,它只会搜索「鲜」这个字紧跟在「新」后面的文档,这自然就很大程度提升了精确率。虽然 PhraseQuery 可以解决在搜索词语时的精确率问题,但是如果只使用 PhraseQuery,针对包含多个词语的搜索又会显著降低召回率,因此关键词的搜索我采用了 TermQuery + PhraseQuery 组合实现。其中单个词语内部之间使用 PhraseQuery,而不同的词语之间使用 TermQuery。
关键词搜索的长度被限制在 38 个字符以内,超过的会被忽略;
如果关键词以 -
符号开始,则意味着搜索不包含该关键词的文档。
Tantivy 的范围检索暂时不支持 Date 类型,因此我将博客的发布时间存为了 int64 类型的时间戳,由于不过对于接口调用,还是使用 ISO8601 的日历日期表示法来作为查询参数会更易理解一些。
如果想查询在某一时间范围的博客,使用:range: startDate~endDate
来查询(其中开始与截止均为 yyyy-mm-dd
的格式),如果只提供了 startDate,则 endDate 会被默认填充为当前时间戳;如果只提供了 endDate,则 startDate 会被填充为 0,二者之间使用 ~
(半角符号的波浪线)连接。
如果指定了多个时间范围,则只会选定首次出现的作为范围,其余的会被忽略。
标签以及分类的组合过滤我使用 TermQuery 来实现,其中标签以及分类的查询最多允许 5 个。
如果要查询某标签下的文章,可以使用 tags:标签名 来查询,如果想查询某分类下的文章,可以使用 category:分类名 来查询,注意使用的是英文的引号。
以上三种查询可以自由组合,但组合需要其中至少一个,否则被视为非法的查询。
快去 搜索页面 耍耍吧~