44.18 向量数据库
一句话
向量库不是“能存向量”,而是用近似最近邻索引(IVF / HNSW / PQ)把 O(N) 的全量距离计算换成 O(候选数)——代价是召回率不再等于 1,工程的全部工作就是在“召回率、延迟、内存”三者之间调参。
五种索引,五种取舍
| 索引 | 做什么 | 构建成本 | 查询成本 | 内存 | 召回 |
|---|---|---|---|---|---|
| Flat(暴力) | 与全部向量算距离 | 0 | O(N) | 全量 | 100% |
| IVF(倒排文件) | 先聚类,只在最近的几个簇里搜 | 中(k-means) | O(nprobe × 簇大小) | 全量 + 质心 | nprobe 越大越高 |
| HNSW(分层图) | 多层邻居图,逐层贪心下降 | 高 | O(log N) 量级 | 全量 + 图(更大) | 高(调 efSearch) |
| PQ(乘积量化) | 把向量切成子段、各段量化成码本 ID | 中 | 查表(不解压原向量) | 1/8–1/32 | 中(有量化误差) |
| DiskANN | 图索引 + SSD 驻留 | 高 | 少量内存 + 磁盘 IO | 小 | 高 |
🔑 三者的关系:IVF 用“分桶”减少候选,HNSW 用“图”减少跳数,PQ 用“量化”减少内存。生产上常组合(IVF-PQ、HNSW+PQ)。
代码:IVF 的 nprobe 与召回率
package main
import (
"fmt"
"math/rand"
"sort"
)
const (
nVectors = 10000
dim = 32
nQueries = 50
k = 10
nlist = 100 // 簇数
)
func gen(n, seed int64) [][]float32 {
r := rand.New(rand.NewSource(seed))
out := make([][]float32, n)
for i := range out {
v := make([]float32, dim)
for j := range v {
v[j] = r.Float32()
}
out[i] = v
}
return out
}
func dist(a, b []float32) float32 {
var s float32
for i := range a {
d := a[i] - b[i]
s += d * d
}
return s
}
type hit struct {
idx int
d float32
}
func topK(cands []int, vecs [][]float32, q []float32, kk int) []int {
hs := make([]hit, 0, len(cands))
for _, i := range cands {
hs = append(hs, hit{i, dist(vecs[i], q)})
}
sort.Slice(hs, func(a, b int) bool { return hs[a].d < hs[b].d })
if len(hs) > kk {
hs = hs[:kk]
}
out := make([]int, len(hs))
for i, h := range hs {
out[i] = h.idx
}
return out
}
func main() {
vecs := gen(nVectors, 1)
queries := gen(nQueries, 2)
// ---------- IVF:随机选 nlist 个点当质心,把每个向量分到最近质心 ----------
r := rand.New(rand.NewSource(3))
centroids := make([][]float32, nlist)
for c := range centroids {
centroids[c] = vecs[r.Intn(nVectors)]
}
// 跑 8 轮 k-means:先分配、再按均值更新质心(聚类质量直接决定召回)
buckets := make([][]int, nlist)
for iter := 0; iter < 8; iter++ {
for c := range buckets {
buckets[c] = nil
}
for i, v := range vecs {
best, bestD := 0, dist(v, centroids[0])
for c := 1; c < nlist; c++ {
if d := dist(v, centroids[c]); d < bestD {
best, bestD = c, d
}
}
buckets[best] = append(buckets[best], i)
}
// 更新质心为簇内均值
for c := range centroids {
if len(buckets[c]) == 0 {
continue
}
mean := make([]float32, dim)
for _, idx := range buckets[c] {
for j := 0; j < dim; j++ {
mean[j] += vecs[idx][j]
}
}
for j := range mean {
mean[j] /= float32(len(buckets[c]))
}
centroids[c] = mean
}
}
// ---------- 对每个 query 算 ground truth(暴力)----------
all := make([]int, nVectors)
for i := range all {
all[i] = i
}
truth := make([][]int, nQueries)
for qi, q := range queries {
truth[qi] = topK(all, vecs, q, k)
}
fmt.Printf("向量 %d 条、维度 %d、查询 %d 个、k=%d、簇数 %d\n\n", nVectors, dim, nQueries, k, nlist)
fmt.Printf("%-8s %-14s %-12s %-10s\n", "nprobe", "平均扫描候选", "recall@10", "相对暴力")
for _, nprobe := range []int{1, 2, 4, 8, 16} {
totalCand, totalHit := 0, 0
for qi, q := range queries {
// 找最近的 nprobe 个质心
cs := make([]hit, nlist)
for c := range centroids {
cs[c] = hit{c, dist(centroids[c], q)}
}
sort.Slice(cs, func(a, b int) bool { return cs[a].d < cs[b].d })
var cand []int
for p := 0; p < nprobe && p < len(cs); p++ {
cand = append(cand, buckets[cs[p].idx]...)
}
totalCand += len(cand)
got := topK(cand, vecs, q, k)
want := map[int]bool{}
for _, id := range truth[qi] {
want[id] = true
}
for _, id := range got {
if want[id] {
totalHit++
}
}
}
recall := float64(totalHit) / float64(nQueries*k) * 100
avgCand := float64(totalCand) / float64(nQueries)
fmt.Printf("%-8d %-14.0f %-12s %-10.1f×\n", nprobe, avgCand, fmt.Sprintf("%.1f%%", recall), float64(nVectors)/avgCand)
}
}
本机实测(macOS,Apple M1,Go 1.27):
向量 10000 条、维度 32、查询 50 个、k=10、簇数 100
nprobe 平均扫描候选 recall@10 相对暴力
1 101 13.2% 98.8 ×
2 201 20.4% 49.8 ×
4 400 34.6% 25.0 ×
8 802 54.4% 12.5 ×
16 1603 71.6% 6.2 ×
读数:
| nprobe | 平均扫描候选 | recall@10 | 相对暴力的加速 |
|---|---|---|---|
| 1 | 101 | 13.2% | 98.8× |
| 2 | 201 | 20.4% | 49.8× |
| 4 | 400 | 34.6% | 25.0× |
| 8 | 802 | 54.4% | 12.5× |
| 16 | 1,603 | 71.6% | 6.2× |
这张表最反直觉的是:扫描了 16% 的候选,召回也只有 71.6%。原因不是实现差,而是数据形态——本节用的是 32 维均匀随机向量,高维下它们近似等距,簇与簇之间没有明显边界,IVF 的“只在邻近簇里找”自然会漏。真实的 embedding(文本、图片)有明显簇结构,同样的 nprobe 通常能到 90%+。
🔑 所以两条结论:① 召回率是用扫描量买的(nprobe 每翻倍,召回涨、延迟涨);② 具体参数必须在自己的数据上评估——“nprobe=4 就够”这类经验只对“有簇结构的数据”成立,抄参数等于赌。
参数怎么给
| 索引 | 关键参数 | 调大的效果 | 建议起点 |
|---|---|---|---|
| IVF | nlist(簇数)、nprobe | nlist 大 → 每簇小、构建慢;nprobe 大 → 召回高、扫描多 | nlist ≈ 4×√N,nprobe 从 1% 的 nlist 起调 |
| HNSW | M(每层邻居数)、efConstruction、efSearch | M/ef 大 → 召回高、内存与延迟上升 | M=16–32,efSearch=64–128 |
| PQ | m(子段数)、nbits | m 大 → 压缩率低但精度高 | 内存紧时 m=8–16 |
过滤:先筛还是先搜
| 方案 | 做法 | 问题 |
|---|---|---|
| 后过滤(post-filter) | 先向量检索 top-K,再按标量条件过滤 | 过滤后可能不足 K 条(返回空) |
| 前过滤(pre-filter) | 先按标量筛出候选集合,再在其中做向量检索 | 候选集大时退化为暴力 |
| 分区/分桶过滤 | 按租户/时间分区,只在相关分区内检索 | 需要数据模型支持(推荐) |
⚠️ “带条件的向量检索”是最容易踩坑的场景:
WHERE tenant_id=? ORDER BY embedding <-> ? LIMIT 10在多租户下,如果先做全局 top-K 再过滤,租户可能一条都拿不到。正确做法是把租户做成分区键(物理隔离),在分区内检索。
产品怎么选
| 产品 | 形态 | 适合 |
|---|---|---|
| FAISS | 库 | 离线/嵌入应用,自己管索引与持久化 |
| pgvector | PostgreSQL 扩展 | 已有 PG、数据量中等、要 join |
| Milvus | 分布式向量库 | 亿级向量、多租户 |
| Qdrant | 向量库(Rust) | 过滤能力强、部署简单 |
反模式
| 反模式 | 后果 |
|---|---|
| 用 Flat 索引扛亿级数据 | 查询 O(N),延迟不可接受 |
| 只调 nprobe/efSearch,不评估召回 | 结果悄悄变差,用户感知为“搜不准” |
| 向量与标量条件混用却不设计分区 | 过滤后结果不足,或退化为暴力 |
| 用 PQ 压到 1/32 却不评估精度 | 召回骤降,且无法定位原因 |
| 向量维度不归一化就用量化索引 | 距离失真,索引效果差 |
| 把向量库当主库(唯一数据源) | 索引重建期间无法服务;向量只是派生数据 |
自检
Q1 为什么 IVF 的 nprobe 从 1 加到 16,召回率能接近 100%,但延迟也涨了 16 倍?
▸ 答案:因为 IVF 只保证“在探测到的簇内精确”,没探测到的簇里的近邻永远找不回来。nprobe 增大 = 探测更多簇 = 候选集更大 = 更可能包含真实近邻,所以召回上升;但每次查询要算的距离数也线性增长,延迟随之上升。这就是近似索引的基本权衡:召回率是用计算量买的。
Q2 HNSW 与 IVF 的本质区别是什么?
▸ 答案:IVF 是空间划分(把向量分到簇,查询只看邻近的簇),HNSW 是图导航(每个点连邻居,查询从上层稀疏图贪心下降,逐层细化)。IVF 的问题是“簇边界效应”(真实近邻恰好落在相邻簇的边界外),HNSW 的问题是需要更多内存(图结构)与更长的构建时间,但查询通常是 O(log N) 量级且召回更稳。大规模、低延迟常用 HNSW;内存敏感、可离线构建常用 IVF-PQ。
Q3 一个多租户 SaaS 用向量库做“文档检索”,查询是“本租户内最相似的 10 条”。用全局索引 + 后过滤会出什么问题?
▸ 答案:全局 top-K 可能全被大租户占满,小租户过滤后一条不剩(返回空);或者为了凑够 10 条不得不把 K 调到几万,延迟爆炸。正确做法是按租户隔离索引/分区(每个租户一个 collection 或一个分区),在分区内做向量检索;如果租户太多,用“租户 shard + 路由”把检索限制在单 shard 内。这也是向量库与关系库一样的老问题:过滤条件必须在索引结构里,而不是在结果上。
Q4 为什么向量索引 “重建” 很贵,增量插入怎么办?
▸ 答案:因为 HNSW/IVF 是图/聚类结构:插入要更新邻居与倒排,删除往往只能 “标记删除”;大量增删后结构退化,需要重建才能恢复召回率。工程做法是 “增量插入 + 定期重建/合并”,并监控 “召回率是否随插入下降”。
Q5 召回率、延迟、成本三者怎么调?
▸ 答案:三个旋钮:索引类型(HNSW 快但内存大、IVF 省内存但要调 nprobe)、参数(ef/nprobe 越大召回越高越慢)、量化(PQ 省内存但损召回)。做法是先用业务可接受的召回率(如 95%)定参数,再在满足召回的前提下压延迟与内存,而不是反过来。