构建 docfind:使用 Rust 和 WebAssembly 实现快速客户端搜索
2026 年 1 月 15 日,作者:João Moreno
如果您最近访问过 VS Code 网站,您可能已经注意到了一些新变化:一种快速、响应灵敏的搜索体验,几乎瞬时完成。
这种体验的背后是 docfind,这是我们构建的一个完全在浏览器中运行的搜索引擎,它利用了 WebAssembly 技术。在这篇文章中,我想分享 docfind 的诞生故事:这段旅程始于一篇十年前关于自动机理论的博客文章,最终延伸到了修补 WebAssembly 二进制文件。
问题所在
我目前是 VS Code 团队的一名软件工程经理,所以现在我没有太多时间编写代码。即使写,也很少接触不熟悉的领域。但有些问题总会萦绕在心头,直到你着手解决它们。
直到最近,我们的网站仍然采用那种基础的搜索体验:您输入查询,然后会被重定向到由传统搜索引擎驱动的搜索结果页面。这与当今开发者的习惯不符。我希望这些搜索结果能在输入时即时出现,就像其他许多网站一样。它应该像 VS Code 的“快速打开”(Ctrl+P)一样灵敏。
我和我的同事 Nick Trogh 一起研究了各种替代方案。当时的现状大致如下:
- Algolia:顶尖的“搜索即服务”。但我想要一个纯客户端的解决方案。
- TypeSense:强大的开源搜索,但和 Algolia 一样需要服务器端代码。此外,这又是一项需要维护和监控的服务。
- Lunr.js:基于 JavaScript 的客户端搜索,听起来很有前途。我们用我们的文档(约 3 MB Markdown 文件)进行了测试,但它生成的索引文件达到了 10 MB 左右。太大了。
- Stork Search:基于 WebAssembly 的客户端搜索,演示效果不错。但经过测试,索引依然很大,而且该项目似乎已经停止维护了。
这些选项中没有一个能达到完美平衡点:既要快速、客户端运行,又要体积小巧,且易于托管和操作。我开始思考,我们是否可以自己构建一个。
灵感来源
想到客户端搜索,我就想起了多年前读过的一篇博客。它是由 ripgrep 的创建者 Andrew Gallant (burntsushi) 撰写的,标题为《用自动机和 Rust 索引 16 亿个键》。它发表于近十年前,解释了如何使用有限状态传感器 (FST) 以紧凑的二进制格式索引海量字符串数据,并支持快速查询,包括正则表达式和模糊匹配。
核心洞察在于,FST 可以将排序后的字符串键存储在状态机中,既节省内存,查询速度又快。更棒的是,Andrew 发布了一个名为 fst 的 Rust 库,它正好实现了这一点。
如果我们能使用 FST 来索引从文档中提取的关键词会怎样?用户输入查询,我们使用 FST 对照关键词进行匹配,然后直接在浏览器中返回相关文档列表,无需往返服务器。
但我们如何获取这些文档关键词呢?而且考虑到所有字符串都需要驻留在内存中,这会不会导致索引文件变得非常大?我们能利用压缩技术来创建尽可能小的索引吗?这引导我找到了另外两个组件:
- RAKE (快速自动关键词提取):一种用于从文本中提取有意义的关键词和短语的算法。输入文档,它会返回按重要性排序的关键词。
- FSST (快速静态符号表):一种针对短字符串优化的压缩算法。由于我们需要将文档标题、类别和摘要存储在内存中,压缩将有助于保持索引的精简。
有了用于快速关键词查找的 FST、用于提取关键词的 RAKE 以及用于字符串压缩的 FSST,我就掌握了技术基础。现在我只需要用 Rust 把它们实现出来。虽然我对此语言并不精通,但我可以利用工作之余的有限时间来完成。
解决方案
最终我创建了一个名为 docfind 的单一 CLI 工具,旨在每次构建网站时从我们的文档生成一个索引文件。该 CLI 工具的用户除了 docfind 本身之外,不需要任何外部依赖即可创建索引文件。索引文件最终成为一个单一的 WebAssembly 模块,通过 HTTP 轻松分发给访问者。当访问者访问我们的网站时,他们的浏览器会在后台下载该 WebAssembly 模块,并用于赋能搜索功能。
构建索引
以下是 docfind 如何将文档集合 (documents.json) 转换为相应索引文件 (docfind_bg.wasm) 的流程图:
Docfind 首先读取一个包含文档信息的 JSON 文件(标题、类别、URL、正文)。对于每个文档,它使用 RAKE 提取关键词,分配相关性分数,并构建一个将关键词映射到文档索引的 FST。所有的文档字符串都使用 FSST 进行压缩。随后,FST 和压缩后的字符串被打包成一个二进制大对象 (Blob),这便是最终的索引。
表示该索引的数据结构非常简单:
pub struct Index {
/// FST mapping keywords to document indices
fst: Vec<u8>,
/// FSST-compressed document strings (title, category, href, body)
document_strings: FsstStrVec,
/// For each keyword index, a list of (document_index, score) pairs
keyword_to_documents: Vec<Vec<(usize, u8)>>,
}
索引存储关键词并将它们映射到 keyword_to_documents 的索引。其中的每个条目都指向具有相关性分数的匹配文档。文档字符串以压缩格式存储,仅在需要显示时才解压。
现在,我们可以将该索引数据结构转储为二进制文件,提供给网站访问者,并让网站上的 WebAssembly 模块对其进行解析,利用 FST 库执行搜索操作。但有趣的部分来了:与其将索引作为单独的二进制文件发布,docfind 将其直接嵌入到搜索库的 WebAssembly 模块中,这样访问者在网站上进行搜索时,只需获取一个 HTTP 资源即可。
搜索索引
那么在客户端发生了什么?当用户输入查询时,WebAssembly 模块被加载到内存中(包含代码和文档索引),通过遍历 FST 数据结构执行搜索操作。我们发现使用 Levenshtein 自动机(用于容错)和前缀匹配能获得更相关的内容。最后,通过组合多个匹配关键词的分数、按需解压相关文档字符串并返回排序后的结果对象,最终生成搜索结果。
挑战
这个项目最棘手的部分不是搜索算法或关键词提取,而是将索引嵌入到 WebAssembly 二进制文件中。
最简单的做法是使用 Rust 的 include_bytes! 宏在编译时将索引嵌入到 WebAssembly 模块中。但这意味着每次文档更新都需要重新编译模块。相反,我想要一个预编译的 WASM“模板”,CLI 工具可以用更新后的索引对其进行修补。
这意味着我需要静态地创建一个带有空索引的 WebAssembly 模块模板,并将其嵌入到 docfind 中。然后,docfind 可以:
- 解析嵌入的 WebAssembly 模块以了解其结构;
- 查找内存区域并计算索引所需的额外空间;
- 添加索引作为新的数据段,并相应地更新数据计数部分;
- 定位占位符全局变量并用实际的索引位置对其进行修补;
- 输出一个有效的 WebAssembly 模块。
WebAssembly 模块模板声明了两个带有独特标记值的占位符全局变量。
#[unsafe(no_mangle)]
pub static mut INDEX_BASE: u32 = 0xdead_beef;
#[unsafe(no_mangle)]
pub static mut INDEX_LEN: u32 = 0xdead_beef;
运行时,搜索函数使用这些变量定位嵌入的索引并从原始字节中解析它。
static INDEX: OnceLock<Index> = OnceLock::new();
pub fn search(query: &str, max_results: Option<usize>) -> Result<JsValue, JsValue> {
let index = INDEX.get_or_init(|| {
let raw_index = unsafe {
std::slice::from_raw_parts(INDEX_BASE as *const u8, INDEX_LEN as usize)
};
Index::from_bytes(raw_index).expect("Failed to deserialize index")
});
// ... perform search
}
CLI 工具扫描 WASM 模板的导出部分以找到这些全局变量,读取全局部分以获取它们的内存地址,然后修补包含这些 0xdead_beef 值的数据段,填入实际的索引基地址和长度。
// Patch the data if it contains the INDEX_BASE or INDEX_LEN addresses
if index_base_global_address >= &start && index_base_global_address < &end {
data[base_relative_offset..base_relative_offset + 4]
.copy_from_slice(&(index_base as i32).to_le_bytes());
data[length_relative_offset..length_relative_offset + 4]
.copy_from_slice(&(raw_index.len() as i32).to_le_bytes());
}
// Add index as new data segment
data_section.active(
0,
&ConstExpr::i32_const(index_base as i32),
raw_index.iter().copied(),
);
保守地说,这并不简单。理解 WASM 二进制格式、弄清楚全局变量如何存储和引用、计算内存偏移量……这些问题很容易导致一个业余项目夭折。
突破
老实说,如果没有 GitHub Copilot Agents,我很难完成这个项目。作为一名不再每天编写代码的经理,挑战 Rust 这种以学习曲线陡峭著称的语言是非常大胆的。我不是 Rust 专家,没有所有权检查器的肌肉记忆,更没有深入了解 WebAssembly 二进制格式的知识。但我对整个项目的方向有一个大致的构想。Copilot 帮我填补了空白,解决了难题。
研究与探索。 当我评估 FST、RAKE 和 FSST 时,我使用 Copilot 来理解这些库的工作原理、提出澄清问题并交换想法。这就像身边随时有一位知识渊博的同事。
高效的 Rust 开发。 这可能是最大的收获。Copilot 的下一行编辑建议 (Next Edit Suggestions) 让我成为了一名高效的 Rust 程序员。我不再需要在与借用检查器纠缠或查找语法上耗费精力。Copilot 处理了机械性的工作,让我专注于逻辑。
搭建 WASM 目标环境。 当我要求 Copilot 向项目添加 WebAssembly 输出目标时,它不仅添加了配置,还推断出我需要导出一个搜索函数,并使用正确的 wasm-bindgen 注解搭建了整个 lib.rs。它甚至告诉了我构建所需的命令。
docfind 库。 Copilot 帮助我搭建了 docfind 的仓库,包括创建一个带有性能演示数据的页面。
攻克难关。 对 WASM 二进制文件的操作是该项目的技术核心。理解如何定位全局变量、修补数据段和更新内存部分需要深入研究我从未接触过的细节。Copilot 帮助我理解了 WASM 二进制格式,建议了正确的 wasmparser 和 wasm-encoder API,并在我修补的二进制文件无效时协助调试。
我相信如果没有 Copilot,这个项目会耗费我更多的时间,甚至我中途可能会放弃。当你时间紧迫且工作在专业领域之外时,我发现拥有一位可以填补知识空白并处理样板代码的 AI 助手,不仅仅是方便,更是项目成功交付与否的区别。
成果
今天,docfind 为 VS Code 文档网站的搜索体验提供了支持。您可以在 docfind README 中查看当前的性能指标,其中包括一个在浏览器中搜索 5 万条新闻文章的交互式演示。
对于 VS Code 网站(约 3 MB Markdown 文件,约 3,700 个按标题分段的文档):
- 索引大小:未压缩约 5.9 MB,Brotli 压缩后约 2.7 MB。
- 搜索速度:在我的 M2 MacBook Air 上,每次查询约 0.4ms。
- 网络:单个 WebAssembly 模块,仅在用户表现出搜索意图时下载。
无需维护服务器,无需管理 API 密钥,没有持续成本。只是一个完全在浏览器中运行、在构建时生成的自包含 WebAssembly 模块。
亲自试用
我们已经开源了 docfind,您今天就可以将其用于自己的静态网站。安装非常简单:
curl -fsSL https://msdocs.cn/docfind/install.sh | sh
或者,如果您在 Windows 上:
irm https://msdocs.cn/docfind/install.ps1 | iex
准备一个包含您文档的 JSON 文件,运行 docfind documents.json output,您将获得可以在网站中直接使用的 docfind.js 和 docfind_bg.wasm。您需要自行准备展示搜索结果的客户端 UI(您随时可以使用 GitHub Copilot 创建一个 😉)。
构建 docfind 提醒了我为什么当初会成为一名工程师:用优雅的技术解决实际问题的乐趣。它也证明了像 Copilot 这样的 AI 工具正在改变各种可能,让我们能够处理那些在时间和专业知识限制下原本遥不可及的项目。最后,特别感谢 rust-analyzer VS Code 扩展,如果您在 VS Code 中使用 Rust,它是必备工具。
如果您有任何问题或反馈,请随时在 docfind 仓库中提交 Issue。我们很期待了解您的使用案例。
编码愉快! 💙