最近在系统学习 Search 和 Information Retrieval 的知识。在看倒排索引(Inverted Index)的时候,我越看越觉得这个结构非常眼熟。直到我想起前段时间写 CS336 作业里 BPE(Byte-Pair Encoding)分词算法的优化逻辑,我才突然意识到:原来这些看似完全不同领域的系统,底层其实都在共享同一种 pattern。
很多人(包括我以前)在思考“如何优化系统性能”时,直觉总是“怎么把单次计算变得更快”。但这次的观察让我真切地体会到:很多高性能系统的优化,并不是想办法让一次计算本身快一点,而是重新组织数据,让绝大多数计算根本不要发生。
一个最笨的 Search#
假设我们现在有十亿篇 document,用户搜了一个词:“machine learning”。
如果不加思索,最直接、最 naive 的做法就是顺着数据结构遍历:
1results = []
2for doc in corpus:
3 if "machine" in doc and "learning" in doc:
4 results.append(doc)我以前看到这种代码,脑子里的第一反应往往是:怎么优化这个 if 检查?要不要用 SIMD 并行扫?要不要把字符串匹配换成更快的 KMP 或多模式匹配算法?
但对于真实的 Search 系统来说,这其实搞错了第一优先级。Retrieval 的第一问题根本不是如何把单次检查优化得更快,而是为什么我们一定要执行这么多次检查?
如果 corpus 规模是 10 亿,无论单次匹配多快,这种 global scan 的延迟都是不可接受的。
倒排索引:重写访问路径#
为了解决这个问题,Inverted Index 出现了。
简单来说,如果自然的数据表示是:
1Doc1 -> machine, learning, systems
2Doc2 -> deep, learning, systems
3Doc3 -> database, systems但 Query 到来以后,系统真正在 runtime 需要回答的问题并不是“这篇文档有哪些词”,而是“这个词在哪些文档里”。
所以我们把关系反过来,提前建立一个字典:
1machine -> Doc1
2learning -> Doc1, Doc2
3systems -> Doc1, Doc2, Doc3这就是倒排索引。名字叫“倒排”,其实就是把数据原本的嵌套关系给 invert 了。
有了这个结构,遇到“machine learning”的 query 时,我只需要做两次查表,拿出 Doc1 和 Doc1, Doc2 的列表做个交集。剩下的数以亿计的文档,连看都不用看。
这里藏着一个非常关键的 insight:Index 的价值往往不是让一次计算更快,而是改变数据访问路径,把 global scan 转换成 indexed lookup,使得大量计算根本不发生。
BPE 中的“历史重演”#
当时看到这里,我脑子里“叮”地一声,想起了 CS336 的作业。
在训练 LLM 的 tokenizer 时,BPE 算法天然的数据关系是这样的:
1word -> pairs
2比如 lower -> (l,o), (o,w), (w,e), (e,r)每次 BPE 决定 merge 一个最高频的 pair(比如 (e, r) -> er)时,我们需要更新统计信息。如果不维护额外的数据结构,代码就会写成:
1for word in vocabulary:
2 if target_pair in word:
3 update(word)
4 update_related_pair_statistics(word)这和刚才的最笨的 Search 如出一辙。由于 BPE 需要 merge 成千上万次,如果每次 merge 都去扫描整个 vocabulary 找含有这个 pair 的 word,整个过程会极其缓慢。
所以当时的优化手段是,维护一个反向映射:
1pair -> words containing this pair于是每次 merge 时,工作流变成了:
1merge pair
2 ↓
3查找 pair_to_words[pair]
4 ↓
5只更新受到影响的 words
6 ↓
7顺带更新相关的 pair statistics发现了吗?这和 Search 里的 Inverted Index 在思想上高度类似:
- Search 把
document -> terms倒转成term -> documents; - BPE 把
word -> pairs倒转成pair -> words。
它们的共同目的都是:给定一个局部条件,快速找到受到影响的全体对象。 只要手里有这个 reverse mapping,系统就可以精准打击,而不是像无头苍蝇一样遍历全局。
增量计算与反向依赖跟踪#
虽然思想类似,但它们面临的工程侧重点还是有区别的。
Search 的 Inverted Index 通常面临的是 read-heavy 的场景。Query 到来后,系统主要利用倒排表进行高并发的只读检索。
而 BPE 中的 pair -> words 则是处于一种动态变化的场景中:每次 merge 之后,某些 word 会改变(比如 lower 变成了 low + er),随之而来的是大量 pair 的消失与新生,这个反向映射表本身也需要实时维护。
因此,BPE 里的这个结构,除了一种“查找索引”,更带有一种增量计算(incremental computation)与反向依赖跟踪的意味。系统可以被抽象成这样:
1local change -> reverse dependency -> affected objects -> incremental update要做到高效的增量计算,关键就在于快速回答:“什么改变了?”以及“谁依赖它?”。BPE 的 pair -> words 完美地解答了后者。
系统的共同底色#
如果把视角再稍微拉高一点,你会发现维护这种反向关系,是一种非常普遍的 systems pattern:
- Database:
key -> rows,就是最基础的数据库索引。 - Build System:
source file -> dependent targets,改了一个 C++ 头文件,系统是怎么知道只需重新编译哪几个.o文件的?因为维护了依赖图。 - Spreadsheet:表格里改了一个单元格,不需要重算整个表格,因为它知道哪些下游单元格依赖了它。
正向关系适合用来“描述数据(Data Representation)”,而反向关系才是用来“回答查询(Runtime Query)”的利器。如果系统经常需要回答:“给定甲,哪些乙与它相关或被它影响?”,那么果断建立映射,通常比死磕“如何让单次扫描快一点”要靠谱得多。
回到 Search 与 Dense Retrieval#
为什么 Search 会把这个问题放大到极致?
因为在真实世界中,候选集(candidate space)实在太大了,而我们在每个 query 上分配的 latency 和 compute budget 是极其有限的。这就导致了现代搜索以及推荐系统必然形成一个漏斗形架构:
1巨大 Corpus -> Retrieval (召回) -> 粗排 -> 精排因为精排用的相关性模型往往非常厚重。如果是传统的相关性计算,可能要用到复杂的算分逻辑;如果是现代的机器学习排序,甚至会搬出巨大的大模型进行推理。我绝对不能把这种昂贵的计算作用于全体候选集。
更有意思的是,即使到了深度学习时代,传统 Search 的 keyword representation 变成了 Dense Retrieval 的 embedding 表示,这个核心矛盾依然存在。
在 Dense Retrieval 里:
- Query 变成了一个高维向量 \( \mathbf{q} \)
- Document 变成了高维向量 \( \mathbf{d_i} \)
表面上 paradigm 完全变了。但问题马上就出现了:如果你有 10 亿篇 document,面对一个 query,你能在几十毫秒内做完 10 亿次点积 \( \mathbf{q} \cdot \mathbf{d_i} \) 吗?
显然不行。所以系统又一次逼迫我们去寻找“避免扫描全集”的方法——于是 Approximate Nearest Neighbor (ANN) 和各种 Vector Index(比如 HNSW)诞生了。
它们用图结构、树结构或者哈希桶的方式,把高维空间切割或组织起来。遇到 Query 时,只在这个结构上沿着最可能相似的路径走,只对几十上百个候选向量做真实的点积计算,从而直接跳过剩下的几亿个向量。
所以,传统的 Inverted Index 和现代的 Vector Index,在具体的数据结构和数学实现上可以说是天壤之别,但从 Systems 视角来看,它们都在回答同一个拷问灵魂的问题:
How do we avoid looking at everything?
技术会不断变化:倒排索引、向量索引、ANN 的具体实现可能完全不同。但那个问题一直没变:我们能不能设计一个更好的数据结构,让系统不必看完所有东西?