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

Matthias Endler

使用 Rust 和 WebAssembly 构建一个微型静态全文搜索引擎

我写了一个基础搜索模块,你可以将它添加到静态网站中。它非常轻量(gzip 压缩后为 50kB-100kB),并且支持 Hugo、Zola 和 Jekyll。目前只支持搜索完整单词。你可以试试左侧的搜索框,看看演示效果。代码在 Github 上

静态网站生成器真是神奇。它们结合了两全其美的优势:无需牺牲性能,就能提供动态内容。

多年来,这个博客一直运行在 JekyllCobalt,以及最近使用的 Zola 上。

不过,我一直不喜欢的一点是,静态网站并不会同时自带“静态”搜索引擎。相反,人们只能使用 Google 自定义搜索Algolia 之类的外部搜索引擎,或者使用纯 JavaScript 方案,例如 lunr.jselasticlunr

对于大多数网站来说,这些方案都运行良好,但我始终觉得它们并不是最终答案。

我不想再增加一个对 Google 的依赖;也不想使用像 Algolia 这样的独立 Web 后端,因为它会增加延迟,而且是专有的。

另一方面,我也不太喜欢大量依赖 JavaScript 的网站。比如,光是 lunr 创建的搜索索引就可能达到数 MB。即使按照今天的带宽标准,这也显得过于奢侈了。除此之外,解析 JavaScript 仍然很耗时

我想要的是一种简单、精简且自包含的搜索功能,可以和其他静态内容一起部署。

因此,我干脆没有给博客添加搜索功能。这很遗憾,因为随着文章数量不断增加,找到相关内容变得越来越困难。

想法

很多年前,也就是 2013 年,我读到了 “Writing a full-text search engine using Bloom filters(《使用 Bloom filters 编写全文搜索引擎》)”——这让我豁然开朗。

想法很简单:把我的所有博客文章交给一个生成器处理,使用一种名为 ✨Bloom Filter(布隆过滤器)✨ 的神奇数据结构,生成一个微小且自包含的搜索索引。

等等,Bloom Filter 是什么?

Bloom filter 是一种节省空间的方式,用来检查某个元素是否属于一个集合。

它的诀窍在于:它不存储元素本身;它只会比较有把握地知道这些元素之前是否被存储过。在我们的例子中,它可以在存在一定错误率的情况下判断某个单词是否出现在一篇文章中。

Bloom filter 不存储所有输入值的原始数据,而是存储它们的“指纹”(一组哈希值)。因此得到的是一种内存占用很低的数据结构。这里以 ‘hello’ 作为输入示例。
Bloom filter 不存储所有输入值的原始数据,而是存储它们的“指纹”(一组哈希值)。因此得到的是一种内存占用很低的数据结构。这里以 ‘hello’ 作为输入示例。

下面是原文中为每篇文章生成 Bloom filters 的 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,其内存占用极小,同时只会产生数量可以忽略的假阳性。

我立刻就知道,我想在自己的主页上实现类似的东西。我的想法是直接把 Bloom filters 和搜索引擎发送到浏览器中。这样我终于可以拥有一个小型静态搜索功能,而不需要后端了!

头疼的问题

但很快我就不再抱有幻想。

我不知道该如何打包和最小化生成的 Bloom filters,更别提如何在客户端运行它们了。原文对此只是简单提到:

你需要在客户端实现 Bloom filter 算法。这可能不会比倒排索引搜索算法长太多,但它可能还是要复杂一些。

我对自己的 JavaScript 技能没有足够信心,无法完成这件事。2013 年,NPM 还只有三岁,WebPack 也刚满一岁,所以我也不知道该去哪里寻找现成方案。

由于不知道下一步该做什么,我的想法最终只能停留在白日梦阶段。

新的希望

五年后的 2018 年,Web 已经变成了一个不同的世界。打包工具无处不在,Node 生态系统也在蓬勃发展。尤其是有一件事重新点燃了我对微型静态搜索引擎的梦想:WebAssembly(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);
}

虽然我成功为每篇文章创建了 Bloom filters,但我仍然不知道该如何将它打包到 Web 上……直到 wasm-pack 于 2018 年 2 月出现

糟糕!我把一些 Rust 代码发送到了你的浏览器。

现在,拼图的所有部分都齐了:

  • Rust——我熟悉的一门语言
  • wasm-pack——WebAssembly 模块的打包工具
  • 一个可运行的原型,作为概念验证

你在本页面左侧看到的搜索框就是最终成果。它完全使用 Rust,并通过 WebAssembly(也就是 RAW stack)运行。想试的话,现在就可以试试。

一路上遇到了不少障碍。

Bloom Filter Crates

我研究了几个实现 Bloom filters 的 Rust 库(crates(代码包))。

首先,我尝试了 jedisct1 的 rust-bloom-filter,但其中的类型没有实现 Serialize/Deserialize。这意味着我无法将生成的 Bloom filters 存储在二进制文件中,并在客户端加载它们。

又尝试了几个其他方案后,我找到了支持序列化的 cuckoofilter crate。它的行为与 Bloom filters 类似;如果你对两者的差异感兴趣,可以看看这份总结

下面是它的使用方式:

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 filters 为博客中的十篇文章打包 filters 时的输出大小:

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

44kB 看起来还不错,但这只是十篇文章的 cuckoo filters,以 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,它会像这样用 unreachable 替换 WebAssembly 函数的函数体,但并没有减小代码体积:

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

稍微调整 cuckoo filters 的参数,并从文章中移除停用词后,我最终达到了 121kB(gzip 压缩后为 51kB)——考虑到 Web 上图片的平均大小约为 900kB,这个结果还不错。除此之外,搜索功能只会在用户点击搜索框时加载。

更新

最近我把项目从 cuckoofilters 移植到了 XOR filters。我使用了很棒的 xorf 项目,它内置了 serde 序列化功能,因此我可以移除大量自定义代码。

这样一来,我又将载荷大小减少了 20-25%。现在我的博客上只剩下 99kBgzip 压缩后为 49kB)了。🎉

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

前端与胶水代码

wasm-pack 会自动生成用于与 Wasm 通信的 JavaScript 代码。

对于搜索界面,我从 w3schools 定制了几段 JavaScript 和 CSS。它甚至支持键盘操作!现在,当用户输入搜索查询时,我们会遍历每篇文章的 cuckoo filter,并尝试匹配其中的单词。结果按照命中次数评分。感谢我亲爱的同事 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 代码快得惊人,因为我们省去了与搜索服务器之间的往返通信。即时反馈循环更像是在筛选列表,而不是搜索文章。它甚至可以完全离线运行;如果你喜欢把它和应用一起打包,这一点可能很有用。

原文由 Matthias Endler 发布

本文章由 openai/gpt-5.6-luna 进行翻译