跳到主要内容

找得快 — 每一样都在拿召回换速度

这一章讲三件事: 默认那套取法为什么会随着库变大而成正比地变慢; 三大类加速手段各自省掉了哪一步计算;以及它们省下来的时间,是从哪里扣走的。 它在全书链条里的位置: 第 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 / 5256 KB
分成 32 堆,只搜最近 2 堆32 + 62 = 94 次4 / 5256 KB + 32 个堆心
连成路网,跳着走约 120 次4 或 5 / 5256 KB + 一张边表
压成 8 个字节,查表算距离仍是 1000 次,但每次只查 8 次表、加 8 个数3 / 58 KB
按主题切成 3 个区,只搜 1 个约 333 次0 或 5256 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 节说。

7. 缓存:按原话对得上才算命中

书里的做法

把问过的问题和它的检索结果记在一个字典里,下次同样的问题直接拿旧结果。 判断命中的那一行代码,是「这句问话在不在字典里」12

书用 5 次查询演示,其中两次是重复的,于是印出来三次「未命中」、两次「命中」13:

Cache Miss: Running similarity search for 'What is machine learning?'
Cache Miss: Running similarity search for 'What is AI?'
Cache Hit: Returning cached results for 'What is machine learning?'
Cache Miss: Running similarity search for 'Applications of machine learning'
Cache Hit: Returning cached results for 'What is AI?'

图说:第 3 次和第 5 次和前面的问句**一模一样**,所以命中。

这种记账本叫缓存。 命中的那一次省掉的不只是检索 —— 连「把问题变成一串数」那一步都省了,而那一步要跑一遍模型。

用户从按下回车到看见结果的那段等待,行话叫延迟。 缓存命中直接把这一段砍到几乎为零。

它的死穴

判断命中的办法是逐字比对整句话。

缓存里已有: "What is machine learning?"

新来的问题: "what is machine learning?" ← 首字母小写,不命中
"What is machine learning" ← 少一个问号,不命中
"what is ML?" ← 同一个问题,不命中
"Explain machine learning." ← 同一个问题,不命中

图说:四种问法问的是同一件事,四种都要重新跑一遍完整检索。

真实用户不会两次打出一模一样的句子。 所以这种缓存在真实流量上的命中率, 和书里演示的「五次里中两次」不是一回事 —— 书里那两次命中是它自己在代码里写死的重复。

业界怎么做

补充(不在书里):把问题也变成一串数,和缓存里存的那些问题比相似度,过线才算命中。 这样「what is ML?」就能命中「What is machine learning?」。这种做法叫语义缓存14

它要多配两样东西:

要配什么不配会怎样
一条相似度门槛定松了就是拿别人的答案回答你的问题 —— 而它看起来完全正常
一个过期时间资料更新了,缓存还在发旧答案

门槛这件事,和第 05 章那道检索门槛是同一类判断,但拦的东西不同: 那一道拦的是「不够相关的资料」,这一道拦的是「不够像的旧问题」。

8. 降维:成分数不能超过样本数

这是本章第一处另起的走查,因为它落在另一条线上:它改的不是搜法,是向量本身。

想干什么

第 4 节算过:384 维的向量,一百万条要 1.5 GB。 如果能把 384 个数压成 128 个而意思基本不变,内存就省掉三分之二,距离计算也快三倍。

这件事叫降维:找出这批向量里最能区分彼此的那几个方向,只保留这几个方向上的坐标。

书里那次的结果

书的配方写着目标 128 维,而它印出来的是15:

Original dim: 384, Chunks: 5
Reduced dim: 5

图说:要 128 维,实际得到 5 维。

为什么是 5?因为它一共只有 5 个块。

代码里那一行其实写清楚了:能降到的维数不能超过样本数, 所以它取了「128 和 5 之间的小者」16

为什么会有这个约束

降维要找的是「这批数据里最能区分彼此的方向」,而方向只能从数据里看出来。

5 个点,摆在 384 维的空间里。

这 5 个点最多只能撑开一个 4 维的形状 ——
就像 2 个点只能定出一条线(1 维),3 个点最多定出一个平面(2 维)。

图说:数据本身没有那么多个方向可看,再多的维度都是空的。

那为什么印的是 5 而不是 4? 两个数都对,只是来路不同。 补充(不在书里,来自通用知识): 几何上这 5 个点只撑得开 4 个真方向 (第 5 个方向上所有点的坐标全是 0,那一维是空的); 而代码取的上限是「样本数」这个更粗、更好写的界,所以它报的是 5。 差的这一维不影响结论: 要的是 128,拿到的是个位数。

这恰恰是降维最该讲的那条约束,而书原样印出了 Reduced dim: 5,一个字没解释。

实用后果:降维要在够多的数据上做。 五个块上做出来的那五个方向, 换一批数据就完全不成立 —— 而这段代码建出来的索引,已经拿这五个方向去存全部向量了。

9. 冷热分层:书里这个是反的

这是本章第二处另起的走查。

想干什么

常用的资料放在快而贵的地方,不常用的放在慢而便宜的地方。

书的划法很干脆:180 天内被访问过的算「热」,其余算「冷」17。 四条文档按这条线分开,热的 2 条进向量索引,冷的 2 条写成一个 JSON 文件18

代码实际做的事

查询来了:
热的那一半 → 走索引,取前 2 条 ← 便宜
冷的那一半 → **把全部冷数据重新变成向量**,再逐条手算余弦 ← 每查一次都做一遍

图说:那一步重新算向量,是这整条流水线上最贵的一步 —— 第 04 章讲过, 它要把每条文字送进模型跑一遍。而这里每查询一次就全做一遍19

所以账是反的

留在索引里书里这个「冷存储」
建库时算一次向量,存下来不存
每次查询查索引把全部冷数据重算一遍向量
总开销一次性和查询次数成正比

书里正文说冷存储是「更慢、更便宜」17慢是真的,便宜是反的。

真正的冷热分层怎么做: 冷数据的向量照样要事先算好并存下来, 只是存在便宜的地方(磁盘、对象存储),查询时按需读回来。 分层分的是「存在哪儿」,不是「存不存」。

10. 「异步建索引」只加快了便宜的那一半

先说这一节要用的那个词。 一个程序默认是排队做事:上一件做完才开始下一件。 让它「这件事还没回来,先去做下一件」的写法叫异步 —— 读一个文件要等磁盘,等的这段时间正好可以去读下一个。

多件事同时推进,这件事叫并行。 异步是手段,并行是结果。

书在上一章有个配方讲怎么把建库这一步做快,正文列的第一条好处是 「多个嵌入可以并行生成」20

而代码里真正同时推进的是读文件那一步:它把几个文件的读取和切块一起发出去21真正耗时的那一步 —— 把所有块变成向量 —— 是在这些文件全部读完之后,一次性排队做的22

代码实际的形状:

同时读 3 个文件、切块 ← 快,本来也不慢

等全部读完

把全部块一次性送进嵌入模型 ← 慢,而这一步没有并行

图说:并行加在了不花时间的那一半上。

这不是说异步没用 —— 要读几百个文件时,同时读确实值钱。 但正文承诺的那条好处,这段代码没有兑现。

11. 作者的判断与证据

书里给了证据的:

说法证据
精确找最近邻算起来很贵那个配方的库是 1000 条,查一次就要算 1000 次距离
缓存能省掉重复查询的计算五次查询里两次命中,命中那两次一次检索都没跑
分区能缩小搜索范围5 块分成 3/1/1,查询只进了那个 3 块的区
降维会受样本数限制Reduced dim: 5 —— 目标 128,实得 5

作者只是断言、没有给证据的:

说法缺什么
「这个配方演示了近似最近邻索引」代码用的是精确逐条搜索,Is trained: True 反而证明了这一点
「分布式索引带来横向扩展与容错」代码是单进程里三个索引合并成一个,没有节点也没有路由
「分区提高准确率」没有量过分区前后的召回;而分区最可能的后果恰恰是漏掉整个区
「冷存储更便宜」代码里冷数据每查一次就全量重算向量,比留在索引里贵
「多个嵌入可以并行生成」并行的是读文件,嵌入是同步的一次调用
「缓存降低延迟、降低成本、改善体验」三条都对,但没提命中条件是整句逐字相同

这一章是全书「正文承诺 vs 代码实现」缺口最集中的一章:六个配方里五个对不上。

12. 边界与局限

  • 全书最大的向量库是这一章的 1000 条随机向量。 它连「几万条时该换什么索引」 这种最基本的规模判断都给不出。第 05 章末尾那个「多大规模会出问题」的问题, 这本书答不了。
  • nlist / nprobe / efSearch 这些旋钮一个都没出现过。 而近似索引的全部工程含量就在这几个旋钮上。
  • 没有任何一处量过速度。 整章讲「efficient」,而全书唯一的计时出现在另一章的评测配方里(第 12 章讲)。
  • 没有任何一处量过召回。 于是这一章讲的每一种取舍,都只演示了「省」那一半。
  • 这一章有五处代码照抄跑不起来: 两条语句挤在一行、赋值被注释吃掉、循环体缩进跑到外面。 这些是这本书通篇的可用性问题,不影响它的思路。
  • 书把「分区」和「分片」都当成扩展手段介绍,而这两者对召回的影响完全相反。

13. 可带走的

全章那条走查,一行写完:

同一个 1000 条的库,要前 5 名 —— 逐条算 1000 次距离、五条全中; 分堆只搜两堆 94 次、可能只中四条;连成路网跳着走 约 120 次、但要多存一张边表

压成 8 个字节 内存省 32 倍、准头掉最多;按主题分区 要么全中要么全丢; 缓存命中 0 次计算,但要问得一字不差

(除第一项外,这些数是为演示编的。)

  1. 默认的向量检索是逐条算、一条不漏,开销和库的大小成正比;
  2. 「本该找到的实际找到了几成」叫召回,精确搜索的召回是十成, 这一章每一样加速手段都从这十成里扣;
  3. 三类偷懒法各省一样: 少比几条(分堆 IVF)、跳着比(路网 HNSW)、 比得便宜(压短码 PQ);
  4. 它们合起来的名字叫近似最近邻(ANN),「近似」两个字就是它的合同 —— 不保证真前五,只保证大概率是;
  5. 分堆索引必须先训练过才能用;Is trained: True 出现在一个没训练过的索引上, 说明它根本不是近似索引;
  6. 分区和分堆长得像但代价不同: 分堆可以多搜几堆捞回来, 分区只搜一个,猜错了区就一条都取不到;
  7. 分区和分片是相反的两件事: 分片每片都搜再合并,召回不掉;分区只搜一个,召回会掉;
  8. 按原话逐字匹配的缓存,在真实流量上几乎不命中 —— 「what is ML?」不会命中「What is machine learning?」;业界的做法是把问题也变成一串数按相似度命中(语义缓存), 代价是要多配一条门槛和一个过期时间;
  9. 降维能降到的维数不能超过样本数 —— 五个块上想降到 128 维,实得 5 维;
  10. 冷热分层分的是「存在哪儿」,不是「存不存」。 冷数据的向量照样要事先算好,否则每查一次就要全部重算一遍,反而更贵;
  11. 凡是给检索加速的地方,都要问同一个问题:这一步是从哪儿把时间省出来的? 答案通常都是召回。

14. 原文地图

主题原书章原文位置
「近似最近邻」那段介绍(点了 HNSW / Annoy 的名)CHAPTER 6 Efficient Retrieval from Vector Storetext/13-fm-introduction.txt:67(搜「hierarchical navigable small world」)
演示用的库:1000 条 × 64 维随机向量同上text/13-fm-introduction.txt:103(搜「nb = 1000」) · text/13-fm-introduction.txt:109(搜「np.random.random((nb, d))」)
用的其实是精确索引同上text/13-fm-introduction.txt:119(搜「index = faiss.IndexFlatL2(d)」)
Is trained: True 与 1000 条入库同上text/13-fm-introduction.txt:155(搜「Is trained: True」) · text/13-fm-introduction.txt:157(搜「Total vectors indexed: 1000」)
25 个距离的上下界同上text/13-fm-introduction.txt:173(搜「6.099058」) · text/13-fm-introduction.txt:177(搜「7.98004」) · text/13-fm-introduction.txt:181(搜「5.6874714」)
缓存:命中条件与输出同上text/13-fm-introduction.txt:442(搜「if query in query_cache」) · text/13-fm-introduction.txt:546(搜「Cache Hit: Returning cached results」)
「分布式索引」的正文承诺与实际合并同上text/13-fm-introduction.txt:794(搜「sharding the vector index across multiple nodes」) · text/13-fm-introduction.txt:880(搜「main_index.merge_from(vs)」)
降维:目标 128、实得 5同上text/13-fm-introduction.txt:1282(搜「cannot exceed samples/features」) · text/13-fm-introduction.txt:1366(搜「Original dim: 384, Chunks: 5」) · text/13-fm-introduction.txt:1368(搜「Reduced dim: 5」)
按主题分区:路由与分区大小同上text/13-fm-introduction.txt:1388(搜「Queries are first routed to the most relevant partition」) · text/13-fm-introduction.txt:1536(搜「Topic 0: 3 chunks」) · text/13-fm-introduction.txt:1542(搜「Query routed to Topic 0」)
冷热分层:180 天的界线与每次重算同上text/13-fm-introduction.txt:1780(搜「slower, cheaper storage formats」) · text/13-fm-introduction.txt:1854(搜「within 6 months」) · text/13-fm-introduction.txt:1906(搜「cold_embeds = embedding_model.embed_documents(cold_texts)」)
「异步建索引」的承诺与实现CHAPTER 5 Vector Stores for Semantic Retrievaltext/12-fm-introduction.txt:1168(搜「Multiple embeddings can be generated in parallel」) · text/12-fm-introduction.txt:1305(搜「asyncio.gather」) · text/12-fm-introduction.txt:1321(搜「db.add_texts(all_chunks)」)

Footnotes

  1. 出处:「CHAPTER 6 Efficient Retrieval from Vector Store」第 67 段(text/13-fm-introduction.txt:67,搜「hierarchical navigable small world」)。原文说精确找最近邻计算代价很高,近似最近邻用专门的数据结构与算法来快速逼近最相近的匹配,并点名了 HNSW 图、FAISS 索引与 Annoy 树三种常见做法。紧接着的配方用的却是 IndexFlatL2(同章第 119 段,text/13-fm-introduction.txt:119,搜「index = faiss.IndexFlatL2(d)」)。 2

  2. 出处:同章第 155 段(text/13-fm-introduction.txt:155,搜「Is trained: True」)与第 157 段(text/13-fm-introduction.txt:157,搜「Total vectors indexed: 1000」)。库与查询的规格写在第 101–105 段(text/13-fm-introduction.txt:103,搜「nb = 1000」),向量由随机数生成(text/13-fm-introduction.txt:109,搜「np.random.random((nb, d))」)。

  3. 出处:同章第 173 段(text/13-fm-introduction.txt:173,搜「6.099058」)、第 177 段(text/13-fm-introduction.txt:177,搜「7.98004」)与第 181 段(text/13-fm-introduction.txt:181,搜「5.6874714」)。这三行分别是第 1、3、5 条查询的五个距离;25 个数的最小值是 5.6874714,最大值是 7.98004。书没有对这些数说任何话。

  4. 补充(不在书里,依据我们的 agent 书架):这类索引的两个旋钮就叫 nlist(建库时聚成几堆)和 nprobe(查询时探几堆);搜索时若不指定,nprobe 会取一个默认值。 依据: shelf=ai-agent-reference/leann#03-backends-registry.md @794092ff4da62ac6d29ee83f0794ae7c8796d4fb 事实=文档写着「IVF(Inverted File,倒排文件)先把向量空间聚成 nlist 个簇,查询时只探其中 nprobe 个簇」,并注明这种索引不省存储、向量照存,换来的是可增删。

  5. 补充(不在书里,依据我们的 agent 书架):分堆索引必须先看过数据才知道堆心在哪,所以建索引的顺序是「先训练,再灌数据」;而平铺索引没有任何要学的东西,它的 is_trained 天生为真。 依据: shelf=ai-agent-reference/leann@src:packages/leann-backend-ivf/leann_backend_ivf/ivf_backend.py:127 @794092ff4da62ac6d29ee83f0794ae7c8796d4fb 事实=源码里建好 IndexIVFFlat 之后、灌数据之前,先调用了 ivf.train(data);而它用来存堆心的粗量化器,正是 IndexFlatL2 —— 也就是书里那个「精确逐条」的索引,在这里只负责 32 个堆心那一小层。 2

  6. 补充(不在书里,依据我们的 agent 书架):这种图式索引建的是多层结构 —— 上层稀疏、用来快速跳转,底层稠密、用来精搜;查询时的步数上限是一个可调的数。 依据: shelf=ai-agent-reference/leann@src:packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py:193 @794092ff4da62ac6d29ee83f0794ae7c8796d4fb 事实=搜索函数的文档字符串里写着 complexity: Search complexity/efSearch, higher = more accurate but slower —— 准确率与速度是同一个旋钮的两头,这正是书没讲的那笔账。

  7. 补充(不在书里,依据我们的 agent 书架):把高维向量切成若干段、每段各自聚类、用簇心编号代替原值,这种压缩法叫乘积量化(Product Quantization,PQ)。 依据: shelf=ai-agent-reference/leann#03-backends-registry.md @794092ff4da62ac6d29ee83f0794ae7c8796d4fb 事实=文档给 PQ 的定义是「把高维向量切段后各段独立聚类,用簇心编号代替原值」,并说明有一种磁盘索引靠它压出一份能全放内存的近似向量做图遍历,再回磁盘取精确数据重排。

  8. 出处:同章第 1388 段(text/13-fm-introduction.txt:1388,搜「Queries are first routed to the most relevant partition」)。原文说按主题分区能减少噪声、提高准确率、并靠缩小搜索空间来加快检索。「缩小搜索空间」和「可能搜不到」是同一件事的两面,原文只写了前一面。 路由那一步的代码在第 1506 段(text/13-fm-introduction.txt:1506,搜「np.argmin(np.linalg.norm(centroids - query_emb」)。 2

  9. 出处:同章第 1532 段(text/13-fm-introduction.txt:1532,搜「Created 5 chunks with 384-dim embeddings」)、第 1536 段(text/13-fm-introduction.txt:1536,搜「Topic 0: 3 chunks」)与第 1542 段(text/13-fm-introduction.txt:1542,搜「Query routed to Topic 0」)。

  10. 出处:同章第 794 段(text/13-fm-introduction.txt:794,搜「sharding the vector index across multiple nodes」)与第 796 段(text/13-fm-introduction.txt:796,搜「horizontal scaling, fault tolerance」)。原文说数据涨到百万、十亿量级时单机扛不住,于是把索引切片放到多个节点,各自存一部分、搜一部分,再把结果合起来。这段描述本身是对的,对不上的是代码。

  11. 出处:同章第 880 段(text/13-fm-introduction.txt:880,搜「main_index.merge_from(vs)」)。建索引与收集索引那两行在第 868–870 段(text/13-fm-introduction.txt:868,搜「vs = FAISS.from_documents(chunks, embedding_model)」),它们的缩进在 for 循环之外;而输出同时命中了三个文件的内容(text/13-fm-introduction.txt:894,搜「Top Results from Distributed Index」)。 2

  12. 出处:同章第 442 段(text/13-fm-introduction.txt:442,搜「if query in query_cache」)。缓存本体就是一个字典(text/13-fm-introduction.txt:434,搜「query_cache = {}」)。正文列的三条好处在第 346–350 段(text/13-fm-introduction.txt:342,搜「such as common customer questions」)。

  13. 出处:同章第 532 段(text/13-fm-introduction.txt:532,搜「Cache Miss: Running similarity search」)与第 546 段(text/13-fm-introduction.txt:546,搜「Cache Hit: Returning cached results」)。那五条查询是写死在代码里的,其中两条标着注释 # repeated(text/13-fm-introduction.txt:472,搜「# repeated」)。

  14. 补充(不在书里,依据我们自己书架上的另一本书):把请求也算成向量、按相似度命中缓存,这种做法叫语义缓存;它的两个必配项是相似度门槛和过期时间,门槛定错就会把别人的答案发给你。 依据:本库另一本已拆的书《AI Engineering》第 14 章(docs/ai-engineering/14-architecture-and-user-feedback.md)。 事实=那一章写着语义缓存「语义相似即命中」,实现等于「嵌入 + 向量搜索 + 相似度阈值」,并给了很谨慎的评级:嵌入质量、向量搜索、阈值三样都要靠谱,错配就是错答,引入前先评估。

  15. 出处:同章第 1366 段(text/13-fm-introduction.txt:1366,搜「Original dim: 384, Chunks: 5」)与第 1368 段(text/13-fm-introduction.txt:1368,搜「Reduced dim: 5」)。这一节的正文在第 1210 段(text/13-fm-introduction.txt:1210,搜「Dimensionality reduction techniques compress these vectors」),说的是把 768–1536 维压到低维以省内存、提速度、降成本。

  16. 出处:同章第 1282 段(text/13-fm-introduction.txt:1282,搜「cannot exceed samples/features」)。代码里那一行取的是「目标维数」和「样本数与原维数之中的小者」两者的小者,注释直接写着不能超过样本数或特征数。这一行写对了,而正文一个字没解释它。 顺带:这段代码的第 1270 段(text/13-fm-introduction.txt:1270,搜「# 3. Initialize the embedding modelembeddings」)把赋值语句吃进了注释里,照抄会直接报错。

  17. 出处:同章第 1780 段(text/13-fm-introduction.txt:1780,搜「slower, cheaper storage formats」)与第 1854 段(text/13-fm-introduction.txt:1854,搜「within 6 months」)。原文的说法是热存储放在快而贵的内存索引里,冷存储放在慢而便宜的格式里(磁盘或归档),两者搭配来平衡性能与成本;划分的界线写死为 180 天。 2

  18. 出处:同章第 1938 段(text/13-fm-introduction.txt:1938,搜「Hot storage (FAISS)」)与第 1940 段(text/13-fm-introduction.txt:1940,搜「Cold storage (JSON)」)。四条文档带着各自的最后访问日期写在第 1837 段(text/13-fm-introduction.txt:1837,搜「Artificial Intelligence is transforming healthcare.」)。

  19. 出处:同章第 1906 段(text/13-fm-introduction.txt:1906,搜「cold_embeds = embedding_model.embed_documents(cold_texts)」)。这一行在检索函数内部,也就是每调用一次检索就把全部冷数据重新算一遍向量,再手工算余弦(text/13-fm-introduction.txt:1910,搜「similarities = np.dot(cold_embeds, query_vec)」)。

  20. 出处:「CHAPTER 5 Vector Stores for Semantic Retrieval」第 1168 段(text/12-fm-introduction.txt:1168,搜「Multiple embeddings can be generated in parallel」)。原文把这一条列为异步建索引的第一项好处,后面三条是资源利用率、可扩展性和失败可重试。

  21. 出处:同章第 1305 段(text/12-fm-introduction.txt:1305,搜「asyncio.gather」)。并发的对象是每个文件的处理函数,而那个函数做的是读文件与切块。

  22. 出处:同章第 1321 段(text/12-fm-introduction.txt:1321,搜「db.add_texts(all_chunks)」)。这一行在并发结束、结果合并成一个列表之后,是一次普通的同步调用 —— 把全部块变成向量的计算全在这一行里,而它没有并行。