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%)定参数,再在满足召回的前提下压延迟与内存,而不是反过来。

关联