博客搜索功能正式上线

Mar 21, 2021
3385
#搜索#博客#Tantivy

本文最近一次更新于 3 年 8 个月前,其中的内容很可能已经有所发展或是发生改变。

https://p26-tt.byteimg.com/origin/pgc-image/e7017ec27e3a48dab655f3f096559b37

博客建站以来,我使用过 Hexo 和 Hugo 两个框架,它们生成的博客在本质上都属于静态博客,对于「搜索」这个与数据库关系紧密的需求,显得有些力不从心——不过也并非没有办法:比如主流的解决方案(这里不考虑使用 Algolia、Swifttype 等第三方服务)就是预先生成一个文档(包括所有的博客数据),然后在浏览器端加载此文档再通过编写 JavaScript 代码进行搜索匹配,最后再输出结果。

这种方案有几个缺点:

  1. 数据保存在客户端,每次搜索都需要请求一次,这很不优雅(即使缓存在了浏览器端);
  2. 不支持稍复杂的表达式,比如按照时间过滤、指定 Tags 或者 Category(当然你也可以自己实现);
  3. 不支持分词,因此精确率会降低,相比前两点,这一点我其实不是很在乎,也有办法可以解决(本博客的关键词搜索方案就是采用逐字匹配的 : )

当然也有优点:搜索不同的关键词不需要额外发送请求了,因此搜索的响应速度会更快……

但这总归算不上一个优雅的解决方案。

因此我很早(大约两三年前)就想为博客构建一个真正的搜索引擎,当时也研究过一些方案:使用 PostgreSQL 加上一些插件(因为当时已经在使用它作为一言的数据库了),后来觉得这些要是跑在我 512M 小内存的主机上实在是有点太为难它了,于是便搁置了;最近在新购置了一个大内存的 VPS 之后,也终于可以将这个想法实现了。

开源技术的选择

之前使用 PostgreSQL 作为解决方案,如今看来不是很满意:并非是 PostgreSQL 不好用,而是我想将数据库依赖从 API 系统中删掉——因为数据库只是存放了一言(我还专门为随机读取一言写了一个存储过程),而查询也只是简单的 SELECT 操作,嵌入式数据库也足够用了,减少了依赖的同时性能还更好;同理我将 Redis 的依赖也删掉了,只是限流操作,我完全可以使用别的方案来代替。究其原因,其实是我想在系统设计的复杂度上做减法,尽可能谨慎的为系统引入新的依赖——如果已经引入了,那就尽可能的去掉。

因此我将解决方案框定在了基于编程语言构建的搜索引擎上,这一次我没有选择自己造轮子:一是搜索引擎涉及的技术太复杂,我没有这么多业余的时间;二是业界已经有不少完善的解决方案,没必要自己造轮子了——这并非是我对搜索引擎背后的技术不感兴趣,等以后有空的话,我仍然会研究相关的技术。

我对博客的搜索引擎有以下几方面的要求:

  1. 支持稍复杂的查询,比如像 Boolean Query、Term Query 、Regex Query 等可以组合起来查询;
  2. 可定制化程度最好高一些(这一点是非必须的),比如:我更偏向于 Lucene,而不是 ElasticSearch(仅举例,实际上我二者我都不会选);
  3. 内存占用要尽可能的小,运行起来要尽可能的稳定,毕竟要 7*24 小时运行;
  4. 支持按照关键词对搜索结果高亮。

综上,我选择了 Tantivy——支持复杂的查询以及对搜索结果的高亮;虽然没有提供开箱即用的配置,需要集成到程序里使用,但这反而更方便我自定义 API 接口;Rust 的内存管理不依赖于运行时,少了 GC 的存在,会比 Golang 之流要更省内存,非常适合编写底层性能敏感的程序。

编写 API 的吐槽

其实符合要求的搜索引擎并不只有 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:分类名 来查询,注意使用的是英文的引号。

以上三种查询可以自由组合,但组合需要其中至少一个,否则被视为非法的查询。

End

快去搜索页面耍耍吧~

博客搜索功能正式上线

https://blog.itswincer.com/posts/blog-search-is-ready/

作者

Wincer

更新于

Mar 21, 2021

许可协议

CC BY-NC-ND 4.0
  1. Jul 22, 2024

    又一次博客优化记录
  2. May 7, 2023

    使用 Hugo + SolidStart 重构博客
  3. Sep 15, 2022

    聊聊博客主题的更新
  4. Aug 22, 2018

    博客折腾小记
  5. Jul 5, 2018

    博客访问统计报告(2017.6.20-2018.7.4)
  6. Jul 29, 2017

    再见 LiveRe,拥抱 Disqus