找得快 — 每一样都在拿召回换速度
这一章讲三件事: 默认那套取法为什么会随着库变大而成正比地变慢; 三大类加速手段各自省掉了哪一步计算;以及它们省下来的时间,是从哪里扣走的。 它在全书链条里的位置: 第 06 章管「取得对」,这一章管「取得快」—— 两章拆的是同一件事的两半。 而这一章是全书错误最密集的一章: 它挂着「近似」的招牌,演示的却是一点都不近似的做法。
1. 顶层全景
一个问题变成一串数
│
▼
┌─────────────────────────────────────────────┐
│ 默认做法:和库里每一条各算一次距离 │
│ 库有 1000 条就算 1000 次,一条不漏 │
└─────────────────────────────────────────────┘
│
│ 库涨到一百万条就要算一百万次 —— 这条路走不下去
▼
三类偷懒法,各省一样东西:
├─ 少比几条 :先把库分成一堆一堆,只搜最近的那几堆
├─ 跳着比 :建一张点与点之间的路网,顺着往近处走
└─ 比得便宜 :把每条向量压成几个字节,用查表代替算距离
│
▼
省下来的时间从哪来?—— 从「本该找到的,这次没找到」里来
图说:上面三行不是三种技术流派,是同一笔买卖的三种付法。 卖的都是同一样东西:「找全」。 这一章的全部内容就是这笔买卖的账单。
一句话链条: 逐条算 → 库一大就撑不住 → 于是想办法不算全部 → 不算全部就意味着可能漏掉 → 漏掉多少,才是这些手段真正的价格。
2. 先看现象:默认的做法是逐条算,一条不漏
先给上一章那个动作补个正式名字。把问题变成一串数、再在库里找出离它最近的那几条 —— 这件事叫向量检索,是你在任何一家向量库的文档里都会撞见的说法。
这一章要找的东西一直没变:离查询这一点最近的那几条,行话叫最近邻。
书在这一章的第一个配方,标题写的正是「近似最近邻索引」。它先造了一个库1:
库: 1000 条数据,每条 64 个数
查询: 5 条,每条要前 5 名
索引: faiss.IndexFlatL2
图说:`Flat`(平铺)的意思是「向量原样摆着,不做任何预处理」。
查一次 = 拿查询和这 1000 条各算一次距离,把最小的 5 个挑出来。
程序印出来的第一行是 Is trained: True,第二行是 Total vectors indexed: 10002。
25 个距离全挤在一起
它印出的 5 条查询 × 5 个名次,一共 25 个距离,全部落在 5.6875 到 7.9800 之间 —— 最远的那个只比最近的那个大四成3。
第 1 条查询的五个名次是 6.0991 / 6.6501 / 6.8039 / 6.9098 / 6.9527: 第一名和第五名只差 14%。
原因不神秘:这 1000 条是随机生成的数,彼此之间没有任何意思上的关系。 所以这五个「最近邻」不代表 任何检索质量,只演示了机制。书没有说这件事。
这一步的代价:库涨几倍,时间就涨几倍
库里有几条,就要算几次距离。 1000 条算 1000 次,一百万条算一百万次。 这种「涨得一样快」的关系叫线性 —— 库翻十倍,查一次的时间也翻十倍,不多不少。
每一次距离本身也不便宜:64 个数要各减一次、各平方一次,再全部加起来。
这一步的好处:一条都不漏
它把每一条都真算了一遍,所以本该进前五的那一条,一定在前五里。
「本该找到的,实际找到了几成」这件事有一个正式名字,叫召回。 精确逐条算的召回永远是十成。
记住这个词。这一章后面每一样加速手段,都是从这十成里往下扣。
3. 为什么可以不算全部
先想清楚问题
你要的是前五名,不是 1000 个距离的完整排名。
库里绝大多数向量离查询很远,根本不可能进前五 —— 而判断「它不可能进前五」,不一定要把它的距离真算出来。
只要有办法粗略地知道「这一片都很远」,就可以整片跳过。 下面两种做法,粗略的办法各不相同。
办法一:建库时先把向量分堆
建库时把 1000 条向量聚成 32 堆,每堆记下一个代表点(这堆里所有向量的平均位置,叫堆心)。
查询来了,先只和 32 个堆心比 —— 32 次距离。挑出最近的 2 堆, 只在这 2 堆里面逐条算,平均每堆 31 条,所以是 62 次。合计 94 次,而原来是 1000 次。
这个办法的名字叫倒排文件索引,缩写 IVF —— 你在向量库的配置项里会撞见这三个字母。
它有两个旋钮:建库时分几堆(叫 nlist)、查询时搜几堆(叫 nprobe)4。
它必须先「学」一遍才能用: 分堆本身要看过数据才知道往哪儿聚。 所以这类索引在灌数据之前,要先跑一遍训练5。
办法二:建库时把向量连成一张路网
另一种做法不分堆,而是给向量之间连边:每个点连上它附近的几十个点。 再在这张网上加几层「高速路」——上层点很少、边跨得很远,下层点很密、边跨得很近。
查询时从最上层随便一个点出发,每一步都走到「比现在更靠近查询」的那个邻居; 走不动了就下到密一点的一层继续走。最后只访问了这张网上的一小撮点。
这个办法叫分层可导航小世界图,缩写 HNSW —— 它是今天最常见的那一种,
你在向量库的默认配置里多半见到的就是它。它的主要旋钮叫 efSearch:
这个数开得越大,走的步数越多,越准也越慢6。
这两样合起来的名字
这两种做法有一个共同的名字:近似最近邻,英文缩写 ANN。
「近似」两个字就是它的合同内容:它不保证给你真正的前五名,只保证「大概率是」。
书在这里出了岔子
书里那段介绍点了 HNSW、FAISS 索引、Annoy 树三个名字1,
然后代码写的是 IndexFlatL2 —— 上一节刚说过,那是逐条算、一条不漏的那种。
而程序自己招了供: 它印出来的 Is trained: True,
恰恰因为平铺索引根本不需要训练,所以这个标志天生就是真的。
真正的近似索引(上面那种要先分堆的)必须先训练过才能用5。
「用一点找不全,换几十倍速度」这个 ANN 的全部要点,书一个字没讲。
4. 还能更狠:连距离都不真算
前面两样省的是「要比几条」。还有一样省的是「每一次比较有多贵」。
问题:向量本身太占地方
先说计量单位:计算机存东西的最小单位叫字节 —— 一个英文字母正好占一个,一个小数通常占 4 个。
于是一条 64 维的向量就是 64 × 4 = 256 字节,一百万条是 256 MB。 而这还只是 64 维 —— 第 04 章那个模型出的是 384 维,同样一百万条要 1.5 GB, 一台普通笔记本的内存就被它吃掉一大块。
做法:把向量压成几个编号
① 把 64 维切成 8 段,每段 8 维
② 对每一段,事先在全库上聚出 256 个代表值
③ 于是任何一条向量都可以写成 8 个编号(每个编号 0–255,正好一个字节)
原来: 64 个小数 = 256 字节
现在: 8 个编号 = 8 字节 ← 压掉 32 倍
图说:这不是无损压缩。8 个字节装不下 256 字节的信息,
还原出来的是「和原向量差不多的那个向量」。
算距离的时候更省: 查询也切成 8 段,先算好「查询的第 1 段」到「第 1 段那 256 个代表值」 的 256 个距离,八段各做一张,一共八张表。 之后每条库向量的距离 = 查 8 次表、把 8 个数加起来 —— 乘法整个没有了。
「拿一个代表值去顶替一批真实值」这个动作,行话叫量化。 这里是先把向量切成几段、每段各自量化一次,所以这个办法的全名叫乘积量化, 缩写 PQ —— 你在向量库的索引类型列表里会看到这两个字母7。
代价
算出来的是近似距离,不是真距离。 名次因此会有出入 —— 又一笔从召回里扣的账。
5. 主走查:同一个库,六种打法
这一节是本章的主走查。上面和下面每一个机制,在这条线上都占一步。
用的就是第 2 节那个库:1000 条、每条 64 个数、要前 5 名。
除了第 ① 行,下面这张表里的数全部是为演示编的,不是书里的,也不是我们跑出来的。 书只给了第 ① 行。 编它们是为了让六种打法的代价能摆在同一把尺子上比, 数量级是对的,具体数字不要引用。
| 步 | 打法 | 一次查询算多少次距离 | 前五名找回几条 | 库占多少内存 |
|---|---|---|---|---|
| ① | 逐条算(书里这一行是真的) | 1000 次 | 5 / 5 | 256 KB |
| ② | 分成 32 堆,只搜最近 2 堆 | 32 + 62 = 94 次 | 4 / 5 | 256 KB + 32 个堆心 |
| ③ | 连成路网,跳着走 | 约 120 次 | 4 或 5 / 5 | 256 KB + 一张边表 |
| ④ | 压成 8 个字节,查表算距离 | 仍是 1000 次,但每次只查 8 次表、加 8 个数 | 3 / 5 | 8 KB |
| ⑤ | 按主题切成 3 个区,只搜 1 个 | 约 333 次 | 0 或 5 | 256 KB |
| ⑥ | 上次问过,直接拿旧答案 | 0 次 | 和上次一模一样 | 256 KB + 一份答案 |
逐行看这张表在说什么
第 ① 行是地面。 1000 次距离、五条全中、256 KB —— 后面每一行都是拿它换东西。
第 ② 行省了九成计算,代价出现在第三列。 本该进前五的第 3 名,如果落在没被搜到的那 30 堆里,它就永远不会出现。 不是 排在后面,是根本没参与比较。
第 ③ 行的账分摊在两列上。 第四列那张边表不小 —— 每个点连几十条边,
一百万条向量的边表常常和向量本身一样大;它省的是时间,不是空间。
第三列写「4 或 5」不是含糊其辞,是它真实的样子:走得够多才接近全中。
第 3 节那个 efSearch 就管这件事 —— 开大多走几步、越走越准,
开小就照样会跳过真正的第 3 名。它和第 ② 行同属近似最近邻,
「不保证给你真正的前五名」这句合同,对它一样生效。
第 ④ 行反过来:空间省了 32 倍,准头掉得最多。 因为它连距离都是估的。 注意它省的是每次比较的单价,不是比较的次数 —— 1000 条还是一条条比, 只是每一条从「64 个小数乘一遍加一遍」变成了「查 8 次表、加 8 个数」(第 4 节算过这笔账)。
第 ⑤ 行的第三列写的是「0 或 5」,不是「4」。 这是它和上面三行的根本区别: 猜对了区就全中,猜错了区就一条不中。 详见下一节。
第 ⑥ 行看着完美 —— 0 次计算。 但它有个前提藏在「上次问过」四个字里,详见第 7 节。
结账
把第三列从上往下读一遍:5 / 4 / 4 或 5 / 3 / 0 或 5 / 上次是几就是几。
这一列就是召回。 第二列每省下一笔(少比几条,或者把每一条比得更便宜), 第三列就要付一次钱 —— 除了第 ① 行,没有一行是白拿的。
而书里这四个配方,没有一个提到第三列。
6. 按主题分区:查询只进一个区,别的区就永远取不到
书里的做法
书有一个配方把切好的块按主题聚成 3 组,每组各建一个索引; 查询来了,先看它离哪一组的中心最近,然后只在那一组里搜8。
书印出来的结果是9:
Created 5 chunks with 384-dim embeddings
Partition sizes:
Topic 0: 3 chunks
Topic 1: 1 chunks
Topic 2: 1 chunks
Query routed to Topic 0
图说:5 块被分成 3 / 1 / 1。查询被送进了 Topic 0,
于是这次搜索的范围是 3 块 —— **另外那 2 块连比都没比。**
它和第 3 节那个「分堆」长得像,但不是一回事
| 第 3 节的分堆(IVF) | 这里的按主题分区 | |
|---|---|---|
| 搜几堆 | 可以搜多堆(nprobe 是个旋钮,开大就搜更多) | 只搜一个,写死的 |
| 找错了怎么办 | 开大 nprobe 就能捞回来 | 捞不回来,那个区根本没被打开 |
| 谁来决定 | 索引内部,对使用者透明 | 你自己在代码里路由 |
所以分区是这一章代价最大的一样:它的召回不 是「掉一点」,是「要么全中要么全丢」。
书里一个字没提这件事。 它只说分区能「减少噪声、提高准确率、缩小搜索范围」8。
对照:同一章那个叫「分布式索引」的配方,做的是另一件事
书还有一个配方,标题叫「为规模而做的分布式索引」, 正文说的是把索引切成几片放到不同机器上,各搜各的,再把结果合起来10。
这句话描述的东西,和分区正好相反:
| 分区 | 分片 | |
|---|---|---|
| 查询走几路 | 一路(只进一个区) | 全部(每片都搜) |
| 结果怎么来 | 那个区的前 k 条 | 各片的前 k 条合并后再取前 k |
| 召回 | 会掉 | 不掉 |
| 换来什么 | 快 | 装得下、扛得住单机故障 |
而那个配方的代码里没有分片: 它在同一个进程里建了三个索引,
然后调用 merge_from 把它们合并成一个11 ——
合并完就是一个普通的单机索引,没有节点、没有路由、没有跨机通信。
判断(我们的,不是书里的): 这个配方演示的是「怎么把几个索引拼成一个」, 这件事本身有用(比如分批建好再合),但它不是分布式,也不构成横向扩展。 如果错,会错在: 如果作者的本意是「先演示合并这一步,分布式的其余部分留给读者」, 那么这只是讲解不完整,不是名不副实。但正文写的是横向扩展和容错这两个结论, 而合并恰恰是这两件事都做不到的那一步 —— 合完之后又回到了单机。
顺带:这个配方的代码跑不出它自己的输出
它建索引和收集索引的那两行,缩进跑到了循环外面11。 照这段代码跑,只有最后一个文件进得去,而印出来的结果同时命中了三个文件的内容。
这类事在这本书里不止一处,统一放在第 12 节说。