本站点文档内容均翻译自code.visualstudio.com,仅供个人学习,如有差异请以官网为准。

构建 docfind:使用 Rust 和 WebAssembly 实现快速客户端搜索

2026年1月15日 由 João Moreno

如果你最近访问了VS Code 网站,你可能会注意到一些新东西:一个快速、响应迅速的搜索体验,几乎感觉即时。

背后的经验是docfind,一个我们构建的搜索引擎,它完全在您的浏览器中使用WebAssembly运行。在本文中,我想分享docfind的来龙去脉:这段旅程带我从十年前关于自动机理论的博客文章到修补WebAssembly二进制文件。

问题

我目前是VS Code团队的软件工程经理,所以最近没有太多时间写代码。当我有时间写代码时,通常不会在不熟悉的领域。但是有些问题会一直困扰你,直到你采取行动解决它们。

直到最近,我们的网站仍然具有那种基本的搜索体验:您会输入一个查询,然后它会将您重定向到由传统搜索引擎提供的搜索结果。这与当今开发人员习惯的不太一样。我希望这些搜索结果能够像您输入时一样瞬间出现,类似于互联网上的许多其他网站。它应该像 VS Code 的 Quick Open 一样快速。Ctrl+P)。

我和我的同事尼克·特罗格一起研究了替代方案。景象看起来如下:

  • Algolia:最先进的搜索即服务。但我想要一个纯粹的客户端解决方案。
  • TypeSense:强大的开源搜索,但需要服务器端代码,就像Algolia一样。此外,这将是一个需要维护和监控的服务。
  • Lunr.js:在JavaScript中进行客户端搜索,听起来很有前途。我们尝试了我们的文档(约3 MB的markdown),但它生成的索引文件大约有10 MB。太大了。
  • Stork Search:由WebAssembly驱动的客户端搜索,带有不错的演示。但是当我们进行测试时,索引仍然相当大,并且该项目似乎没有维护。

这些选项都未能达到最佳效果:快速、客户端、紧凑、易于托管和操作。我开始思考我们是否可以自己构建一些东西。

灵感

思考客户端搜索让我想起了几年前读过的一篇博客。它是由Andrew Gallant(burntsushi)所写,ripgrep的创造者,标题是用Automata和Rust索引16亿个键。这篇文章是在近十年前发布的,它解释了如何使用有限状态转换器(FSTs)来以支持快速查找(包括正则表达式和模糊匹配)的紧凑二进制格式索引大量字符串数据。

关键见解是,FSTs 可以在状态机中存储排序的字符串键,该状态机既内存高效又查询速度快。更棒的是,Andrew 已经发布了一个名为 fst 的 Rust 库,实现了这一点。

如果我们能使用FSTs来索引从我们文档中提取的关键词会怎样?用户输入查询,我们使用FST将查询与关键词匹配,并在浏览器中返回相关的文档列表,而不需要服务器往返。

但是我们如何获取这些文档的关键词呢?而且,考虑到所有的字符串都需要在内存中,这不会仅仅创建一个非常大的索引文件吗?我们能否使用压缩来创建尽可能小的索引?这又让我想到拼图中的另外两块:

  • RAKE (快速自动关键词提取): 一个从文本中提取有意义关键词和短语的算法。输入一个文档,它会返回按重要性排序的关键词。
  • FSST (快速静态符号表): 一种针对短字符串优化的压缩算法。由于我们需要在内存中存储文档标题、类别和片段,压缩将有助于保持索引的小型。

有了FST用于快速关键词查找,RAKE用于关键词提取,FSST用于字符串压缩,我已经有技术基础。现在我只需要在有限的时间内用我不太熟悉的Rust语言来实现它,从我的日常工作当中抽时间完成。

解决方案

我最终创建了一个单一的命令行工具docfind,每当构建网站时,该工具可以从我们的网站文档中创建索引文件。使用这个命令行工具的用户不需要任何外部依赖,只需docfind本身即可创建索引文件。那个索引文件最终成为一个单一的WebAssembly模块,通过HTTP轻松地提供给访客。当访客访问我们的网站时,他们的浏览器在后台下载这个WebAssembly模块,并用于增强搜索功能。

构建索引

这是一个展示 docfind 如何将文档集合转换为索引的图表documents.json) 到相应的索引文件 (docfind_bg.wasm):

显示docfind中数据流动的图表

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

视觉解释什么是文档,什么是关键词以及它们如何在索引中表示

表示索引的数据结构出乎意料地简单:

pub struct Index {
    /// FST 映射关键词到文档索引
    fst: Vec<u8>,

    /// FSST压缩的文档字符串(标题,类别,链接,正文)
    文档字符串FstStrVec

    /// 对于每个关键词索引,一个 (文档索引, 分数) 对的列表
    keyword_to_documents: Vec<Vec<(usize, u8)>>,
}

索引存储关键词并将它们映射到索引中关键词到文档每个条目都指向相关文档及其相关性评分。文档字符串在需要显示时才会被压缩和解压。

现在,我们可以将索引数据结构导出到二进制文件,提供给网站访客,并在网站上有一些 WebAssembly 模块,这些模块会解析它并使用 FST 库执行搜索操作。但事情变得有趣的是,而不是将索引作为单独的二进制文件分发,docfind 直接将它嵌入到搜索库 WebAssembly 模块中,允许访客在他们打算在网站上搜索时获取单个 HTTP 资源。

搜索索引

那么客户端发生了什么?当用户输入查询时,WebAssembly 模块会加载到内存中(代码和文档索引),通过遍历 FST 数据结构来执行查询作为搜索操作。我们发现使用 Levenshtein 自动机(用于容忍拼写错误)和前缀匹配来获取更相关的匹配很有用。最后,通过组合多个匹配关键词的分数,按需解压相关的文档字符串,并以排名结果的 JavaScript 对象形式返回搜索结果。

挑战

这个项目中最棘手的部分不是搜索算法或关键词提取,而是将索引嵌入到WebAssembly二进制文件中。

天真的方法是使用 Rust 的包含字节!宏在编译时将索引烘焙到WebAssembly模块中。但是,这意味着每次文档更改时都需要重新编译WebAssembly模块。相反,我希望有一个预编译的WASM“模板”,CLI工具可以将其与更新的索引一起修补。

这意味着我需要静态地创建一个WebAssembly模块模板,其中包含一个空的index,并将该模板嵌入到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;

在运行时,搜索函数使用这些信息来定位嵌入索引并从原始字节中解析它:

静态 索引: OnceLock<索引> = OnceLock::();

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("反序列化索引失败")
    });
    // ... 执行搜索
}

CLI工具扫描WASM模板的导出部分以找到这些全局变量,读取全局部分以获取它们的内存地址,然后修补包含这些数据段。0x死牛使用实际索引基地址和长度的值:

// 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());
}

// 将索引添加为新的数据段
data_section.active(
    0,
    &ConstExpr::i32_const(index_base as i32),
    raw_index.iter().copied(),
);

这可以说是非常复杂的。理解WASM二进制格式,弄清楚全局变量如何存储和引用,计算内存偏移量。这些问题是很容易使侧项目出轨的。

突破

我必须坦诚地承认,如果没有使用GitHub Copilot 代理,我可能无法完成这个项目。作为一个不再每天编码的经理,选择用以学习曲线陡峭著称的 Rust 语言来处理这个项目是雄心勃勃的。我不是 Rust 专家。我没有对借用检查器的肌肉记忆。而且我当然对 WebAssembly 二进制格式没有深入的了解。但我确实对我想从这一切中获得什么有一个大致的方向。 Copilot 帮助我填补了空白并解决了难题。

研究和探索。 当我在评估FST、RAKE和FSST时,我使用Copilot来了解这些库的工作原理,提出澄清问题,并交流想法。这就像随时都有一个知识渊博的同事在身边。

高效的 Rust 开发。 这可能最大的收获。 Copilot 的下一次编辑建议让我成为了一名高效的 Rust 程序员。我不再花费精力去与借用检查器作斗争或查找语法。 Copilot 处理了机械的部分,让我专注于逻辑。

为WASM目标建立脚手架。 当我要求 Copilot 在项目中添加一个 WebAssembly 输出目标时,它不仅添加了配置,还推断出我希望导出一个搜索函数,并生成了整个lib.rs拥有正确wasm-bindgen注释。它甚至告诉我运行哪个命令来构建它。

docfind. Copilot 帮助我构建了 docfind 的仓库,包括创建一个工作的演示页面,并提供性能虚数。

解决困难的部分。 WASM二进制操作是这个项目的技术关键。理解如何定位全局变量、修补数据段和更新内存部分需要深入细节,这是我以前从未遇到过的。 Copilot帮助我理解了WASM二进制格式,并建议了正确的WASM解析器wasm-编码器APIs,并在 my 的修补二进制文件不有效时帮助调试问题。

没有 Copilot,我相信这个项目会花费我相当长的时间,而且这还是在假设我不会在某个时候放弃的情况下。当你时间有限且在自己的专业领域之外工作时,我发现有一个可以填补知识空白并处理模板的 AI 助手不仅方便,而且是交付和放弃之间的区别。

结果

今天,docfind 为 VS Code 文档网站的搜索体验提供支持。您可以在 docfind 说明文档 中看到当前的性能指标,包括一个 交互式演示,通过您的浏览器搜索 50,000 篇新闻文章。

对于 VS Code 网站(约 3 MB 的 markdown,约 3,700 个文档按标题分区):

  • 索引大小:无压缩约5.9 MB,使用Brotli压缩约2.7 MB
  • 搜索速度:每个查询约0.4毫秒,我的M2 MacBook Air上
  • 网络:单个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文件,包含您的文档,运行文档查找 documents.json 输出,你将会得到一个docfind.jsdocfind_bg.wasm准备好在您的网站上使用。您需要提供自己的客户端用户界面来显示搜索结果(您可以随时使用 GitHub Copilot 创建一个 😉)。

构建 docfind 提醒了我最初选择成为工程师的原因:用优雅的技术解决实际问题的喜悦。这也证明了像 Copilot 这样的 AI 工具如何改变可能的事情,让我们可以处理在时间和专业知识有限的情况下无法完成的项目。最后,快速感谢 rust-analyzer VS Code 扩展,如果你在 VS Code 中使用 Rust,这是必备的。

如果您有任何问题或反馈,请随时在docfind 仓库上提交问题。我们非常希望听到您是如何使用它的。

祝你编码愉快!💙