← 返回信息流
AI 资讯Hacker News·2 小时前

浏览器内置语义与混合搜索

原标题:Semantic/Hybrid Search in the Browser

速览

该技术让浏览器能够理解查询语义,结合关键词与向量搜索,实现更精准的本地检索。它减少了对后端服务的依赖,可离线运行,推动浏览器向智能搜索平台演进。

AI 深度解读

背景

八年前,作者为自己的博客添加了客户端搜索功能,使用了 Lunr.js。该工具在构建时生成倒排索引,以 JSON 格式发送到浏览器,然后在浏览器端匹配字符串。整个过程不需要服务端引擎,且运行良好——只要输入的单词确实出现在文章中,就能找到对应内容。

今年早些时候,作者用约 250 行 Python 编写了一个语义搜索引擎(例如,搜索“alcoholic beverage disaster in England”能返回“London Beer Flood”,因为模型理解 beer 是酒精饮料,flood 是灾难)。但这个引擎需要安装 sentence-transformers 和几百 MB 的 PyTorch,要在生产服务器上运行需要昂贵的大内存机器,根本无法在浏览器标签页中运行。

因此,博客之前只有关键词搜索:随处可运行但无法理解语义;没有语义搜索:能理解语义却无法在静态站点附近运行。这篇文章的目标就是填补这一空白:实现完全在浏览器中运行的语义搜索,无需服务端和 API,整个模型仅是一个约 4 MB 的查找表。作者在文章中提供了三个基准模型的在线演示,读者可以在自己的硬件上实时测试(作者表示自己负担不起 GPU 集群)。

在这个过程中,作者还发现关键词搜索存在一个持续数年的 bug,这再次证明了正确做评估的重要性。

核心内容

明显的方案:23 MB

支持 Python 版语义搜索的模型(如 all-MiniLM-L6-v2 这类 sentence-transformers)实际上可以在浏览器中运行。Transformers.js 可以下载 ONNX 格式的模型并用 WebAssembly 执行。作者在笔记本上测试了量化模型及其运行时,总传输量为 23.45 MB,加载并可用约需 2 秒,嵌入查询耗时约 18 毫秒。

对于一个只有 14 篇文章的博客,为了嵌入一个搜索框而下载 23 MB 显得过于奢侈——这相当于十几张高分辨率照片的数据量。虽然对于某些应用完全值得,但作者不希望访问 Bloom filter 文章的用户(尤其是通过移动网络)被迫下载这么大体积。

关键点是:嵌入搜索查询并不一定需要 transformer。只需要一个足够好的向量,而有一种更廉价的方式获取它。

静态嵌入模型:一张查找表

诀窍在于一类称为 model2vec 的模型(具体有名为“potion”的变体)。它们是从真正的 sentence-transformer 蒸馏而来的,但结果不是神经网络,而是一张表。

整个模型的前向传播过程(来自库源代码,不是简化版):

  1. 分词:ids = tokenize(text) 将文本分割为子词 token id。
  2. 查表:rows = embedding[ids] 为每个 token 查找一个向量。
  3. 平均:vector = rows.mean(axis=0) 对所有 token 向量取平均。
  4. 归一化:vector = vector / norm(vector) 归一化为单位长度。

没有注意力、没有层、没有推理。“运行模型”仅仅是几次数组查找和一次平均。对于 potion-base-8M,表包含 29,528 个 token,每个 256 维 float32(约 30 MB),达到 MiniLM 检索质量的 81%,而本质上是字典查找。

30 MB 仍然太大,但一张表比一个 transformer 更容易压缩。

构建索引:分块与无用的缓存

嵌入在构建时生成。作者在笔记本电脑上运行 Hugo 时,一个 Python 脚本会遍历所有文章,去除 front matter 和代码块,将每篇文章分割成约 600 字符的重叠块,对每个块进行嵌入,并将向量写入一个文件——当用户点击搜索框时,浏览器会下载这个文件。

分块的重要性超出预期,因为静态嵌入是 token 向量的平均。如果将整个 15,000 字符的文章平均成一个向量,得到的结果只能指向“关于软件的通用英文散文”,而无法区分具体内容。稀有、有区分度的词(如 pydub 或 mmh3)会被周围数百个普通词淹没。将文章切成更小的块有助于保持这些信号的清晰度。这后来也成为某些模型优于其他模型的关键因素。

作者还构建了一个嵌入缓存,以每个块文本的哈希值作为键,这样重新嵌入时只处理变化的块。但事实证明这浪费了时间和 token。对博客所有块进行嵌入只需几百次查找和一次平均,耗时约 10 毫秒。为这个已经瞬间完成的操作构建缓存完全是画蛇添足(毕竟 14 篇文章的数据量太小)。对于 MiniLM 这种真正的神经网络前向传播过程,缓存也许有意义,但作者并未使用该模型。所以缓存正确但毫无用处,作者保留它作为“过度工程很容易”的提醒。

压缩表:模型的停用词列表就藏在几何中

原始表 30 MB 是因为 float32。量化为 int8 可以缩小到四分之一。但问题在于不能简单地将所有值缩放到同一尺度——理解原因成为作者构建过程中最喜欢的收获。

观察每个 token 的行向量的实际模长。对 potion-base-8M 中所有 token 按向量长度排序,最短的(接近零)是:

  • a, ., ,, -, ), the, to, and, of, in

最长的:

  • turkmenistan, seychelles, guantanamo, hemingway, vanuatu

这不是巧合,也不是噪声。模型的停用词列表就是它的行模长。当平均 token 向量时,向量很小的词几乎不影响结果,而向量大的词则主导结果。模型没有使用停用词列表,也没有特殊处理,就学会了“the”几乎不应贡献,“guantanamo”应贡献很大。这很优美,而且就在几何中。

因此量化需要每个行单独的缩放因子:每个 token 有自己的 float32 乘数,这样相对模长在压缩为 int8 后仍能保留。但实际测试发现,这不重要。

作者测量了:用单个全局缩放因子量化真实表格,然后检查查询向量实际退化程度。答案是:几乎没有。聚合余弦相似度保持 0.9998。全局缩放因子仅将整个 29,528 token 词汇表中的两行(.a)置零——恰好是模型已经决定几乎不贡献的两个 token。这个机制是真的,但在这个具体模型上不起作用。

作者还是保留了每行缩放因子,因为它们只占用 4 MB 中的 118 KB,且是正确的,但更像是“廉价保险”而非“承重结构”。整个 int8 表(含每行缩放因子)与原始 float32 模型的余弦相似度为 0.999958。足以在浏览器中提供查找表而非模型。

WordPiece 分词器的 80 行 JavaScript 实现及其三个陷阱

浏览器已经有了 token 表,但还需要将用户输入的查询转换为 token id,与 Python 中使用的相同方式,这样才能查找正确的行。这意味着要在 JavaScript 中重新实现 BERT 的 WordPiece 分词器。大约 80 行代码,但有三个陷阱,每个都可能无声地破坏查询向量:

  1. 不要使用 [CLS]/[SEP]:BERT 分词器通常会在文本两端包裹特殊标记。tokenizer.json 配置中甚至有描述这些标记的部分。但 model2vec 不使用这些标记,而是以 add_special_tokens=False 调用分词器。如果添加了标记,就会把两个不应存在的向量平均进去。
  2. 未知 token 应删除而非嵌入:如果某个词不在词汇表中,model2vec 会从序列中完全删除它,而不是替换为 [UNK] 标记。若替换为 [UNK],则引入了一个错误的向量,会污染平均结果。
  3. 大小写和重音:需要确保预处理与原始训练时一致(例如小写化、去除重音等),否则会导致 token 匹配失败。

关键要点

  • 静态嵌入模型(model2vec) 是 transformer 的廉价替代品:仅需查表和平均,却能保留大部分检索质量(potion-base-8M 达到 MiniLM 的 81%)。
  • 分块策略至关重要:将长文档切分成较小块,可以保留稀有词的特征信号,避免被高频词淹没。
  • 模型隐藏的停用词机制:token 向量模长天然反映了词的重要性,最短的向量对应停用词。量化时虽需要每行缩放因子,但实际全局缩放也能达到极高的余弦相似度。
  • 量化可实现极致压缩
查看原文 →bart.degoe.de