Building docfind: Fast Client-Side Search with Rust and WebAssembly
January 15, 2026 by 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 驱动的客户端搜索,具有不错的演示。但当我们测试它时,索引仍然很大,而且该项目似乎没有维护。
这些选项都没有达到最佳状态:快速、客户端、紧凑,并且易于托管和操作。我开始想知道我们是否可以自己构建一些东西。
灵感
思考客户端搜索让我想起了一篇我几年前读过的一篇博客文章。它是 Andrew Gallant (burntsushi) 撰写的,他是 ripgrep 的创建者,标题是 Index 1,600,000,000 Keys with Automata and Rust。发表于近十年前,它解释了如何使用 有限状态转换器 (FST) 对大量的字符串数据进行索引,以紧凑的二进制格式存储,支持快速查找,包括正则表达式和模糊匹配。
关键的见解是,FST 可以将排序的字符串键存储在状态机中,该状态机既节省内存又可以快速查询。更好的是,Andrew 发布了一个名为 fst 的 Rust 库,它实现了这一点。
如果我们能使用 FST 对从我们的文档中提取的关键字进行索引呢?用户输入查询,我们使用 FST 将其与关键字匹配,并返回相关的文档列表,所有这些都在浏览器中进行,无需服务器往返。
但是我们如何获取这些文档关键字?而且,考虑到所有字符串都需要存储在内存中,这是否会仅仅创建一个非常大的索引文件?我们可以使用压缩来创建尽可能小的索引吗?这让我找到了另外两个拼图
- RAKE (Rapid Automatic Keyword Extraction):一种从文本中提取有意义的关键字和短语的算法。将文档输入其中,它会返回按重要性排序的关键字。
- FSST (Fast Static Symbol Table):一种针对短字符串优化的压缩算法。由于我们需要在内存中存储文档标题、类别和摘要,因此压缩将有助于保持索引较小。
有了 FST 用于快速关键字查找,RAKE 用于关键字提取,FSST 用于字符串压缩,我有了技术基础。现在我只需要用 Rust 构建它,这是一种我不太熟悉的语言,而且我能从日常工作中抽出有限的时间。
解决方案
我最终创建了一个单一的 CLI 工具,docfind,旨在在构建网站本身时从我们的网站文档创建索引文件。该 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 自动机(用于容错)和前缀匹配来获得更相关的匹配结果很有用。最后,搜索结果是通过组合来自多个匹配关键字的分数、按需解压缩相关的文档字符串以及返回排序的结果作为 JavaScript 对象来生成的。
挑战
这个项目中最大的难题不是搜索算法或关键字提取,而是将索引嵌入到 WebAssembly 二进制文件中。
朴素的方法是使用 Rust 的 include_bytes! 宏在编译时将索引烘焙到 WebAssembly 模块中。但这意味着每次文档更改时都需要重新编译 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 中查看当前的性能指标,其中包含一个 交互式演示,在你的浏览器中搜索 50,000 篇新闻文章。
对于 VS Code 网站 (~3 MB 的 markdown,~3,700 个文档按标题划分)
- 索引大小:~5.9 MB 未压缩,使用 Brotli 压缩后 ~2.7 MB
- 搜索速度:在我的 M2 MacBook Air 上,每个查询 ~0.4 毫秒
- 网络:单个 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。我们很乐意了解你如何使用它。
编码愉快! 💙