Eight years ago I added client-side search to this blog with Lunr.js. It creates an inverted index at build time, ships it as JSON, and matches strings in your browser. No server-side engine required. It has worked fine ever since, in the sense that it finds a post if you type a word that is actually in it.八年前,我通过 Lunr.js 为这个博客添加了客户端搜索功能。它在构建时创建一个倒排索引,将其作为 JSON 文件发布,并在浏览器中进行字符串匹配。无需任何服务器端引擎。从那时起,它一直运行良好——前提是你输入的词确实存在于文章中。
Earlier this year I wrote a semantic search engine in ±250 lines of Python (the kind that lets you find the “London Beer Flood” when you search for “alcoholic beverage disaster in England,” because it understands that beer is alcoholic and a flood is a disaster). That one needs a machine with sentence-transformers installed and a few hundred megabytes of PyTorch; to serve something like that in a production server requires beefy machines with expensive RAM. Not something you run in a browser tab.今年早些时候,我用约 250 行 Python 代码编写了一个语义搜索引擎(那种当你搜索“英国酒精饮料灾难”时能找到“伦敦啤酒洪水”的引擎,因为它理解啤酒是酒精饮料,而洪水是灾难)。该引擎需要安装 sentence-transformers 且拥有几百兆内存的 PyTorch 环境;要在生产服务器上运行此类程序,需要配备昂贵内存的高性能机器。这显然不是能在浏览器标签页中运行的东西。
So this blog had keyword search that runs anywhere and understands nothing, but no semantic search that understands things but can’t run anywhere near a static site. This post is about closing that gap: semantic search that runs entirely in your browser, with no server and no API, where the entire model is essentially a 4 MB lookup table. You can try all three of the models I benchmarked, further down, running live on your own hardware (I can’t afford GPU clusters).因此,这个博客之前只有随处可运行但“不求甚解”的关键词搜索,而没有既能理解语义又能在静态网站上运行的搜索功能。这篇文章旨在弥补这一差距:实现完全在浏览器中运行的语义搜索,无需服务器,无需 API,整个模型本质上就是一个 4 MB 的查找表。你可以在下方亲自体验我基准测试过的全部三种模型,它们直接在你的硬件上运行(我可买不起 GPU 集群)。
Doing this surfaced a couple things, chief among which that the keyword search had a bug for a couple of years. TUrns out that doing proper evals is important.做这件事的过程中发现了一些问题,其中最主要的是关键词搜索功能在过去几年里一直存在一个 Bug。事实证明,进行正确的评估非常重要。
The obvious approach costs 23 megabytes显而易见的方案成本高达 23 兆字节#
The models that power the Python post (sentence-transformers like all-MiniLM-L6-v2) can, in fact, run in a browser. Transformers.js will download an ONNX build of one and run it on WebAssembly. I benchmarked it, and on my laptop, the quantized model plus its runtime is 23.45 MB over the wire, takes about two seconds to load and become available, and then embeds a query in ±18 ms.驱动那篇 Python 文章的模型(如 all-MiniLM-L6-v2 等 sentence-transformers 模型)实际上可以在浏览器中运行。Transformers.js 可以下载其 ONNX 构建版本,并在 WebAssembly 上运行。我对其进行了基准测试,在我的笔记本电脑上,量化后的模型及其运行时环境通过网络传输需 23.45 MB,加载并可用大约需要两秒,之后嵌入查询只需约 18 毫秒。
Twenty-three megabytes is a bit rich to embed a search box query, for a blog that has 14 posts. That is roughly a dozen high-resolution photos worth of bytes to download so we can have fancy search. It works, and for some applications it is completely worth it, but it is not something I want to inflict on someone who clicked through to read about bloom filters, especially not on a mobile connection.对于一个只有 14 篇文章的博客来说,为了嵌入一个搜索框查询而消耗 23 MB 流量实在有点奢侈。这相当于下载了十几张高分辨率照片,只为了实现一个花哨的搜索功能。它确实可行,在某些应用场景下也完全值得,但我不想强迫那些点击进来阅读关于布隆过滤器文章的用户承受这些,尤其是在移动网络连接下。
The thing is, you do not need a transformer to embed a search query. You need a vector that’s good enough, and there is a much cheaper way to get one.问题在于,你并不需要一个 Transformer 模型来嵌入搜索查询。你只需要一个足够好的向量,而获取向量的方法有很多更廉价的选择。
A static embedding model is a lookup table静态嵌入模型就是一个查找表#
The trick is a family of models called model2vec (the specific ones are named “potion”). They are distilled from a real sentence-transformer, but the result is not a neural network. It is a table.秘诀在于一个名为 model2vec 的模型系列(具体型号称为“potion”)。它们是从真实的 sentence-transformer 模型中蒸馏出来的,但结果并非神经网络,而是一个表格。
Here is the model’s entire forward pass. Not a simplification — the whole thing, from the library’s source:这是该模型完整的前向传播过程。这不是简化版——这就是从库源码中提取的全部内容:
ids = tokenize(text) # split into subword token ids
rows = embedding[ids] # look up one vector per token
vector = rows.mean(axis=0) # average them
vector = vector / norm(vector) # normalise to unit length
That is it. Tokenize, look up a row per token, average, normalize. There is no attention, no layers, no inference. “Running the model” is a handful of array lookups and an average. For potion-base-8M, the table is 29,528 tokens by 256 dimensions of float32 (about 30 MB) and it reaches 81% of MiniLM’s retrieval quality while being, definitionally, a dictionary lookup.就是这样。分词、按 Token 查找对应的行、求平均值、归一化。没有注意力机制,没有层,没有推理过程。“运行模型”就是几次数组查找和一次平均值计算。对于 potion-base-8M,该表包含 29,528 个 Token,每个 Token 对应 256 维的 float32(约 30 MB),它能达到 MiniLM 81% 的检索质量,且本质上就是一个字典查找。
Thirty megabytes is still too much, but a table is a much friendlier thing to shrink than a transformer.30 MB 依然太大,但相比 Transformer,表格更容易压缩。
Building the index: chunking, and a cache that never earns its keep构建索引:分块,以及一个毫无用处的缓存#
Embeddings are generated at build time. On my laptop, whenever I run hugo, a Python script walks through the posts, strips out the front matter and the code blocks, splits each post into overlapping 600-character-ish chunks, embeds every chunk, and writes the vectors to a file the browser downloads when a user clicks the search input box.嵌入向量是在构建时生成的。在我的笔记本电脑上,每当我运行 Hugo 时,一个 Python 脚本就会遍历所有文章,剔除 Front Matter 和代码块,将每篇文章拆分为约 600 字符的重叠块,对每个块进行嵌入,并将向量写入文件。当用户点击搜索输入框时,浏览器会下载这些向量。
The chunking matters more than I expected, because of that averaging step. A static embedding is the mean of its token vectors, and if you average an entire 15,000-character post into one vector, you get something that points at “generic English prose about software” and not much else. The rare, distinctive words (terms like pydub or mmh3) get drowned out by the hundreds of ordinary words around them. Chopping the post into smaller chunks helps keep those signals sharp. I’ll come back to this, because it turns out to be the key to why some models beat others.分块的重要性超出了我的预期,这归因于求平均值的那一步。静态嵌入是其 Token 向量的平均值;如果你将整篇 15,000 字的文章平均成一个向量,你得到的只会是“关于软件的通用英语散文”,而不会有其他信息。那些稀有且独特的词汇(如 pydub 或 mmh3)会被周围数百个普通词汇淹没。将文章切成小块有助于保持这些信号的清晰度。稍后我会回到这一点,因为事实证明这是某些模型优于其他模型的关键所在。
I also built an embedding cache, keyed on a hash of each chunk’s text, so that re-embedding only touches chunks that changed. This turned out to be a waste of time and tokens. Embedding every chunk of this blog is a few hundred lookups and an average; it takes about ten milliseconds. I built a cache to speed up an operation that is already pretty instantaneous (turns out 14 blog posts is not a lot of data). It would matter more for the MiniLM model, where embedding is a real neural network forward pass, but I’m not shipping that one. So the cache sits there, correct and pointless, and I’ve left it in as a reminder that overengineering is easy.我还构建了一个嵌入缓存,以每个块文本的哈希值为键,这样只有发生变化的块才会被重新嵌入。结果证明这既浪费时间又浪费 Token。为这个博客的所有块进行嵌入只需几百次查找和一次平均计算;耗时约 10 毫秒。我构建缓存是为了加速一个本就瞬间完成的操作(毕竟 14 篇博客文章的数据量并不大)。对于 MiniLM 模型,由于嵌入过程涉及真正的神经网络前向传播,缓存会更有意义,但我并不打算发布那个模型。所以这个缓存就留在那儿,既正确又毫无意义,我把它留着作为一种提醒:过度工程化是很容易的。
Shrinking the table: the model’s stopword list is hiding in plain sight缩小表格:模型的停用词列表就隐藏在显眼处#
The table is 30 MB because it is float32. Quantizing it to int8 makes it a quarter of the size. The catch is that you cannot just clip everything to the same scale, and understanding why was one of my favorite things I learned building this.该表之所以达到 30 MB,是因为它采用了 float32 格式。将其量化为 int8 可以将其大小缩减为原来的四分之一。难点在于你不能简单地将所有数据裁剪到同一个尺度,理解其中的原因是我在构建此项目时学到的最有趣的事情之一。
Look at what the row magnitudes actually are. If you sort every token in potion-base-8M by the length of its vector, the shortest vectors (i.e. the ones closest to zero) are:看看这些行的模长实际是多少。如果你按向量长度对 potion-base-8M 中的每个 Token 进行排序,最短的向量(即最接近零的向量)是:
a . , - ) the to and of in
And the longest are:而最长的向量是:
turkmenistan seychelles guantanamo hemingway vanuatu
This is not a coincidence and it isn’t noise either. The model’s stopword list is its row magnitudes. When you average token vectors together, a word with a tiny vector barely moves the result, and a word with a big vector dominates it. The model has learned, with no stopword list and no special-casing, that a word like “the” should contribute almost nothing and a word like “guantanamo” should contribute a lot. It’s kinda beautiful, and it’s sitting right there in the geometry.这并非巧合,也不是噪声。该模型的停用词列表就是其行模长。当你将 Token 向量平均在一起时,模长极小的词几乎不会改变结果,而模长巨大的词则会占据主导地位。该模型在没有停用词列表和特殊处理的情况下,已经学会了像“the”这样的词应该几乎不产生贡献,而像“guantanamo”这样的词应该产生很大贡献。这非常精妙,并且直接体现在几何结构中。
Which is why quantization needs a per row scale: each token gets its own float32 multiplier, so that the relative magnitudes survive being crushed into int8. However, turns out that it didn’t matter much anyway.这就是为什么量化需要按行缩放:每个 Token 都有自己的 float32 乘数,这样在压缩为 int8 时,相对大小关系得以保留。不过,事实证明这其实没那么重要。
I measured it; I quantized the real table with a single global scale and checked how much it actually degraded the query vectors. The answer was: almost nothing. Aggregate cosine similarity against the original stayed at 0.9998. A global scale zeroes out exactly two rows in the entire 29,528-token vocabulary (. and a) which are precisely the two tokens the model had already decided contribute pretty much nothing. The mechanism is real; it just doesn’t matter for this particular model.我测量过了;我用一个全局统一的尺度量化了原始表格,并检查了它对查询向量的实际损害程度。结果是:几乎没有。与原始向量的聚合余弦相似度保持在 0.9998。全局尺度将 29,528 个 Token 词汇表中仅有的两个 Token(“.”和“a”)归零,而这两个 Token 正是模型已经判定几乎不产生贡献的词。这个机制是存在的,只是对于这个特定模型来说并不重要。
I kept the per-row scales anyway, because they cost 118 KB out of 4 MB and they’re correct, but more like “cheap insurance” than “load-bearing”. The whole int8 table, per-row scales and all, reproduces the original float32 model to a cosine of 0.999958. Good enough to ship a lookup table in the browser rather than a model.我还是保留了按行缩放,因为它们在 4 MB 的空间里只占用了 118 KB,而且这样做更严谨,更像是“廉价的保险”而非“承重墙”。整个 int8 表格(包含按行缩放因子)对原始 float32 模型的余弦相似度还原达到了 0.999958。这足以在浏览器中发布一个查找表,而不是一个模型。
WordPiece in eighty lines, and its three gotchas八十行代码实现 WordPiece,以及它的三个陷阱#
The browser has the token table, but it still has to turn your typed query into token ids the same way we did in Python, so we can look up the right rows. That means reimplementing the BERT WordPiece tokenizer in JavaScript. It’s about eighty lines, and it has three gotchas that could each silently poison query vector:浏览器拥有 Token 表,但它仍然需要像我们在 Python 中那样将你输入的查询转换为 Token ID,以便我们查找对应的行。这意味着要在 JavaScript 中重新实现 BERT 的 WordPiece 分词器。这大约需要 80 行代码,且有三个陷阱,每一个都可能悄悄破坏查询向量:
No
[CLS]/[SEP]. BERT tokenizers normally wrap your text in special marker tokens. Thetokenizer.jsonconfig even has a section describing how.model2vecdoesn’t use it, but calls the tokenizer withadd_special_tokens=Falseinstead. Add the markers and you’re averaging in two vectors that shouldn’t be there.没有 [CLS]/[SEP]。BERT 分词器通常会在文本前后加上特殊的标记 Token。tokenizer.json 配置甚至有一节描述了如何操作。model2vec 不使用它,而是通过 add_special_tokens=False 调用分词器。如果加上这些标记,你就会把本不该存在的两个向量平均进去。Unknown tokens are deleted, not embedded. If a word isn’t in the vocabulary,
model2vecdrops it from the sequence entirely rather than substituting an[UNK]vector. So a query made entirely of gibberish produces an empty token list and a zero vector, and you have to handle that instead of dividing by zero. (In practice this is nearly unreachable; with a 29,528-token vocabulary, every single character is in the vocabulary, so the only way to trigger it is a word longer than 100 characters.)未知 Token 会被删除而非嵌入。如果一个词不在词汇表中,model2vec 会直接将其从序列中剔除,而不是替换为 [UNK] 向量。因此,如果查询全是乱码,会产生一个空的 Token 列表和一个零向量,你必须处理这种情况,而不是执行除以零的操作。(实际上这几乎不可能发生;在 29,528 个 Token 的词汇表中,每一个字符都在词汇表内,所以触发此问题的唯一方法是输入超过 100 个字符的单词。)"strip_accents": nullmeans accents are stripped. This one is a little nasty. The config saysstrip_accentsis null, which reads like “off.” But in HuggingFace’s tokenizer library, a null value inherits from thelowercasesetting, which is on. Socafébecomescafe. If we’d copy the config literally, every accented query drifts."strip_accents": null 意味着重音会被去除。这一点很讨厌。配置中显示 strip_accents 为 null,看起来像是“关闭”。但在 HuggingFace 的分词器库中,null 值会继承 lowercase 设置,而后者是开启的。所以 café 会变成 cafe。如果我们照搬配置,每个带重音的查询都会产生偏差。
Some frustrations and a bunch of Claude generated test strings ( pydub, café naïve, C++ vs C#, 日本語のみ) later, I’m reasonably confident the JS tokenizer matches the BERT WordPiece tokenizer.经过一番挫折和 Claude 生成的一堆测试字符串(pydub, café naïve, C++ vs C#, 日本語のみ)后,我相当确信这个 JS 分词器与 BERT 的 WordPiece 分词器是一致的。
Search is a few hundred dot products搜索就是几百次点积运算#
With the query embedded, search is almost anticlimactic. There are a few hundred chunk vectors (one per chunk of every post). Computing the cosine similarity against all of them is a few hundred dot products of 128 numbers each, tens of thousands of multiply-adds, which a browser on a reasonably modern machine does in a fraction of a millisecond. Then we group the chunks by post, take each post’s best-scoring chunk, and sort. On my machine (M1 MacBook Air) the entire query (tokenize, embed, score every chunk, rank) takes about 0.4 milliseconds.完成查询嵌入后,搜索过程几乎平淡无奇。这里有几百个块向量(每篇文章的每个块对应一个)。计算它们与所有块的余弦相似度就是几百次 128 维向量的点积,即数万次乘加运算,现代机器上的浏览器在几分之一毫秒内就能完成。然后我们将块按文章分组,取每篇文章得分最高的块,并进行排序。在我的机器(M1 MacBook Air)上,整个查询过程(分词、嵌入、为每个块评分、排名)大约耗时 0.4 毫秒。
In a production setting, this is where you reach for approximate-nearest-neighbour indexes (HNSW and friends), and if you have a bazillion documents you should. With a few hundred, an ANN index would be slower than the brute-force loop and much larger on disk. It’s worth saying out loud because “vector search” in this case isn’t a complex database system,but just a for loop.在生产环境中,这通常是你需要使用近似最近邻索引(HNSW 等)的地方,如果你有海量文档,确实应该这样做。但对于几百个文档,ANN 索引比暴力循环更慢,且在磁盘上占用更多空间。值得大声说出来的是,在这种情况下,“向量搜索”并不是一个复杂的数据库系统,而仅仅是一个 for 循环。
There’s one wrinkle worth knowing if you build one of these. The document vectors are stored as int8. Cosine similarity is invariant to a positive scale, and the document matrix uses one global scale, so the browser can dot a float32 query straight against the raw int8 bytes and get the right ranking without ever un-quantizing them. But int8 × int8 in JavaScript overflows silently; a dot product whose true value is three million comes back as -64, no error, no warning. So it’s better to accumulate into a regular float. I mention it because this was a fun one to debug1.如果你要构建此类程序,有一个细节值得了解。文档向量存储为 int8 类型。余弦相似度对正向缩放是不变的,文档矩阵使用一个全局缩放因子,因此浏览器可以直接将 float32 查询向量与原始 int8 字节进行点积,从而获得正确的排名,而无需进行反量化。但在 JavaScript 中,int8 × int8 会静默溢出;一个真实值为三百万的点积结果可能会变成 -64,没有错误,没有警告。因此,最好累加到常规浮点数中。我提到这一点是因为调试这个过程很有趣¹。
Two search engines that are each blind in a different way两个各有所长的盲人搜索引擎#
Before adding semantic search, I figured I’d do the responsible thing and measured how bad the existing keyword search actually was, so I’d have a baseline to beat. I had Claude create a set of thirty test queries and split them into three kinds:在添加语义搜索之前,我想我应该负责任地衡量一下现有的关键词搜索到底有多糟糕,这样我才有超越的基准。我让 Claude 创建了 30 个测试查询,并将它们分为三类:
- Exact tokens:
pydub,lunr,mmh3,papermod. Words that literally appear in a post.精确 Token:pydub, lunr, mmh3, papermod。字面上出现在文章中的词。 - Paraphrases: “find documents by what they mean instead of which words they contain.” Concepts, in different words than the post uses.释义:例如“通过含义而非包含的词来查找文档”。这是用与文章不同的词汇表达的概念。
- Navigational: “how do I add search to a static site.” Broad intent.导航类:例如“如何为静态网站添加搜索”。广泛的意图。
Keyword search would surely ace the exact tokens and fail the paraphrases. Then I ran it, and it failed pydub.关键词搜索肯定能在精确 Token 上表现出色,而在释义上失败。结果我运行了一下,它连 pydub 都没找到。
pydub is a Python library I’ve used in a previous post. The word is right there in the text-to-speech post. And Fuse.js, the fuzzy-search library this blog uses, returned nothing. It turned out my configuration told Fuse to only score matches near the start of a field; a setting called location, with a distance window of 1000 characters. pydub first appears about 3,700 characters into that post, well outside the window, so as far as my search box was concerned it did not exist. The same was true for mmh3, and for any distinctive word that happened to appear deep in a long post. The words that did work only worked because they were in a title.pydub 是我在之前文章中使用过的一个 Python 库。这个词就在那篇语音合成文章的文本中。而这个博客使用的模糊搜索库 Fuse.js 却什么也没返回。原来我的配置告诉 Fuse 仅对字段开头附近的匹配项进行评分;这是一个名为 location 的设置,距离窗口为 1000 个字符。pydub 第一次出现在那篇文章约 3,700 字符处,远在窗口之外,所以对于我的搜索框来说,它不存在。mmh3 以及任何出现在长文章深处的独特词汇也是如此。那些能搜到的词仅仅是因为它们出现在标题中。
My keyword search had been quietly broken for years, and I only found out because I was about to benchmark against it. Derp.我的关键词搜索已经悄悄坏了几年,而我只有在准备进行基准测试时才发现。真是笨。
And the two engines turn out to be blind in exactly complementary ways:结果发现这两个引擎在盲点上正好互补:
| keyword finds it | semantic finds it | |
|---|---|---|
pydub (exact token, out of vocabulary) | ✅ | ❌ |
| “find documents by what they mean” (paraphrase) | ❌ | ✅ |
Keyword search is perfect on the exact tokens and returns literally nothing for all ten paraphrases; not bad results, just zero results. Semantic search is the mirror image: it nails the paraphrases and stumbles on the proper nouns, because pydub isn’t in its vocabulary and gets shattered into meaningless subword fragments. Neither is better. They fail on disjoint sets.关键词搜索在精确 Token 上表现完美,但对所有十个释义查询字面上返回了零结果;不是结果不好,而是零结果。语义搜索则恰恰相反:它完美匹配了释义,却在专有名词上跌跌撞撞,因为 pydub 不在它的词汇表中,被拆分成了毫无意义的子词片段。没有谁更好,它们在不同的集合上失效。
Reciprocal Rank Fusion, or: how to merge different search result sets倒数排名融合(Reciprocal Rank Fusion),或者:如何合并不同的搜索结果集#
If each engine catches what the other misses, you want both. The problem is combining them. Keyword search returns a fuzzy-match distance; vector search returns a cosine similarity. These live on different scales that mean different things, and averaging them is meaningless; it’d be like adding a temperature to a weight.如果每个引擎都能捕捉到对方遗漏的内容,你自然希望两者兼得。问题在于如何结合它们。关键词搜索返回模糊匹配距离;向量搜索返回余弦相似度。它们处于不同的尺度,含义也不同,将它们平均化毫无意义;这就像将温度与重量相加一样。
The answer here is Reciprocal Rank Fusion. Run queries against both indices, throw away the scores entirely and keep only the ranks. Each document’s “fused score” is the sum, over both engines, of 1 / (k + rank), with k conventionally 60. A document ranked first by one engine and unranked by the other scores 1/61. A document ranked third by both scores 1/63 + 1/63, which is larger. The k flattens the top of the curve so that “both engines quite liked this” beats “one engine loved it”, which is exactly what you want when one engine is blind to paraphrase and the other to proper nouns. It’s about ten lines of code, it has a single parameter with a sensible default, and it never has to compare the two incomparable scores. When you search this blog now, that’s what runs: keyword and semantic, fused by rank. This is easily extensible to many sources of ranked lists.答案是倒数排名融合(Reciprocal Rank Fusion)。对两个索引运行查询,完全抛弃分数,只保留排名。每个文档的“融合分数”是两个引擎中 1 / (k + rank) 的总和,k 通常取 60。一个被一个引擎排在第一、另一个引擎未排名的文档,得分为 1/61。一个被两个引擎都排在第三的文档,得分为 1/63 + 1/63,分数更高。k 值拉平了曲线的顶部,使得“两个引擎都比较喜欢”的结果胜过“一个引擎极度喜爱”的结果,这正是当一个引擎对释义盲目、另一个对专有名词盲目时你所需要的。这只需要大约十行代码,有一个带有合理默认值的参数,且永远不需要比较两个不可比的分数。现在当你搜索这个博客时,运行的就是这个:关键词和语义搜索,按排名融合。这很容易扩展到多个排名列表源。
The benchmark: it’ll have to run on your hardware基准测试:它必须在你的硬件上运行#
I benchmarked three query encoders: the model2vec lookup table I’ve been describing, MiniLM q8 through transformers.js, and ternlight, a clever BitNet-style ternary model compiled to WebAssembly that originally sent me down this whole path.我基准测试了三种查询编码器:我一直在描述的 model2vec 查找表、通过 transformers.js 运行的 MiniLM q8,以及 ternlight——一种精巧的 BitNet 风格三元模型,它被编译为 WebAssembly,最初让我走上了这条路。
The widget below runs all three in your browser, on your hardware, because that’s what matters for this anyway. Each has its own Run button because one of them downloads 23 MB and you should get to decide whether you want to pay that on your connection. The download sizes are constants I measured (your browser can’t see the size of a cross-origin download that doesn’t send the right header); the timings are live on your device.下面的小部件在你的浏览器中运行所有这三种模型,直接在你的硬件上运行,因为这才是最重要的。每个模型都有自己的“运行”按钮,因为其中一个需要下载 23 MB,你应该有权决定是否愿意在你的连接上支付这部分流量。下载大小是我测量的常量(你的浏览器无法看到跨域下载且未发送正确头信息的文件大小);时间是你设备上的实时数据。
| Arm | Run | Download (measured constant) |
Ready (live) |
Embed (live, median of 10) |
Search (live) |
Top 3 posts (live) |
|---|---|---|---|---|---|---|
| potion (ours) | 4.21 MB | — | — | — | — | |
| MiniLM q8 | 23.45 MB | — | — | — | — | |
| ternlight base | 7.17 MB | — | — | — | — |
Corpus frozen at 13 posts, 2026-07-10. Download sizes are measured constants; times are live on your device.语料库冻结于 2026-07-10,共 13 篇文章。下载大小为测量常量;时间为设备上的实时数据。
On my laptop the story is pretty clear: the lookup table is 4.2 MB and embeds a query in a third of a millisecond; MiniLM is 23.5 MB and 18 milliseconds; ternlight sits in between. And in retrieval quality, measured over those thirty labeled queries, the lookup table scores just as well as MiniLM. Just as well. At a fifth of the size and roughly fifty times the speed.在我的笔记本电脑上,结果很明确:查找表大小为 4.2 MB,嵌入查询耗时 0.3 毫秒;MiniLM 大小为 23.5 MB,耗时 18 毫秒;ternlight 介于两者之间。在检索质量方面,基于那 30 个标注查询的测试显示,查找表的得分与 MiniLM 一样好。确实一样好。体积只有五分之一,速度快了大约五十倍。
Caveat on that claim, though; thirty queries is a small sample and I nearly fooled myself with it. Twice.不过,对于这一结论需要保留意见;30 个查询只是一个小样本,我差点被它骗了。两次。
The metric couldn’t see anything指标什么也看不见#
Initially, the headline metric was recall@3: did a relevant post make the top three? On a thirteen-post corpus, “top three of thirteen” is not a very high bar (random guessing clears it about a quarter of the time) and it turned out that almost every model cleared it on almost every query. Five different configurations tied at exactly 0.978. Recall@3 could not tell a 4 MB lookup table apart from a 23 MB transformer.最初,主要指标是 recall@3:相关文章是否进入了前三名?在 13 篇文章的语料库中,“13 选 3”并不是很高的门槛(随机猜测大约有四分之一的概率命中),结果几乎每个模型在每个查询上都达标了。五种不同的配置在 0.978 的得分上打平。Recall@3 无法区分 4 MB 的查找表和 23 MB 的 Transformer。
Worse, it produced confident nonsense. Pure semantic search scored a perfect 1.000 on the exact-token queries; it apparently found pydub every time. Except it didn’t:更糟糕的是,它产生了自信的废话。纯语义搜索在精确 Token 查询上获得了 1.000 的完美分数;它似乎每次都能找到 pydub。但事实并非如此:
pydub → 1. Free SSL on GitHub Pages (wrong)
2. Let's Encrypt on GitHub Pages (wrong)
3. Text-to-Speech with pydub (correct)
The right post is third, behind two completely unrelated ones, and recall@3 scored that as a hit. The metric was rewarding the model for landing the right answer in a bucket wide enough that landing it there meant almost nothing. Switching to recall@1 (“is the first result correct?”) separated the models more cleanly and told a different story. Lesson learned: a metric is worthless if the metric can’t see the thing you care about. I only caught it because the results looked too good.正确的文章排在第三位,落后于两篇完全无关的文章,而 recall@3 将其记为命中。该指标奖励模型将正确答案放入一个足够宽的桶中,而落入桶中几乎没有任何意义。切换到 recall@1(“第一个结果是否正确?”)能更清晰地分离模型,并讲述了一个不同的故事。教训:如果指标无法看到你关心的东西,那么它就是毫无价值的。我之所以发现这一点,只是因为结果看起来太好了。
What shipped, and how to steal it发布了什么,以及如何借鉴#
The search box on the homepage now runs keyword, semantic, and hybrid search, with a toggle so you can compare and watch them disagree. Type pydub and flip to semantic mode to see it get the answer wrong; flip to hybrid to see it get it right again. The whole thing is a 4 MB lookup table, a tiny document index, and about 300 lines of dependency-free JavaScript, lazy-loaded only when you focus the search box so the page itself pays nothing.主页上的搜索框现在运行关键词、语义和混合搜索,并配有一个切换开关,方便你进行比较并观察它们的分歧。输入 pydub 并切换到语义模式,你会看到它给出了错误答案;切换到混合模式,它又变回了正确结果。整个系统包含一个 4 MB 的查找表、一个微小的文档索引和约 300 行无依赖的 JavaScript 代码,仅在聚焦搜索框时才懒加载,因此页面本身无需承担任何成本。
The build pipeline (the chunking, the quantization, the eval harness, all of it) is a Python package you can point at your own site:构建流水线(分块、量化、评估工具等所有内容)是一个 Python 包,你可以将其指向你自己的网站:
pip install static-site-search-eval
sss-eval build --corpus content/post --outdir static/search \
--model minishlab/potion-base-8M --dims 128 --chunk-size 600
The code is on GitHub, the thirty eval queries are in there too so you can try it out for yourself. If you run the benchmark above on a phone over cellular, you’ll likely feel the 23 MB, and understand why the lookup table was worth the trouble.代码在 GitHub 上,那 30 个评估查询也在里面,你可以亲自尝试。如果你在手机蜂窝网络上运行上面的基准测试,你可能会感受到 23 MB 的分量,并理解为什么这个查找表值得花费这些精力。
All time classic https://www.destroyallsoftware.com/talks/wat ↩︎永恒的经典 https://www.destroyallsoftware.com/talks/wat ↩︎