VS Code 团队分享用 Rust 和 WebAssembly 构建搜索引擎的深度技术细节,展示 Copilot 在工程中的加速效果。
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 索引 1,600,000,000 个键》。发表于近十年前,它解释了如何使用有限状态转换器(FST)在紧凑的二进制格式中索引大量字符串数据,同时支持快速查询,包括正则表达式和模糊匹配。
关键的洞察是 FST 可以将排序的字符串键存储在一个既节省内存又快速查询的状态机中。更好的是,Andrew 发布了一个名为 fst 的 Rust 库,它正好实现了这一点。
如果我们可以使用 FST 来索引从文档中提取的关键词呢?用户输入查询,我们用 FST 匹配它到关键词,得到相关文档列表,所有这一切都在浏览器中进行,无需服务器往返。
但我们如何获得这些文档关键词呢?而且,这不会只是创建一个很大的索引文件吗,考虑到所有的字符串都需要在内存中?我们可以使用压缩来创建尽可能小的索引吗?这引出了我需要解决的另外两个问题:
RAKE(快速自动关键词提取):从文本中提取有意义的关键词和短语的算法。给它一份文档,它会返回按重要性排序的关键词。
FSST(快速静态符号表):针对短字符串优化的压缩算法。由于我们需要存储文档标题、分类和摘要在内存中,压缩将有助于保持索引规模较小。
有了用于快速关键词查询的 FST、用于关键词提取的 RAKE 和用于字符串压缩的 FSST,我有了技术基础。现在我只需要用 Rust 构建它,而这是一门我不太熟悉的语言,且我只能从繁忙的日常工作中抽出有限的时间。
我最终创建了一个单一的 CLI 工具 docfind,用于在网站本身构建时从我们的网站文档创建索引文件。这个 CLI 工具的用户不需要除了 docfind 本身之外的任何外部依赖来创建索引文件。那个索引文件最终成为一个单一的 WebAssembly 模块,可以轻松通过 HTTP 提供给访问者。当访问者来到我们的网站时,他们的浏览器会在后台下载这个 WebAssembly 模块,用于增强搜索功能。
下面是一个关于 docfind 如何将一个文档集合(documents.json)转换为相应索引文件(docfind_bg.wasm)的图表:
Docfind 首先读取一个包含关于你的文档的信息(标题、类别、URL、主文本)的 JSON 文件。对于每份文档,它使用 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 automaton(用于容错拼写错误)和前缀匹配很有用,以获得更相关的匹配。最后,搜索结果是通过组合多个匹配关键词的得分、按需解压缩相关文档字符串并返回排序的结果作为 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 专家。我没有 borrow checker 的肌肉记忆。我当然也没有关于 WebAssembly 二进制格式的深入知识。但我对这一切的总体方向有一定的认识。Copilot 帮我填补了空白并解决了难题。
研究和探索。当我评估 FST、RAKE 和 FSST 时,我使用 Copilot 来理解这些库是如何工作的、提出澄清问题并交流想法。这就像随时随地都能得到一位知识渊博的同事。
高效的 Rust 开发。这可能是最大的收获。Copilot 的下一步编辑建议让我成为一个高效的 Rust 程序员。我不再花费精力与 borrow checker 抗争或查阅语法。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.4ms
网络:单个 WebAssembly 模块,仅在用户表示意图搜索时下载
无需维护服务器。无需管理 API 密钥。无需持续成本。只是一个完全在浏览器中运行、在构建时创建的自包含 WebAssembly 模块。
我们已经开源了 docfind,你今天就可以为你自己的静态网站使用它。安装很直接:
curl -fsSL https://microsoft.github.io/docfind/install.sh | sh
或者,如果你在 Windows 上:
irm https://microsoft.github.io/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。我们很想听听你如何使用它。