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 的库就是这么干的——它根本没有「索引」结构,只是把向量按行连续存着,查询时逐行扫过。所以它的成本可以直接推:

这两条在 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]

逐行看这些字段:

规模放大:延迟不是线性的

把 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 有四条可能走的路,其中三条的终点不是「向量库」。把这张图跑一遍,很多「要不要上向量数据库」的争论会直接消失。

什么时候根本不需要向量库

主动破坏:把扫描量推过缓存

回到上面那张规模表。把 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 章要盯着执行计划的原因。

生产边界

动手

  1. 跑 01_baseline_flat.py,把 N 改成 200000,观察 index_mb 和 single_query_median_ms 各变了几倍,解释为什么两者比例不同。
  2. 跑 00_scale.py,在 (100000,128) 和 (200000,128) 之间再加一行 (150000,128),找出本机的「缓存悬崖」大概落在多少 MiB。
  3. 把 index.search(xq, K) 换成 index.search(xq[:1], K) 重复 1000 次,再和批量 search(xq, K) 比 QPS,说明为什么单条延迟的倒数远小于批量吞吐。

自测

  1. 10 万条 128 维 float32 索引占 48.83 MiB,请不查表说出 100 万条 256 维占多少,并说明你的算法。
  2. 为什么单条查询 0.26ms 但批量吞吐是 6278 QPS,而不是 1/0.0002615 ≈ 3824 QPS?
  3. 40 万×128 和 10 万×512 的内存完全相同,但它们的「问题」在工程上不一样——分别是什么问题?(提示:一个影响过滤后的候选量,一个影响模型维度)
  4. 你的场景是「10 万条文档,查询前已按部门过滤到 2000 条」,还需要上 IVF/HNSW 吗?给出理由。
  5. faiss 的 sample_top1_dist 返回 13.36,如果你把它当成「相似度越高越好」的分数直接展示给用户,会出什么问题?

↓ 下一步:01 章 · 向量、距离与度量 —— 先把「距离」这件事讲清楚,否则你会用错索引参数。

进入 keel 阅读