構建 docfind:使用 Rust 與 WebAssembly 實現快速的客戶端搜尋
2026 年 1 月 15 日,作者:João Moreno
如果您最近瀏覽過 VS Code 網站,可能會注意到一個新功能:一種幾乎即時、快速且響應迅速的搜尋體驗。
這項體驗的幕後功臣是 docfind,這是我們構建的一個搜尋引擎,完全在您的瀏覽器中運行,並使用 WebAssembly 技術。在這篇文章中,我想分享 docfind 的誕生故事:這是一段從十年前關於自動機理論的部落格文章,一直到修改 WebAssembly 二進位檔案的旅程。
問題所在
我目前是 VS Code 團隊的軟體工程經理,所以這些日子裡我沒有太多時間編寫程式碼。即便有,也鮮少涉足不熟悉的領域。但有些問題總是會困擾著你,直到你採取行動解決它們為止。
直到最近,我們的網站仍然使用那種基礎的搜尋體驗:輸入查詢後,它會將您重定向到由傳統搜尋引擎驅動的搜尋結果頁面。這並非當今開發者所習慣的方式。我希望這些搜尋結果能像許多其他網站一樣,隨著您的輸入即時出現。它應該要像 VS Code 的「快速開啟」(Ctrl+P) 一樣流暢。
我和我的同事 Nick Trogh 一起研究了替代方案。當時的情況大致如下:
- Algolia:頂尖的搜尋即服務 (Search-as-a-Service) 解決方案。但我想要的是純客戶端的解決方案。
- TypeSense:強大的開源搜尋引擎,但和 Algolia 一樣需要伺服器端程式碼。此外,這意味著要維護和監控額外的服務。
- Lunr.js:使用 JavaScript 實現的客戶端搜尋,聽起來很有希望。我們用我們的文件(約 3 MB 的 Markdown)進行了測試,但產生的索引檔案高達 10 MB 左右。太大了。
- Stork Search:基於 WebAssembly 的客戶端搜尋,且有一個不錯的演示。但當我們進行測試時,索引仍然相當龐大,而且該專案似乎已停止維護。
這些選項都沒有達到最佳平衡點:快速、客戶端、精簡,且易於託管和操作。我開始思考,我們是否能自己構建一個解決方案。
靈感來源
思考客戶端搜尋時,我想起了多年前讀過的一篇部落格文章。它由 ripgrep 的創作者 Andrew Gallant (burntsushi) 所寫,標題為《使用自動機與 Rust 索引 16 億個鍵值》。這篇文章發表於近十年前,解釋了如何利用有限狀態轉換器 (Finite State Transducers, FSTs) 將大量字串資料索引為精簡的二進位格式,從而支援快速查找,包括正則表達式和模糊比對。
關鍵在於 FST 可以將排序後的字串鍵值儲存在一個既節省記憶體又易於查詢的狀態機中。更棒的是,Andrew 已經發佈了一個名為 fst 的 Rust 函式庫,正好實現了這一點。
如果我們能利用 FST 從文件中提取關鍵字來建立索引呢?使用者輸入查詢時,我們使用 FST 對照關鍵字進行比對,就能在瀏覽器中取得相關文件的列表,完全無需伺服器往返。
但我們該如何取得這些文件的關鍵字?考慮到所有字串都需要載入記憶體,這會不會建立出一個非常大的索引檔案?我們是否能使用壓縮技術來建立盡可能小的索引?這引導我找到了拼圖中的另外兩塊:
- RAKE (Rapid Automatic Keyword Extraction):一種從文字中提取有意義關鍵字和短語的演算法。將文件餵給它,它會回傳按重要性排序的關鍵字。
- FSST (Fast Static Symbol Table):一種針對短字串最佳化的壓縮演算法。由於我們需要將文件標題、類別和摘要儲存在記憶體中,壓縮將有助於保持索引的精簡。
有了 FST 用於快速關鍵字查找、RAKE 用於關鍵字提取,以及 FSST 用於字串壓縮,我已經掌握了技術基礎。現在我只需要在有限的時間內,用我並不精通的 Rust 語言將其建構出來。
解決方案
最終,我建立了一個名為 docfind 的單一 CLI 工具,旨在每次構建網站時,都能從網站文件中產生索引檔案。使用此 CLI 工具的使用者除了 docfind 本身外,不需要任何額外的相依套件即可建立索引檔案。該索引檔案最終成為一個單一的 WebAssembly 模組,並透過 HTTP 輕鬆提供給訪客。當訪客進入我們的網站時,他們的瀏覽器會在後台下載此 WebAssembly 模組,並用於支援搜尋功能。
構建索引
下圖展示了 docfind 如何將文件集合 (documents.json) 轉換為對應的索引檔案 (docfind_bg.wasm):
Docfind 首先會讀取一個包含文件資訊(標題、類別、URL、正文)的 JSON 檔案。對於每一份文件,它會使用 RAKE 提取關鍵字、分配相關性評分,並建立一個將關鍵字對應到文件索引的 FST。所有的文件字串都使用 FSST 進行壓縮。隨後,FST 和壓縮後的字串會被封裝成一個二進位大物件 (Binary 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 模組以了解其結構
- 找到記憶體區段並計算索引所需的額外空間
- 將索引作為新的資料區段 (Data Segment) 加入,並相應地更新資料計數區段
- 定位預留位置的全域變數,並將其修補為實際的索引位置
- 寫出一個有效的 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 的 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
- 搜尋速度:每次查詢約 0.4ms(在我 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 檔案,執行 docfind documents.json output,您將獲得可用於網站的 docfind.js 和 docfind_bg.wasm。您需要自行提供客戶端 UI 來展示搜尋結果(您隨時可以用 GitHub Copilot 建立一個 😉)。
構建 docfind 讓我回想起當初為何成為工程師:用優雅的技術解決實際問題的樂趣。這也證明了像 Copilot 這樣的 AI 工具正在改變可能性,讓我們能夠挑戰那些因時間和專業知識限制而遙不可及的專案。最後,特別感謝 rust-analyzer VS Code 擴充功能,如果您在 VS Code 中使用 Rust,這是必備工具。
如果您有任何問題或反饋,歡迎在 docfind 儲存庫開啟 issue。我們很樂意聽聽您是如何使用它的。
祝開發愉快! 💙