KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · 从暴力检索到 ANN — keel 龙骨
这一章不引入任何索引,只把「不建索引」的那条基线跑出来。理由很实际:后面每一章都会说某个索引「更快」「更省内存」,如果没有一条自己机器上量出来的基线,这些说法就只是别人文档里的形容词。基线跑完,你还会发现一件反直觉的事——向量检索的延迟不随数据量线性增长,它会在某个点突然掉下一个台阶。
这一章不引入任何索引,只把「不建索引」的那条基线跑出来。理由很实际:后面每一章都会说某个索引「更快」「更省内存」,如果没有一条自己机器上量出来的基线,这些说法就只是别人文档里的形容词。基线跑完,你还会发现一件反直觉的事——向量检索的延迟不随数据量线性增长,它会在某个点突然掉下一个台阶。
现场
你手里有一批文档向量:10 万条,128 维,float32。产品要「给定一条查询,返回最相似的 10 条」,P99 延迟压到 10ms 以内,单机内存留 2GB 预算。你在库文档里看到 FLAT / IVF / HNSW / PQ / DiskANN 一串名字,价格、内存、召回率各不相同。
先别选。先算清楚不做任何索引时,这笔账是多少。
精确检索在算什么
最近邻检索的朴素做法只有一步:把查询向量 q 和库里每一条向量 xᵢ 算一次距离,排序,取前 k。索引成 Flat 的库就是这么干的——它根本没有「索引」结构,只是把向量按行连续存着,查询时逐行扫过。所以它的成本可以直接推:
- 内存 ≈
N × d × 4字节。多一条向量就多 512 字节(d=128 时),和省不省无关。 - 计算 ≈
N × d次乘加。延迟正比于你要扫过的标量总量,而不是正比于 N。
这两条在 d 固定时看起来是一回事,d 会变的时候就分开了,下一节会拿数据说明。
一次完整运行:10 万 × 128 的 Flat 基线
脚本用固定种子生成数据,先加进 faiss.IndexFlatL2,再分别测单条查询延迟和批量吞吐。单条和批量各测 3 轮取中位数,因为这两项在本机上都吃线程调度,波动不小。
SEED, N, D, NQ, K = 20261005, 100_000, 128, 1_000, 10
rng = np.random.default_rng(SEED)
xb = rng.random((N, D), dtype=np.float32)
xq = rng.random((NQ, D), dtype=np.float32)
index = faiss.IndexFlatL2(D)
index.add(xb) # Flat 的「建索引」只是拷贝
...
_, Ires = index.search(xq, K) # 一次查 1000 条,跨查询并行
本机原始输出(lab/evidence/vector-database-engineering/01-baseline-flat.txt):
faiss 1.15.1 | numpy 2.5.3
cpu_count 32 | omp_threads 32
N=100000 d=128 nq=1000 k=10 seed=20261005
flat_add_seconds=0.007 ntotal=100000
round0_single_ms=0.2560
round1_single_ms=0.2771
round2_single_ms=0.2615
single_query_median_ms=0.2615
round0_batch_qps=6239.05 time_s=0.1603
...
batch_qps_median=6278.23
index_bytes=51200000 index_mb=48.83
per_vector_bytes=512.0
sample_top1_ids [84994, 80135, 88175, 87808, 43971]
sample_top1_dist(squared_L2) [13.3604, 13.5794, 13.6348, 13.6999, 13.7049]
逐行看这些字段:
flat_add_seconds=0.007:10 万条入库只要 7ms。Flat 没有训练、没有图、没有聚类,add就是一次内存拷贝,这解释了为什么它「建索引」几乎不要钱。single_query_median_ms=0.2615:一次查询 1000 条里挑 10 条最近邻,0.26ms。这里的循环是index.search(xq[i:i+1], K),每次只给一条查询,faiss 的并行只作用在「同时有多少条查询」这个维度上,所以这个数接近单核延迟。batch_qps_median=6278.23:把 1000 条查询一次性丢进去,32 个线程分摊,吞吐到 6278 QPS。注意它和单条延迟不是倒数关系(1/0.0002615 ≈ 3824):批量摊薄了调用开销、又把扫描摊到多核,所以吞吐数字明显更高。汇报时别拿单条延迟去反推吞吐。index_bytes=51200000/index_mb=48.83:正好等于100000 × 128 × 4。per_vector_bytes=512.0就是d × 4。这条关系是后面所有「内存估算」的锚。sample_top1_dist里的数字是 平方 L2 距离,不是欧氏距离。faiss 的METRIC_L2返回平方值(省一次开方)。13.36 是 10 万条里最小的一条,别拿它当「相似度」,它没有上界。sample_top1_ids是内部自增 id。Flat 里 id 就是插入顺序,第 0 条是 0 号。
规模放大:延迟不是线性的
把 N 从 10 万涨到 40 万,或者把 d 从 128 涨到 512,两者都会让扫描总量变成 4 倍。本机实测(00-scale.txt):
N d | memory_MiB | add_s | single_ms | batch_qps
100000 128 | 48.83 | 0.008 | 0.2401 | 2920.3
400000 128 | 195.31 | 0.031 | 8.7027 | 271.3
100000 512 | 195.31 | 0.030 | 8.0073 | 3639.9
内存严格遵守 N × d × 4:40万×128 和 10万×512 都恰好是 195.31 MiB。但延迟不是 4 倍,是 30 倍以上:0.24ms → 8.7ms。这两行数据都把扫描总量推到 204.8MB,正好越过了本机的 CPU 缓存容量。
这就是那条反直觉结论:Flat 的查询延迟由「数据能不能放进缓存」而非「N 有多大」主导。51MB 的索引能常驻缓存,单条 0.24ms;一旦涨到 200MB 级别,每次查询都要去内存条搬,单条直接跳到 8~9ms。所以「10 万条很快」推不出「100 万条也就慢 10 倍」——它可能慢 40 倍。你在做容量估算时,红线是缓存,不是行数。
ANN 用哪一部分准确率换了什么
近似最近邻(ANN)索引的本质是放弃「100% 找全」,换取少扫数据。不同索引放弃的东西不一样:
| 索引 | 省掉了什么 | 代价 | 典型失效场景 |
|---|---|---|---|
| Flat | 什么都不省 | 每次全扫 | 数据量或 QPS 上来后延迟扛不住 |
| IVF | 只扫最可能的几个簇 | 邻居落在没探测的簇里就丢了,召回 < 100% | nprobe 太小;数据分布均匀(第 03 章实测 nprobe=1 时召回 2.5%) |
| IVF + PQ | 用编码代替原始向量,省内存 | 距离本身是近似的,召回再降一层 | 高精度场景(第 04 章实测 m=16 省 19.6 倍内存、召回掉一半) |
| HNSW | 只在图上走几条路径 | 召回 < 100%,且图结构额外吃内存 | 高维均匀数据图连通性差(第 05 章实测单条可能比线性扫描还慢) |
| DiskANN | 图放磁盘,只在内存留 PQ 码 | 受 SSD 随机读限制,延迟比纯内存方案高 | 慢盘;QPS 极高且延迟敏感的场景 |
这张表里的每个数字后面都会单独给一章的实测,这里先记结论:没有一种索引是「又快又准又省内存」的,只有「你更能接受哪一项损失」。
决策流:先判断要不要向量库
flowchart TD
START["拿到检索需求<br/>N 条 / d 维 / 目标 QPS"] --> MEM["算内存 N*d*4<br/>和单条扫描成本"]
MEM --> CAND{"标量条件能把候选<br/>缩到几千条以内?"}
CAND -->|能| BF["过滤后暴力算距离<br/>精确、免建索引"]
CAND -->|不能| KEY{"查询是精确匹配<br/>或关键词?"}
KEY -->|是| INVERT["倒排 / B-tree<br/>不走向量"]
KEY -->|否| SCALE{"N*d*4 能常驻缓存<br/>且 QPS 够?"}
SCALE -->|能| FLAT["Flat / 顺序扫描<br/>100% 召回"]
SCALE -->|不能| ANN["上 ANN 索引<br/>IVF / HNSW / PQ"]
ANN --> RECALL{"能接受召回 < 100%?"}
RECALL -->|不能| RE["退回基线:加内存、<br/>缩小候选集或换精确方案"]
RECALL -->|能| OK["按第 03 章调参<br/>定验收线"]
style START fill:#e3f2fd,color:#0d3b66
style MEM fill:#f1f8e9,color:#33691e
style CAND fill:#fff3e0,color:#8a4b00
style BF fill:#e8f5e9,color:#1b5e20
style KEY fill:#fff3e0,color:#8a4b00
style INVERT fill:#e8f5e9,color:#1b5e20
style SCALE fill:#fff3e0,color:#8a4b00
style FLAT fill:#e8f5e9,color:#1b5e20
style ANN fill:#e3f2fd,color:#0d3b66
style RECALL fill:#fff3e0,color:#8a4b00
style RE fill:#ffebee,color:#b71c1c
style OK fill:#e8f5e9,color:#1b5e20
图上从 START 到 OK 有四条可能走的路,其中三条的终点不是「向量库」。把这张图跑一遍,很多「要不要上向量数据库」的争论会直接消失。
什么时候根本不需要向量库
- 候选集能被标量条件缩到几千条。比如「先按 tenant_id + status 过滤,剩下的候选只有几千条」,这时先过滤、再暴力算距离,既精确又便宜。ANN 索引是为了在「不能先缩小范围」时才有价值。
- 数据量小、QPS 低。本机 10 万 × 128 的 Flat 单条 0.26ms、内存 49MB;如果你的 N 就在这个量级、QPS 只有几十,上专用向量库是在给自己加一套要运维的组件。
- 查询是精确匹配或关键词。工单号、函数名、错误码这类查询,倒排或 B-tree 比向量更准。第 07 章(记忆系统)讲的「元数据 + 关键词 + 语义」三种信号,语义只是其中一种。
- 需要强事务一致性。向量库的写入到可见往往有延迟或一致性级别选择(第 04、05 章),如果你的业务要求「写完马上按最新状态检索」,得先确认这个一致性代价你付不付得起。
主动破坏:把扫描量推过缓存
回到上面那张规模表。把 N=100000, d=128 改成 N=400000, d=128(脚本里改一行),重跑。你会看到内存从 48.83 MiB 变成 195.31 MiB(线性的),但单条延迟从 0.24ms 变成 8.70ms——内存涨 4 倍,延迟涨 36 倍。再改成 N=100000, d=512,内存同样是 195.31 MiB,单条 8.01ms,和 40 万×128 几乎一样。
两次破坏说明同一件事:拖慢 Flat 的不是行数,是「每次查询要搬多少字节」。这也是为什么第 02 章的 PQ 索引要先解决「怎么让向量变小」,而不是「怎么让向量变少」——变少你做不到(数据就在那里),变小可以。
Flat 在真实系统里长什么样
Flat 不是一个只存在于 faiss 里的概念。第 05 章里那台 PostgreSQL 上,把 enable_indexscan 关掉,pgvector 的 ORDER BY v <-> '...' 就会退化成顺序扫描——那一刻它干的就是 Flat 的活:逐行算距离、排序、取前 k。本机实测里,同一批数据、同一条查询,关掉索引扫描得到的精确 top-10 是
exact_top10= [84994, 80135, 88175, 87808, 43971, 98827, 48637, 74848, 45447, 44454]
而走 HNSW 索引得到的 top-10 和它只重合 4 条(重合 = 4 / 10)。这两个数字放在一起,就是本课后面所有内容的问题起点:精确结果是这 10 条,索引给你的是那 10 条,中间差的 6 条是你为速度付的账。什么时候这 6 条的偏差可以接受,就是调参要回答的事。
顺带记一个工程细节:pgvector 无过滤时走索引,Execution Time 在毫秒以下;一旦索引用不上退回顺序扫描,耗时就随表大小线性涨。本机表只有 10 万行,顺序扫描还能在毫秒级返回;真实表到千万行,顺序扫描就不再是「慢一点」,而是「根本等不起」。这也是第 05 章要盯着执行计划的原因。
生产边界
- 教学替身:
faiss.IndexFlatL2+ 内存内 numpy 数据,测的是纯计算与内存访问。真实系统里数据从磁盘或对象存储加载,第一次查询还要冷启动,绝对延迟会更高。 - 上线要盯的指标:
p50/p99 单条延迟、QPS、索引常驻内存、缓存命中率。别只看平均延迟——缓存悬崖会让 p99 和 p50 差一个数量级。 - 失败策略:容量估算按「能不能常驻缓存」分档,而不是按行数线性推。缓存装不下时,要么加内存,要么换成第 02 章的压缩/磁盘索引,要么先做标量过滤缩小扫描量。
动手
- 跑
01_baseline_flat.py,把N改成200000,观察index_mb和single_query_median_ms各变了几倍,解释为什么两者比例不同。 - 跑
00_scale.py,在(100000,128)和(200000,128)之间再加一行(150000,128),找出本机的「缓存悬崖」大概落在多少 MiB。 - 把
index.search(xq, K)换成index.search(xq[:1], K)重复 1000 次,再和批量search(xq, K)比 QPS,说明为什么单条延迟的倒数远小于批量吞吐。
自测
- 10 万条 128 维 float32 索引占 48.83 MiB,请不查表说出 100 万条 256 维占多少,并说明你的算法。
- 为什么单条查询 0.26ms 但批量吞吐是 6278 QPS,而不是
1/0.0002615 ≈ 3824 QPS? - 40 万×128 和 10 万×512 的内存完全相同,但它们的「问题」在工程上不一样——分别是什么问题?(提示:一个影响过滤后的候选量,一个影响模型维度)
- 你的场景是「10 万条文档,查询前已按部门过滤到 2000 条」,还需要上 IVF/HNSW 吗?给出理由。
- faiss 的
sample_top1_dist返回 13.36,如果你把它当成「相似度越高越好」的分数直接展示给用户,会出什么问题?
↓ 下一步:01 章 · 向量、距离与度量 —— 先把「距离」这件事讲清楚,否则你会用错索引参数。