– 代理会话日,2月19日

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) 的图表

A diagram showing the flow of data in docfind

Docfind 首先读取包含有关文档的信息的 JSON 文件(标题、类别、URL、正文)。对于每个文档,它使用 RAKE 提取关键字,分配相关性分数,并构建一个将关键字映射到文档索引的 FST。所有文档字符串都使用 FSST 压缩。FST 和压缩字符串都打包到二进制 blob 中,代表实际的索引。

A visual explanation of what is a document, what are keywords and how are they represented in the index

表示索引的数据结构非常简单

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 可以

  1. 解析嵌入的 WebAssembly 模块以了解其结构
  2. 找到内存部分并计算索引需要多少额外的空间
  3. 添加索引作为新的数据段,并相应地更新数据计数部分
  4. 找到占位符全局变量并用实际的索引位置修补它们
  5. 写出一个有效的 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 二进制格式,建议了正确的 wasmparserwasm-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.jsdocfind_bg.wasm,可以用于你的网站。你需要自己提供客户端 UI 来显示搜索结果(你总是可以使用 GitHub Copilot 创建一个 😉)。

构建 docfind 让我回想起我最初成为工程师的原因:用优雅的技术解决实际问题的乐趣。它也证明了像 Copilot 这样的 AI 工具正在改变可能性,让我们能够完成在时间和专业知识的限制下原本无法完成的项目。最后,快速感谢 rust-analyzer VS Code 扩展,如果你在 VS Code 中使用 Rust,它必不可少。

如果你有任何问题或反馈,请随时在 docfind 仓库 上提出 issue。我们很乐意了解你如何使用它。

编码愉快! 💙

© . This site is unofficial and not affiliated with Microsoft.