随着 Embedding 成为语义表示的标准范式,向量数据库从可选项变成 RAG 与语义搜索的基础设施。选错索引、用错参数,会让召回率与延迟同时恶化。本文从 ANN 算法原理出发,给出可落地的选型与调优方法。
向量检索原理与 ANN
向量检索的目标:给定查询向量 $q$,在 $N$ 个向量中找到与它最相似(通常用余弦相似度或内积度量)的 Top-K 个。
精确检索(KNN):对全部 $N$ 个向量逐一遍历计算距离,复杂度 $O(N)$。当 $N$ 达到百万级,单次查询就要遍历千万次距离计算,延迟不可接受。
近似最近邻(ANN, Approximate Nearest Neighbor):用索引结构在召回率与延迟之间做权衡,牺牲少量召回换取数量级的加速。核心思想是分层(Hierarchical)与分区(Partitioning)——先定位到大概率包含近邻的区域,再在该区域精细搜索。
| 检索方式 | 时间复杂度 | 召回率 | 适用规模 | 场景 |
|---|---|---|---|---|
| 暴力 KNN | O(N) | 100% | <10 万 | 基准、精确场景 |
| ANN(HNSW) | O(log N) | 90%-99% | 千万级 | 生产默认 |
| ANN(IVF) | O(√N) | 85%-95% | 亿级 | 超大索引 |
| 暴力 + GPU | O(N) 并行 | 100% | 百万级 | 高精度低延迟 |
距离度量的选择先于索引:余弦相似度(Cosine)适合语义文本;内积(Dot Product)适合未归一化且关注幅度(如推荐打分)的场景;L2 距离(欧氏)适合图像特征等几何语义。多数文本场景用 Cosine,向量库一般会在存储时归一化。
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""余弦相似度:文本语义检索的默认度量。"""
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
# 内积在归一化后等价于余弦相似度
a = np.random.randn(1024); a = a / np.linalg.norm(a)
b = np.random.randn(1024); b = b / np.linalg.norm(b)
print(np.dot(a, b)) # 归一化后的内积 = 余弦相似度
HNSW 索引
HNSW(Hierarchical Navigable Small World)是生产环境最常用的 ANN 索引,兼顾召回率、延迟与查询稳定性。它构建多层图结构:上层图稀疏、节点少,用于「远跳」快速接近目标区域;底层图稠密,用于精细搜索。
层 3(稀疏) o───────────o
│ │
层 2 o─────o─────o─────o
│ │
层 1(稠密) o───o───o───o───o───o───o ← 精确搜索层
关键参数:M(每个节点的最大连接数)、efConstruction(建图时的候选队列大小)、efSearch(查询时的候选队列大小)。参数直接影响「图连通性 vs 内存 vs 速度」的三角权衡。
| 参数 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|
| M | 节点出度/图密度 | 召回↑、内存↑、建图慢 | 召回↓、内存↓ |
| efConstruction | 建图搜索宽度 | 图质量↑、建图慢 | 召回下降 |
| efSearch | 查询搜索宽度 | 召回↑、延迟↑ | 召回↓、延迟↓ |
# Qdrant HNSW 建索引配置
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="kb_embeddings",
vectors_config=models.VectorParams(
size=1024, # 向量维度
distance=models.Distance.COSINE,
),
hnsw_config=models.HnswConfigDiff(
m=16, # 出度:16-64 常见,默认 16
ef_construct=100, # 建图宽度:默认 100
),
)
# 查询时通过 search_params 控制 efSearch
client.search(
collection_name="kb_embeddings",
query_vector=query_vec,
limit=20,
search_params=models.SearchParams(ef=256, hnsw_ef=256),
)
HNSW 的工程经验:
efSearch是「召回-延迟旋钮」,从 64 逐步上调观察召回与 P99 延迟的拐点;M一旦建好再改动需要重建索引,所以建库前先用代表性数据在小样本上调优。
IVF 与 PQ 量化
当向量规模达到亿级,纯 HNSW 的内存(每个向量约 4B×d,加图边开销)与建图成本吃不消,需要分区 + 量化。
IVF(Inverted File):用 K-Means 把所有向量划分为 $nlist$ 个聚类(Voronoi 单元),查询时只搜索最近的 $nprobe$ 个聚类。召回率由 $nprobe$ 控制,速度提升约 $nlist/nprobe$ 倍。
PQ(Product Quantization):把 $d$ 维向量切分为 $m$ 个子向量,每个子向量用 $k$ 个码本中心近似,用码本索引(如 8bit)代替原始浮点。PQ 可将向量压缩到每维 1bit 量级,极大降低内存,但会引入量化误差。
IVF-PQ 组合是超大索引的标准做法:IVF 负责分区粗筛,PQ 负责压缩存储与距离计算。代价是召回率略降,适合「规模优先、精度可容忍」的场景。
# Milvus 中使用 IVF_SQ8 或 IVF_PQ 索引
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
]
schema = CollectionSchema(fields, description="knowledge base")
collection = Collection("kb", schema)
index_params = {
"index_type": "IVF_SQ8", # 分区 + 标量量化(SQ8)
"metric_type": "COSINE",
"params": {"nlist": 4096}, # 聚类中心数
}
collection.create_index("vector", index_params)
# 查询:nprobe 越大召回越高、延迟越高
search_params = {"metric_type": "COSINE", "params": {"nprobe": 32}}
results = collection.search(
data=[query_vec], anns_field="vector",
param=search_params, limit=20, output_fields=["text"],
)
| 索引 | 内存占用/向量 | 召回 | 建库速度 | 适用规模 |
|---|---|---|---|---|
| 暴力 | 4B×d | 100% | 快 | <10 万 |
| HNSW | 4B×d + 图边 | 高 | 慢 | 千万级 |
| IVF | 4B×d + 聚类 | 中高 | 中 | 亿级 |
| IVF-PQ | <1B×d | 中 | 中 | 亿级+ |
主流方案对比
向量数据库选型要考虑部署形态(托管 vs 自建)、生态、混合检索支持与运维成本。
| 方案 | 类型 | 混合检索 | 分布式 | 生态/易用性 | 典型场景 |
|---|---|---|---|---|---|
| Milvus | 独立服务 | 支持(+ 稀疏) | 强(云原生) | 中 | 大规模生产、GPU 加速 |
| Qdrant | 独立服务 | 支持(Rust 高性能) | 强 | 高 | 中大规模、RAG 默认 |
| Pinecone | 托管 SaaS | 支持 | 全托管 | 极高 | 快速上线、无运维团队 |
| pgvector | PostgreSQL 扩展 | 需自行融合 | 随 PG | 高 | 已有 PG、数据同库 |
| Weaviate | 独立服务 | 支持 | 中 | 高 | 多模态、GraphQL |
| Elasticsearch | 检索引擎 | 原生 BM25+向量 | 强 | 高 | 既有 ES 栈、日志+语义 |
选型决策树:
- 已有 PostgreSQL 且数据量不大 → pgvector,避免引入新组件
- 已有 Elasticsearch 且需要全文+语义 → ES kNN(dense_vector 字段)
- 千万级规模、需要独立扩展 → Qdrant 或 Milvus
- 无运维团队、要快速上线 → Pinecone 托管
- 需要 GPU 加速暴力检索/超大索引 → Milvus(支持 GPU index)
# pgvector 最小示例
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
content text,
embedding vector(1024) -- 向量列
);
-- 建 HNSW 索引(pgvector 0.5+)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- 检索 Top-5
SELECT id, content, 1 - (embedding <=> $1) AS similarity
FROM documents
ORDER BY embedding <=> $1 -- <=> 为余弦距离
LIMIT 5;
索引与量化参数调优
选型之后,参数调优决定实际性能。调优遵循「先定召回目标,再压延迟」的顺序:
Step 1:确定召回率基线。用一组标准查询,暴力 KNN 的 Top-10 作为 ground truth,计算 ANN 的 Recall@10。生产目标通常 ≥95%(Top-K 命中率)。
Step 2:扫描 efSearch / nprobe。从小值开始逐步放大,记录「召回率-延迟」曲线,找到拐点(延迟突增但召回几乎不再上升的位置)。
import time
import numpy as np
from qdrant_client import QdrantClient, models
def recall_at_k(ground_truth, preds, k=10):
gt = set(ground_truth[:k])
hit = len(gt.intersection(preds[:k]))
return hit / k
def tune_efsearch(client, queries, ef_range, k=10):
for ef in ef_range:
latencies, recalls = [], []
for q, gt in queries:
t0 = time.perf_counter()
res = client.search(collection_name="kb", query_vector=q,
limit=k, search_params=models.SearchParams(ef=ef))
latencies.append((time.perf_counter() - t0) * 1000)
recalls.append(recall_at_k(gt, [r.id for r in res], k))
print(f"efSearch={ef}: recall={np.mean(recalls):.3f}, p95={np.percentile(latencies, 95):.1f}ms")
Step 3:按场景分配资源:
| 场景 | 优先方向 | 典型配置 |
|---|---|---|
| 高召回(知识库问答) | 召回优先 | HNSW M=32, efSearch=512 |
| 低延迟(实时推荐) | 延迟优先 | HNSW M=16, efSearch=64 |
| 大容量(日志/全量) | 内存优先 | IVF-PQ + SQ8 |
| 动态写入频繁 | 写放大小 | HNSW(动态优于 IVF 重建) |
调参铁律:没有免费的召回率。每提升 1% 召回通常以 10%-30% 延迟为代价,应根据业务红线(如 P99 < 30ms)反推可接受的最大 efSearch,而不是盲目调大。
混合过滤:标量 + 向量
生产检索几乎总是「向量相似 + 标量约束」的组合:按时间过滤、按租户过滤、按文档类型过滤、按权限过滤。标量与向量组合有两条路径:
Pre-filtering(先过滤再检索):先按标量条件缩小候选集,再在候选集内做向量检索。适合过滤后候选集仍较大、或过滤条件非常选择性(如按租户)的场景。
Post-filtering(先检索再过滤):先向量检索 Top-100,再丢弃不满足标量条件的记录。适合过滤条件弱、向量检索本身召回高的场景,但可能因过滤损失有效结果。
# Qdrant 预过滤 + 向量检索
from qdrant_client import QdrantClient, models
client.search(
collection_name="kb",
query_vector=query_vec,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tenant_id", match=models.MatchValue(value="tenant-123")),
models.FieldCondition(
key="updated_at", range=models.Range(
gte=int(start_ts), lte=int(end_ts))),
]
),
limit=20,
search_params=models.SearchParams(hnsw_ef=256),
)
// Milvus 中布尔表达式过滤
{"bool": {
"must": [
{"term": {"tenant_id": "tenant-123"}},
{"range": {"updated_at": {"gte": 1727280000}}}
]
}}
过滤对索引的连锁影响:过滤后候选集可能变得很小,HNSW 的图搜索性能取决于过滤选择性。经验上,预过滤后候选 <1 万时考虑「暴力 + 过滤」反而更快;候选在 1 万-100 万时 HNSW 高效;更大规模需分区与按标量分区策略。
Embedding 模型选型
向量库的上限由 Embedding 决定。选型要点:
| 考量 | 建议 |
|---|---|
| 领域匹配 | 优先选在领域语料上微调过的模型 |
| 维度 | 高维(1536/3072)精度高、存储贵;低维(256/512)反之 |
| 语言 | 中文场景优先 bge-zh 系列 |
| 批量编码 | 支持 batch + GPU 加速 |
| 版本稳定 | 同源同版,变更需重建索引 |
维度的经济账:1000 万条 1024 维 float32 向量占用 1000万×1024×4B ≈ 40GB;降到 512 维且 int8 量化后约 5GB,成本差 8 倍。若不追求极限精度,降维 + 量化是容量规划的常用手段。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-m3", device="cuda")
def batch_encode(texts: list[str], batch_size: int = 64):
embeddings = model.encode(
texts, batch_size=batch_size, normalize_embeddings=True,
convert_to_numpy=True,
)
return embeddings.astype(np.float16) # 存储用 FP16 减半显存
docs = ["向量数据库选型要点", "RAG 检索优化实践"]
vecs = batch_encode(docs)
print(vecs.shape, vecs.dtype) # (2, 1024) float16
不要反复切换 Embedding 模型:每次切换都要全量重建索引,代价是几小时到几天。先离线用小样本对比候选模型的检索命中率,选定后再投入全量索引。
召回率与延迟权衡及容量规划
召回率-延迟-成本是向量检索的三角约束,容量规划要把三者换算成数字:
延迟预算拆解:一次语义检索的延迟 = 查询 Embedding(几 ms)+ 向量检索(几 ms~几十 ms)+ Reranker(几十 ms)+ LLM 生成(秒级)。向量检索通常只占总预算的一小部分,别为了省几 ms 牺牲召回。
容量估算公式:
$$内存 \approx N \times d \times (bytesPerDim) + 索引边开销$$
| 规模 N | 维度 d | 精度 | 估算内存 | 建议方案 |
|---|---|---|---|---|
| 100 万 | 1024 | FP32 | ~4.1GB | HNSW / pgvector |
| 1000 万 | 1024 | FP16 | ~20GB | Qdrant / Milvus HNSW |
| 1 亿 | 1024 | PQ/SQ8 | ~10-15GB | Milvus IVF-PQ |
| 10 亿 | 768 | PQ+分区 | ~100GB | Milvus 分布式 |
水平扩展:单机索引容量到顶后,按分片(Sharding)水平扩展。分片策略优先按向量 ID 哈希(均匀分布),若带强租户维度可考虑租户分片(提高过滤后的局部性)。
# Milvus 分布式容量规划参考
clusters:
query_nodes:
replica: 2 # 查询副本数
resources:
cpu: "16"
memory: "64Gi"
limits:
maxMemory: "48Gi" # 留给索引 + 检索缓冲
index_nodes:
cpu: "8"
memory: "32Gi"
生产运维与监控
向量库进入生产后,运维重点从「建库」转向「持续健康」。
写入与一致性:增量写入要设置合理的 flush 周期与索引构建策略(HNSW 动态插入不重建,IVF 需定期重建聚类中心)。写入高峰与查询高峰的资源冲突需要通过副本隔离。
关键监控指标:
| 指标 | 含义 | 告警阈值建议 |
|---|---|---|
| 检索 P95 延迟 | 查询性能 | > 50ms 关注 |
| 召回率漂移 | 抽样查询对比暴力 KNN | 下降 >2% 告警 |
| 段/分片数量 | 索引碎片化程度 | 过多需合并 |
| 索引构建排队 | 写入堆积 | 队列 >1 小时告警 |
| 磁盘 IO / 内存水位 | 资源健康 | 内存 >85% 关注 |
数据生命周期:向量库需要 TTL 或定期归档过期数据(如新闻稿 90 天后失效)。删除策略上注意 HNSW 图的「墓碑」(tombstone)机制——删除会在图上留标记,过多删除建议定期重建索引恢复查询效率。
# Qdrant 定期清理过期 collection 片段的简单脚本(cron)
#!/bin/bash
COLLECTION="news_kb"
BEFORE_TS=$(date -d "-90 days" +%s)
curl -s -X POST http://localhost:6333/collections/$COLLECTION/points/delete \
-H "Content-Type: application/json" \
-d "{\"filter\": {\"must\": [{\"key\": \"published_at\", \"range\": {\"lt\": $BEFORE_TS}}]}, \"wait\": true}"
生产铁律:向量库的「召回率」是隐性质量指标,而延迟是显性指标。多数故障(索引碎片、过滤选择性差、版本不匹配)都先表现为召回率漂移,再表现为延迟劣化,因此必须把抽样召回率纳入监控。
总结
| 决策点 | 关键考量 | 推荐 |
|---|---|---|
| ANN 索引 | 规模 × 召回 × 内存 | HNSW(默认)/ IVF-PQ(超大) |
| 方案选型 | 已有技术栈 × 规模 × 运维 | 已有 PG→pgvector;独立→Qdrant/Milvus |
| 距离度量 | 语义场景 | Cosine(文本默认) |
| 参数调优 | 先定召回再压延迟 | efSearch/nprobe 扫描拐点 |
| 过滤 | 选择性 × 候选集大小 | Pre-filter(高选择)/ Post-filter(弱约束) |
| Embedding | 维度 × 语言 × 版本稳定 | bge-m3 / text-embedding-3 |
| 容量 | N × d × bytes | 降维 + 量化 + 分片 |
| 运维 | 召回漂移 × 索引碎片 | 抽样召回监控 + 定期重建 |
向量数据库选型的本质是在召回率、延迟、成本三者间为业务找到最优解,而调优的全部功夫在于用可量化的 Recall@K 与 P95 延迟驱动决策,而非依赖直觉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。