A Tiny, Static, Full-Text Search Engine using Rust and WebAssembly

Matthias Endler

用 Rust 和 WebAssembly 打造轻量静态全文搜索引擎

原文由 Matthias Endler 发布,订阅该博客

我写了一个可以添加到静态网站的基础搜索模块。它非常轻量(gzip 后仅 50kB-100kB),支持 Hugo、Zola 和 Jekyll。目前仅支持完整单词搜索。你可以点击左侧的搜索框体验演示。代码已在 Github 上开源

静态网站生成器非常神奇。它们兼具两者的优点:无需牺牲性能就能实现动态内容。

多年来,这个博客先后使用过 JekyllCobalt,最近则换成了 Zola

不过,我一直不太喜欢的一点是,静态网站却没有与之配套的“静态”搜索引擎。人们往往只能求助于谷歌自定义搜索Algolia 这类外部搜索引擎,或是 lunr.jselasticlunr 这样的纯 JavaScript 方案。

这些方案对大多数网站来说都没问题,但在我看来始终不是最终的答案。

我不想再多一个对 Google 的依赖;也不想使用像 Algolia 这样会增加延迟且闭源的独立后端服务。

另一方面,我也不太喜欢重度依赖 JavaScript 的网站。例如,lunr 生成的搜索索引就可能达到数 MB 之大。这未免太过铺张——即便以当今的带宽标准来看也是如此。更何况,解析 JavaScript 依然很耗时

我想要的是简单、轻量、自包含的搜索,能够和其它静态资源一起部署。

因此,我干脆一直没有给博客加上搜索功能。这很遗憾,因为随着文章越来越多,想找到相关内容也变得越来越困难。

想法

很多年前,在 2013 年,我读到了《Writing a full-text search engine using Bloom filters》一文——那真让我豁然开朗。

想法很简单:把我所有的博客文章丢进一个生成器,用这种神奇的数据结构——✨布隆过滤器✨——来生成一个小巧、自包含的搜索索引。

等等,什么是布隆过滤器?

布隆过滤器是一种节省空间的、用于判断元素是否属于某个集合的方法。

它的巧妙之处在于,它并不存储元素本身;它只是以一定的可信度知道这些元素曾经被存储过。在我们的场景里,它能以一定的错误率判断某个词是否在某篇文章中出现过。

布隆过滤器存储的是所有输入值的“指纹”(若干哈希值),而非原始输入本身,从而形成一种内存占用极低的数据结构。图为以“hello”作为输入的示例。
布隆过滤器存储的是所有输入值的“指纹”(若干哈希值),而非原始输入本身,从而形成一种内存占用极低的数据结构。图为以“hello”作为输入的示例。

以下是原文中为每篇文章生成布隆过滤器的 Python 代码(由 Stavros Korokithakis 提供):

filters = {}
for name, words in split_posts.items():
  filters[name] = BloomFilter(capacity=len(words), error_rate=0.1)
  for word in words:
    filters[name].add(word)

得益于 error_rate 允许极少量的误判,它的内存占用非常小。

我立刻就想为自己的主页做一个类似的东西。我的想法是直接把布隆过滤器和搜索引擎一起发到浏览器端。这样我终于可以拥有一个小巧、无需后端的静态搜索了!

困境

很快就迎来了幻灭。

我完全不知道该如何打包和压缩生成的布隆过滤器,更不用说让它们在客户端跑起来了。原文对此只是轻描淡写地提了一句:

你需要在客户端实现布隆过滤器算法。它的代码可能不会比倒排索引搜索算法长太多,但很可能还是要复杂一些。

我对自己的 JavaScript 水平没什么信心,不觉得自己能搞定。当时是 2013 年,NPM 才诞生三年,WebPack 也刚满一岁,所以我也不知道该去哪里找现成的解决方案。

不知下一步该怎么做,这个想法便一直只是空想。

新的希望

五年后,到了 2018 年,Web 世界已经大不相同。打包工具无处不在,Node 生态也蓬勃发展。有一件事尤其让我重燃了对微型静态搜索引擎的梦想:WebAssembly

WebAssembly(简称 Wasm)是一种基于栈式虚拟机的二进制指令格式。Wasm 被设计为 C/C++/Rust 等高级语言的可移植编译目标,使得在 Web 客户端和服务端都能部署应用。[原文]

这意味着我可以用自己熟悉的语言来写客户端代码——Rust!🎉

我的尝试始于 2018 年 1 月的一个原型。它只是对上面 Python 版本的直接移植:

let mut filters = HashMap::new();
for (name, words) in articles {
  let mut filter = BloomFilter::with_rate(0.1, words.len() as u32);
  for word in words {
    filter.insert(&word);
  }
  filters.insert(name, filter);
}

虽然我成功为每篇文章创建了布隆过滤器,但我依然不知道该如何将它打包到网页中使用……直到 wasm-pack 在 2018 年 2 月出现

哎呀!我把 Rust 代码发到你的浏览器里了。

现在我已经集齐了所有拼图:

  • Rust——我用得顺手的语言
  • wasm-pack——WebAssembly 模块打包器
  • 一个作为概念验证的可运行原型

你在本页面左侧看到的搜索框就是成果。它完全基于 Rust 并通过 WebAssembly 运行(也就是所谓的 RAW 技术栈)。喜欢的话现在就可以试试。

这一路上遇到了不少障碍。

布隆过滤器相关的 crate

我调研了几个实现了布隆过滤器的 Rust 库(crate)。

一开始我尝试了 jedisct1 的 rust-bloom-filter,但它的类型没有实现 Serialize/Deserialize。这意味着我无法将生成的布隆过滤器存到二进制文件中并在客户端加载。

尝试了其他几个之后,我找到了支持序列化的 cuckoofilter crate。它的行为与布隆过滤器类似,如果你想了解两者的区别,可以看看这篇总结

用法如下:

let mut cf = cuckoofilter::new();

// Add data to the filter
let value: &str = "hello world";
let success = cf.add(value)?;

// Lookup if data was added before
let success = cf.contains(value);
// success ==> true

让我们看看用布谷鸟过滤器(cuckoo filter)打包博客中十篇文章的过滤器后,输出体积有多大:

~/C/p/tinysearch ❯❯❯ l storage
Permissions Size User    Date Modified Name
.rw-r--r--   44k mendler 24 Mar 15:42  storage

44kB 听起来还不错,但这仅仅是十篇文章的布谷鸟过滤器序列化为 Rust 二进制后的大小。再加上搜索功能和辅助代码,客户端代码在使用原生 wasm-pack 打包后总共达到了 216kB。太大了。

精简二进制体积

在初版原型 216kB 这个令人沮丧的结果之后,我们有几种办法来减小体积。

第一种是遵循 johnthagen 关于最小化 Rust 二进制体积的建议。

通过在 Cargo.toml 中设置几个选项,我们就能省下不少字节:

"opt-level = 'z'" => 249665 bytes
"lto = true"      => 202516 bytes
"opt-level = 's'" => 195950 bytes

opt-level 设为 s 意味着我们以速度换体积,但我们目前首要关注的是尽可能小。毕竟,更小的下载体积也能提升性能。

接下来,我们可以尝试 wee_alloc,这是一个能生成更小 .wasm 体积的 Rust 替代分配器。

它面向的是那种一开始进行少量动态大小的分配,之后便不再进行额外分配、专注于繁重计算的场景。这种场景下仍然需要一个分配器,但我们非常乐意用分配性能来换取更小的代码体积。

这正是我们想要的。来试试看!

"wee_alloc and nightly" => 187560 bytes

我们又从二进制中削掉了约 4%。

出于好奇,我尝试将 codegen-units 设为 1,也就是只用单线程进行代码生成。令人惊讶的是,这也让体积稍微变小了一点。

"codegen-units = 1" => 183294 bytes

随后我了解到一个叫 binaryen 的 Wasm 优化器。在 macOS 上可以通过 homebrew 安装:

brew install binaryen

它提供了一个叫 wasm-opt 的二进制工具,又帮我们削掉了约 15%:

"wasm-opt -Oz" => 154413 bytes

接着我移除了 web-sys,因为我们不需要绑定 DOM:152858 bytes。

有一个叫 twiggy 的工具可以分析 Wasm 二进制的代码体积。它输出了如下内容:

twiggy top -n 20 pkg/tinysearch_bg.wasm
 Shallow Bytes │ Shallow % │ Item
─────────────┼───────────┼────────────────────────────────
         79256 ┊    44.37% ┊ data[0]
         13886 ┊     7.77% ┊ "function names" subsection
          7289 ┊     4.08% ┊ data[1]
          6888 ┊     3.86% ┊ core::fmt::float::float_to_decimal_common_shortest::hdd201d50dffd0509
          6080 ┊     3.40% ┊ core::fmt::float::float_to_decimal_common_exact::hcb5f56a54ebe7361
          5972 ┊     3.34% ┊ std::sync::once::Once::call_once::{{closure}}::ha520deb2caa7e231
          5869 ┊     3.29% ┊ search

从输出可以看出,二进制中占比最大的部分是我们文章的原始数据段。其次是函数名和一些浮点数转十进制的辅助函数,它们很可能来自反序列化。

最后,我尝试了 wasm-snip,它会将 WebAssembly 函数体替换为 unreachable,像这样,但并没有减小体积:

wasm-snip --snip-rust-fmt-code --snip-rust-panicking-code -o pkg/tinysearch_bg_snip.wasm pkg/tinysearch_bg_opt.wasm

在调整了布谷鸟过滤器的参数并从文章中去除了停用词之后,我最终将体积降到了 121kB(gzip 后 51kB)——考虑到网页上单张图片的平均大小约为 900kB,这个结果已经相当不错了。更何况,搜索功能只有在用户点击搜索框时才会加载。

更新

最近我把项目从布谷鸟过滤器迁移到了 XOR 过滤器。我使用了非常棒的 xorf 项目,它自带 serde 序列化支持,这让我得以删掉了大量自定义代码。

借此,我又将负载体积减小了 20-25%。现在我的博客上已经降到了 99kBgzip 后 49kB)🎉

新版本已经在 crates.io 上发布,如果想尝试的话可以去看看。

前端与胶水代码

wasm-pack 会自动生成与 Wasm 交互的 JavaScript 代码。

搜索界面方面,我基于 w3schools 的一些 JavaScript 和 CSS 代码做了定制。它甚至支持键盘操作!现在当用户输入搜索查询时,我们会遍历每篇文章的布谷鸟过滤器来尝试匹配单词,并按命中次数对结果进行评分。感谢我的好同事 Jorge Luis Betancourt 添加了这部分功能。

搜索功能演示视频
搜索功能演示视频

(趣闻:这个动画的大小和未压缩的 Wasm 搜索本身差不多。)

局限

只会匹配完整单词。我很想加上前缀搜索,但尝试后发现二进制体积会变得过大。

使用方法

用于创建 Wasm 文件的独立二进制程序叫 tinysearch。它只需要一个指向 JSON 文件的路径作为输入:

tinysearch path/to/corpus.json

这个 corpus.json 包含了你想要索引的文本。格式非常简单:

[
  {
    "title": "Article 1",
    "url": "https://example.com/article1",
    "body": "This is the body of article 1."
  },
  {
    "title": "Article 2",
    "url": "https://example.com/article2",
    "body": "This is the body of article 2."
  }
]

你可以用任意静态网站生成器来生成这个 JSON 文件。这是我为 Zola 写的版本

{% set section = get_section(path="_index.md") %}

[
  {%- for post in section.pages -%}
    {% if not post.draft %}
      {
        "title": {{ post.title | striptags | json_encode | safe }},
        "url": {{ post.permalink | json_encode | safe }},
        "body": {{ post.content | striptags | json_encode | safe }}
      }
      {% if not loop.last %},{% endif %}
    {% endif %}
  {%- endfor -%}
]

我相信 Jekyll 的版本看起来也差不多。这里有一个起点。如果你为自己的静态网站生成器实现了可用方案,请告诉我

一些体会

  • 这仍然是一片蛮荒之地:不稳定的特性、nightly 版 Rust、文档几乎每天都在过时。
    要做好心理准备!
  • 把一个好点子做成产品需要大量工作。需要关注诸多因素:易用性、通用性、可维护性、文档等等。
  • Rust 在移除无用代码方面非常出色,所以你通常不会为没用到的东西买单。不过我还是建议你在为 Wasm 二进制添加依赖时要非常谨慎,因为很容易为了添加一些不需要的功能而增加体积。比如,我在测试期间用了 StructOpt,还写了一个解析命令行参数的 main() 函数。这对 Wasm 来说是不必要的,所以后来我把它移除了。
  • 我理解不是每个人都想写 Rust 代码。入门确实有点复杂,但妙处在于你几乎可以用任何其他语言。比如,你可以写 Go 代码并转译为 Wasm,或者你更喜欢 PHP 或 Haskell 也行。已经有对多种语言的支持了。
  • 很多人把 WebAssembly 视为玩具技术。他们大错特错。在我看来,WebAssembly 将会彻底改变我们在 Web 及更广领域构建产品的方式。两年前还很困难的事情,现在已经变得轻而易举:用任意语言编写代码并在所有浏览器中运行。我对其未来感到无比兴奋。
  • 如果你在为公司网站寻找独立的、自托管的搜索索引,可以看看 sonic。也可以看看 stork 作为替代方案。

来试试吧!

tinysearch 的代码已在 Github 上开源

请注意以下限制:

  • 仅搜索完整单词。没有搜索建议。原因是前缀搜索会让二进制体积像曼妥思加可乐一样爆炸。
  • 由于我们将所有文章的所有搜索索引打包进同一个静态二进制文件中,我只推荐在中小型网站上使用。每篇文章大约会增加 4kB(未压缩)。
  • 编译时间目前惨不忍睹(在我的机器上全新安装后每次重建索引大约需要 1.5 分钟),主要是因为每次重建索引时都要从头编译 Rust crate。
    更新:多亏了 CephalonRho 在 PR #13 中的出色工作,这个问题已基本解决。再次感谢!

最终的 Wasm 代码速度极快,因为我们省去了到搜索服务器的往返。实时的反馈感觉更像是在过滤列表,而不是在文章中搜索。它甚至可以完全离线工作,如果你想把它打包到应用中,这或许会很有用。

本文章由 muse-spark-1.2-contributor 进行翻译

评论