我一直都知道我想要为我的投资组合中的文章提供全文搜索功能,以便访问者快速访问他们感兴趣的内容。迁移到 Contentlayer 后,它似乎不再那么牵强了。于是我开始探索🚀
tinysearch
启发:WebAssembly 全文搜索引擎经过一番研究,我找到了一个名为tinysearch
的搜索引擎。这是一个使用Rust和WebAssembly (Wasm) 构建的静态搜索引擎。作者 Matthias Endler 写了一篇关于tinysearch
是如何产生的精彩博客文章。
我喜欢在构建时构建一个简约的搜索引擎并将其以优化的低级代码发送到浏览器的想法。所以我决定使用tinysearch
作为蓝图,编写自己的搜索引擎来与我的Next.js 静态站点集成。
我强烈推荐阅读tinysearch
的代码库。它写得很好。我的搜索引擎的实现是它的简化版本。核心逻辑是一样的。
很简单:
您可以试试文章页面的搜索功能!
在撰写本文时,有:
为了使全文搜索适用于追求速度的静态站点,代码大小必须很小。
大多数现代浏览器现在都支持 WebAssembly 。他们能够与 JavaScript 一起运行本机 WebAssembly 代码和二进制文件。
搜索功能的概念很简单。它接受一个查询字符串作为参数。在函数中,我们将查询标记为搜索词。然后,我们根据每篇文章包含的搜索词数为每篇文章打分。最后,我们根据相关性对文章进行排名。分数越高,它的相关性就越高。
流程如下所示:
对文章进行评分是计算量最大的地方。一种简单的方法是将每篇文章转换为包含文章中所有唯一单词的哈希集。我们可以通过简单地计算哈希集中有多少搜索词来计算分数。
您可以想象,这不是哈希集最节省内存的方法。有更好的数据结构来代替它: xor 过滤器。
Xor 过滤器是相对较新的数据结构,它允许我们估计一个值是否存在。它速度快,内存效率高,因此非常适合全文搜索。
xor 过滤器不是像散列集那样存储实际输入值,而是以特定方式存储输入值的指纹(L 位散列序列)。在查找过滤器中是否存在某个值时,它会检查该值的指纹是否存在。
但是,Xor 过滤器有几个取舍:
因为我有 Contentlayer 生成的文章数据,所以我通过在构建 WebAssembly 之前向它们提供数据来构建 xor 过滤器。然后我序列化了异或过滤器并将它们存储在一个文件中。要在 WebAssembly 中使用过滤器,我需要做的就是从存储文件中读取并反序列化过滤器。
过滤器生成流程如下所示:
xorf
crate 是实现异或过滤器的不错选择,因为它提供了序列化/反序列化以及一些提高内存效率和误报率的功能。它还为我的用例提供了一个非常方便的HashProxy
结构来构造带有字符串切片的异或过滤器。用 Rust 编写的构造大致如下所示:
use std::collections::hash_map::DefaultHasher; use xorf::{Filter, HashProxy, Xor8}; mod utils; fn build_filter(title: String, body: String) -> HashProxy<String, DefaultHasher, Xor8> { let title_tokens: HashSet<String> = utils::tokenize(&title); let body_tokens: HashSet<String> = utils::tokenize(&body); let tokens: Vec<String> = body_tokens.union(&title_tokens).cloned().collect(); HashProxy::from(&tokens) }
如果您对实际实现感兴趣,可以在存储库中阅读更多内容。
这是我在 Next.js 中集成 xor 过滤器生成脚本和 WebAssembly 的方法。
文件结构如下所示:
my-portfolio ├── next.config.js ├── pages ├── scripts │ └── fulltext-search ├── components │ └── Search.tsx └── wasm └── fulltext-search
为了支持 WebAssembly,我更新了我的 Webpack 配置以将 WebAssembly 模块加载为异步模块。为了使它适用于静态站点生成,我需要一种解决方法来在.next/server
目录中生成 WebAssembly 模块,以便在运行next build
脚本时静态页面可以成功预渲染。
next.config.js
webpack: function (config, { isServer }) { // it makes a WebAssembly modules async modules config.experiments = { asyncWebAssembly: true } // generate wasm module in ".next/server" for ssr & ssg if (isServer) { config.output.webassemblyModuleFilename = './../static/wasm/[modulehash].wasm' } else { config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' } return config },
这就是集成的全部内容✨
为了从 Rust 代码构建 WebAssembly 模块,我使用wasm-pack
。
生成的.wasm
文件和 JavaScript 的胶水代码位于wasm/fulltext-search/pkg
中。我需要做的就是使用next/dynamic
动态导入它们。像这样:
组件/Search.tsx
import React, { useState, useCallback, ChangeEvent, useEffect } from 'react' import dynamic from 'next/dynamic' type Title = string; type Url = string; type SearchResult = [Title, Url][]; const Search = dynamic({ loader: async () => { const wasm = await import('../../wasm/fulltext-search/pkg') return () => { const [term, setTerm] = useState('') const [results, setResults] = useState<SearchResult>([]) const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { setTerm(e.target.value) }, []) useEffect(() => { const pending = wasm.search(term, 5) setResults(pending) }, [term]) return ( <div> <input value={term} onChange={onChange} placeholder="🔭 search..." /> {results.map(([title, url]) => ( <a key={url} href={url}>{title}</a> ))} </div> ) } }, }) export default Search
未经任何优化,原始 Wasm 文件大小为114.56KB
。我使用Twiggy找出代码大小。
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 117314 ┊ 100.00% ┊ Σ [1670 Total Rows]
与628KB
的原始数据文件相比,它比我预期的要小得多。我很高兴已经将它交付到生产环境中,但我很想知道使用Rust 和 WebAssembly 工作组的优化建议可以减少多少代码大小。
第一个实验是切换 LTO 并尝试不同opt-level
。以下配置产生最小的.wasm
代码大小:
货运.toml
[profile.release] + opt-level = 's' + lto = true
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 111319 ┊ 100.00% ┊ Σ [1604 Total Rows]
接下来,我用wee_alloc
替换了默认分配器。
wasm/全文搜索/src/lib.rs
+ #[global_allocator] + static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100483 ┊ 100.00% ┊ Σ [1625 Total Rows]
然后我尝试了 Binaryen 中的wasm wasm-opt
工具。
wasm-opt -Oz -o wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100390 ┊ 100.00% ┊ Σ [1625 Total Rows]
这比原始代码大小减少了14.4%
。
最后,我能够在以下位置发布全文搜索引擎:
还不错😎
我用web-sys
分析了性能并收集了一些数据:
搜索次数:208
分钟:0.046 毫秒
最大值:0.814 毫秒
平均值:0.0994 毫秒✨
标准差:0.0678
平均而言,执行全文搜索所需的时间不到 0.1 毫秒。
真是太爽了😎
经过一些实验,我能够使用 WebAssembly、Rust 和 xor 过滤器构建一个快速、轻量级的全文搜索。它与 Next.js 和静态站点生成很好地集成。
速度和大小带来了一些权衡,但它们对用户体验没有太大影响。如果您正在寻找更全面的搜索功能,这里有一些很酷的产品可供选择:
SaaS 搜索引擎
静态搜索引擎
基于服务器的搜索引擎
浏览器内搜索引擎
这篇文章也发表在道智的网站上。