Skip to main content
  1. Posts/

从倒排索引到 BPE:高性能系统为什么总在避免扫描全集

·2540 words·6 mins
Table of Contents
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

最近在系统学习 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 时,我只需要做两次查表,拿出 Doc1Doc1, 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 
23查找 pair_to_words[pair]
45只更新受到影响的 words
67顺带更新相关的 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:

  • Databasekey -> rows,就是最基础的数据库索引。
  • Build Systemsource 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 的具体实现可能完全不同。但那个问题一直没变:我们能不能设计一个更好的数据结构,让系统不必看完所有东西?

Related

模型压缩之后,为什么推理反而变慢了:一次 CPU Serving 的性能优化实践

·2821 words·6 mins
之前我负责过一个推荐系统深度学习 Serving 框架的功能开发与性能优化。 我们的系统主要跑在 CPU 上,以 TensorFlow 为主力 inference engine。随着模型迭代,尤其是 Embedding 层越来越大,模型导出、传输和上线变得非常慢,Serving 节点的内存成本也眼看着往上飙。