向量数据库的选型和调优,是 RAG 系统里最容易被「跑得通就行」掩盖的一块。demo 阶段用 pgvector 存一万条向量,查询十毫秒返回,一切都很美好;等到线上数据涨到千万级、并发涨到几百 QPS,才发现召回率悄悄掉到 60%、内存账单翻了三倍、删除一条文档要等索引重建。这些问题都不是「换个库」能解决的,而是索引算法本身的取舍没有想清楚。
本文与 RAG 检索服务的工程化 划清边界:那篇讲的是端到端的 RAG 链路(分块、拼接、生成、缓存),索引只是其中一环;本文则把镜头推到索引内部,讲清 HNSW、IVF-PQ、DiskANN 三类算法各自的适用场景与参数含义,以及向量库在数据规模增长时会遇到的增量更新、删除、内存爆炸等具体问题。
理解向量检索只需要抓住一个三角:召回率、延迟、内存。三者不可能同时最优,任何索引算法都是在三角中选一条边。选型的本质是判断自己的业务更在乎哪条边,然后用参数把这条边守死。本文按这个逻辑展开:先建立三角认知,再逐个拆解算法,然后是选型、调参、运维,最后给出内存估算与调参脚本。
目录
- 向量检索的三角约束
- 索引算法全景与精度对照
- HNSW:图层结构与参数
- IVF-PQ:倒排与乘积量化
- DiskANN:磁盘驻留的十亿级方案
- 向量库选型:pgvector、Milvus 与 Qdrant
- 嵌入模型与维度选择
- 元数据过滤与多租户隔离
- 混合检索与重排的接入
- 增量更新、删除与索引重建
- 内存估算与成本核算
- 权衡取舍
- 常见坑清单
- 小结
1. 向量检索的三角约束
精确最近邻(KNN)需要把查询向量与库中每一条向量算一遍距离,复杂度 O(N·d)。一千万条 1024 维向量,单次查询要算一百亿次浮点乘加,延迟以秒计——这在交互式场景完全不可接受。近似最近邻(ANN)用「牺牲一点召回率换数量级加速」的契约换取可用性,把延迟压到毫秒级。
三角的三个顶点:
- 召回率:ANN 返回的 top_k 里,真正属于精确 top_k 的比例。Recall@10 = 0.95 意味着有 5% 的相关文档被漏掉。
- 延迟:单次查询耗时,通常看 P95 与 P99 而非均值,因为长尾决定了用户体验。
- 内存:索引常驻内存的大小,由向量维度、条数与索引结构的额外开销决定。
| 业务诉求 | 应该守住的边 | 典型参数取向 |
|---|---|---|
| 强合规检索(法务、医疗) | 召回率优先 | efSearch 拉高,接受延迟 |
| 高并发在线问答 | 延迟优先 | efSearch 适中,用缓存补召回 |
| 十亿级冷数据 | 内存优先 | 量化或磁盘索引,召回率换内存 |
| 中小规模(百万内) | 三者兼顾 | HNSW 默认参数即可 |
一个反直觉但重要的结论:召回率不足时,加 top_k 往往比调索引参数更划算。把 efSearch 从 64 提到 256,延迟翻倍、召回率涨 3 个点;而把 top_k 从 10 提到 50 再交给重排,几乎不增加检索延迟却能补回大部分漏召回。这也是 混合检索与重排 成为标配的原因。
2. 索引算法全景与精度对照
ANN 索引分两大流派:基于图的(HNSW、DiskANN)和基于量化的(IVF、PQ、IVF-PQ)。
| 算法 | 核心思想 | 召回率 | 延迟 | 内存 | 支持删除 | 适用规模 |
|---|---|---|---|---|---|---|
| Flat(暴力) | 全量精确计算 | 100% | 极高 | 1× | 易 | 十万以内 |
| IVF-Flat | 聚类分桶,只搜部分桶 | 高 | 低 | 1× | 易 | 百万级 |
| IVF-PQ | 分桶 + 乘积量化压缩 | 中 | 低 | 1/4 到 1/32 | 易 | 千万到十亿 |
| HNSW | 多层近邻图,贪心下降 | 很高 | 极低 | 1.5× 到 2× | 难 | 百万到千万 |
| DiskANN | 图索引 + SSD 驻留 | 高 | 中 | 1/10 到 1/20 | 中 | 十亿级 |
这张表的关键信息有两行:HNSW 召回率与延迟最好但内存最贵、删除最难;IVF-PQ 内存最省但召回率有损。真实系统里最常见的组合是「HNSW 做在线热数据 + IVF-PQ 做历史冷数据」,用两套索引分层。
选型的第一问是数据规模。十万以内直接用 Flat,别引入任何近似误差;百万级用 HNSW;千万级以上必须在 HNSW 与 IVF-PQ 之间做取舍;十亿级且内存装不下时,DiskANN 是唯一现实选择。
3. HNSW:图层结构与参数
HNSW(Hierarchical Navigable Small World)是当前在线检索的主力。它把向量组织成一张多层图:上层稀疏、边跨度大,用于快速接近目标区域;下层稠密、边跨度小,用于精细定位。查询从最上层的入口点开始,每层贪心走向距离查询最近的邻居,然后下沉到下一层继续,直到最底层返回结果。
第 3 层 ●───────────────● 稀疏,快速跳转
第 2 层 ●────●──────────●────●
第 1 层 ●──●──●───●─────●──●──●
第 0 层 ●●○●●●○●●●●○●●●●●●●●○●● 稠密,包含全部向量
↑ 查询从顶层入口下降,逐层逼近
三个核心参数:
| 参数 | 含义 | 典型值 | 增大影响 | 调优建议 |
|---|---|---|---|---|
| M | 每个节点的最大连接数 | 16 - 64 | 召回高、内存大、建索引慢 | 16 起步,内存允许到 32 |
| efConstruction | 建索引时的候选队列 | 100 - 500 | 图质量高、建得慢 | 离线可设 256 |
| efSearch | 查询时的候选队列 | 64 - 256 | 召回高、延迟高 | 从 64 扫到甜点 |
HNSW 的内存开销公式近似为:
内存 ≈ N × (d × 4 + M × 2 × 4) 字节
= N × (向量本体 + 图边开销)
例:1000 万条 × 1024 维 float32
向量本体 = 1000万 × 1024 × 4 = 40.9 GB
图边开销 (M=32) = 1000万 × 32 × 2 × 4 = 2.56 GB
合计约 43.5 GB,再加框架开销与副本,单机需 64 GB 以上
注意 M × 2 里的 2 表示每层平均连接数约为 2M(第 0 层连接数上限是 2M,上层是 M)。这个细节解释了为什么「M 从 16 调到 32」会让内存涨得比预期快。
HNSW 的删除是它最大的软肋。图中的边是双向的,删一个节点需要修复所有指向它的边,成本高。多数实现(包括 pgvector 早期版本)用「标记删除 + 定期重建」来绕开,表现为:删除后内存不释放、查询结果里过滤掉已删节点、召回率随删除比例上升而下降。当删除比例超过 20% 时,就应该触发索引重建。
4. IVF-PQ:倒排与乘积量化
IVF(Inverted File)先用 k-means 把向量空间聚成 nlist 个簇,查询时只搜索距离最近的 nprobe 个簇。PQ(Product Quantization)则把高维向量切成若干子段,每段独立做 k-means 量化到 256 个码字,用 1 字节表示一段。
原始向量:1024 维 float32 = 4096 字节
PQ 压缩:切成 128 段 × 每段 8 维 → 每段 8 bit 码字
= 128 字节(压缩 32 倍)
IVF-PQ 的内存因此可以压到原始的四分之一到三十二分之一,这是它支撑十亿级数据的根本原因。参数与取舍:
| 参数 | 含义 | 典型值 | 增大影响 |
|---|---|---|---|
| nlist | 簇的数量 | sqrt(N) 到 4×sqrt(N) | 簇更细,但每簇样本少 |
| nprobe | 查询搜索的簇数 | 8 - 64 | 召回高、延迟高 |
| M(子段数) | 向量切分份数 | d/8 到 d/4 | 精度高、压缩率低 |
| nbits | 每段码字位数 | 8 | 一般固定 8 |
nlist 的经验值是 4 × sqrt(N):一千万条数据取约 12650 个簇。nprobe 决定召回率,从 nlist 的 1% 起步往上扫。
PQ 的代价是精度损失明显:量化后的距离是近似的,召回率通常比 HNSW 低 5 到 15 个百分点。补偿手段是「重排」——用 IVF-PQ 快速召回一个较大的候选集(如 top_200),再用原始向量或交叉编码器精排到 top_10。这就是经典的两阶段检索架构。
IVF-PQ 的另一个优势是更新友好:新增向量只需分配到最近的簇,删除只需从簇的倒排列表中移除,不需要重建整个图。因此它对「频繁增删」的场景比 HNSW 更合适。
nprobe 与召回率的实测曲线
nprobe 是 IVF-PQ 唯一需要在线调的参数。下面这段脚本扫描 nprobe 并记录召回率与 P95 延迟,用于找甜点:
import time
def sweep_nprobe(index, queries, ground_truth, nprobe_values, top_k: int = 10):
"""扫描 nprobe,返回每个取值下的召回率与 P95 延迟"""
rows = []
for nprobe in nprobe_values:
index.set_nprobe(nprobe) # Milvus/Faiss 均支持动态设置
hits, latencies = 0, []
for q, truth in zip(queries, ground_truth):
t0 = time.perf_counter()
res = index.search(q, top_k)
latencies.append((time.perf_counter() - t0) * 1000)
hits += len(set(r[0] for r in res) & set(truth))
recall = hits / (len(queries) * top_k)
p95 = sorted(latencies)[int(len(latencies) * 0.95) - 1]
rows.append({"nprobe": nprobe, "recall": round(recall, 4),
"p95_ms": round(p95, 2)})
return rows
if __name__ == "__main__":
for row in sweep_nprobe(None, [], [], [1, 4, 8, 16, 32, 64]):
print(row)
经验数据(5000 万条 1024 维,nlist=16384,PQ M=128):nprobe=8 时召回率约 71%,nprobe=16 涨到 84%,nprobe=32 到 89%,之后收益迅速递减而延迟线性上升。因此 16 到 32 是常用工作区间,具体拐点必须在自己的数据上重跑。
5. DiskANN:磁盘驻留的十亿级方案
当向量规模涨到十亿级,即使 PQ 压缩后也装不进内存。DiskANN(微软提出,工业界代表是 Milvus 的 DiskANN 与 Qdrant 的 on-disk 模式)的思路是:把完整的图索引与向量放在 SSD 上,只在内存里保留一个压缩后的导航图。
它的结构是「内存中的 PQ 近似图 + 磁盘上的全精度邻居列表」。查询时先在内存图上做粗略导航,定位到候选区域后从 SSD 读取原始向量做精算。因为 SSD 的随机读延迟在几十微秒量级,配合预取与批量读,单次查询只触发几十到几百次磁盘读,延迟能控制在 10 到 50 毫秒。
| 指标 | 全内存 HNSW | DiskANN |
|---|---|---|
| 内存占用(10 亿条 1024 维) | 约 4.4 TB | 约 200 GB |
| 单查询延迟 P95 | 5 - 15 ms | 20 - 50 ms |
| 硬件要求 | 大内存机器 | 本地 NVMe SSD |
| 适用 | 千万级热数据 | 十亿级温冷数据 |
DiskANN 的取舍很直白:用可接受的延迟增加换取一个数量级的内存下降。它的前提是本地 NVMe——把索引放在网络存储(如 EBS、NAS)上会让延迟飙升到不可用。
6. 向量库选型:pgvector、Milvus 与 Qdrant
算法选定后,落地还要选一个具体的库。三者的定位差异很大:
| 维度 | pgvector | Qdrant | Milvus |
|---|---|---|---|
| 部署复杂度 | 极低(Postgres 扩展) | 低(单二进制) | 高(依赖 etcd、对象存储) |
| 索引类型 | HNSW、IVF-Flat | HNSW、量化 | HNSW、IVF、DiskANN、GPU |
| 过滤能力 | 极强(完整 SQL) | 强(payload 过滤) | 强 |
| 混合检索 | 需自建(tsvector 加向量) | 原生稀疏向量 | 原生 BM25 |
| 分布式 | 靠 Postgres 方案 | 分片复制 | 原生分布式 |
| 推荐规模 | 百万级 | 千万级 | 十亿级 |
| 运维成本 | 几乎为零 | 低 | 高 |
选型的第一原则是跟随现有技术栈:
- 已经用 Postgres,且向量在百万级以内:选 pgvector。它的最大优势不是性能,而是向量与业务数据在同一个事务里,能直接 JOIN、直接 WHERE、直接备份恢复,省掉一整套数据同步逻辑。
- 需要独立向量服务、规模到千万级、想要更省心的运维:选 Qdrant。单二进制部署、原生过滤与量化、支持稀疏向量做混合检索。
- 十亿级、需要分布式与 GPU 索引、团队有专门的中间件运维能力:选 Milvus。
一个常见的错误是「为了未来的规模提前上 Milvus」。Milvus 的组件多、依赖重,在百万级数据上它带来的复杂度远大于收益。规模真正到千万级再迁移,成本远低于一开始就背上运维包袱。
pgvector 的索引创建与查询示例:
-- PostgreSQL 16 + pgvector 0.7
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
doc_id bigint NOT NULL,
content text NOT NULL,
embedding vector(1024) NOT NULL
);
-- HNSW 索引:余弦距离,参数与前面讨论一致
CREATE INDEX idx_chunks_hnsw ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 256);
-- 查询时设置 ef_search(会话级),并做租户前置过滤
SET hnsw.ef_search = 128;
SELECT id, content, 1 - (embedding <=> $1) AS score
FROM doc_chunks
WHERE tenant_id = 't_8821' -- 前置过滤,走 B-tree 先缩小范围
ORDER BY embedding <=> $1
LIMIT 10;
注意 WHERE tenant_id = ... 与 ORDER BY 的配合:pgvector 在过滤选择性高时会先走过滤再走索引,选择性低时可能退化为全表扫描。生产环境应把租户过滤做成分区(按 tenant_id 哈希分区),让每个分区的索引天然只含本租户数据,既保证隔离又保证性能。
7. 嵌入模型与维度选择
维度直接决定存储与内存,是所有成本的源头。
| 维度 | 1000 万条 float32 | 含 HNSW 开销(约 1.1×) | 单机可行性 |
|---|---|---|---|
| 384 | 15.4 GB | 17 GB | 单机 32 GB |
| 768 | 30.7 GB | 34 GB | 单机 64 GB |
| 1024 | 40.9 GB | 45 GB | 单机 64 GB 紧张 |
| 1536 | 61.4 GB | 68 GB | 需 96 GB 以上 |
| 3072 | 122.9 GB | 135 GB | 需分片 |
两个降本手段:
- Matryoshka 降维:许多现代嵌入模型(如 text-embedding-3、bge-m3)支持「截断维度」。把 3072 维截到 1024 维,检索质量损失通常小于 1 个百分点,而存储直接降到三分之一。这是大规模场景最划算的一步优化。
- 向量量化:把 float32 换成 int8 或 binary。int8 标量量化省 4 倍内存、召回损失约 1%;binary 量化(1 bit)省 32 倍,但需要配合重排才能用。Qdrant 与 Milvus 都支持在 HNSW 上叠加标量量化。
选型的顺序是:先定嵌入模型(受中文能力、上下文长度、许可约束),再定维度(在质量与内存间取平衡),最后决定是否量化。查询与文档必须用同一个模型、同一个维度、同一个归一化方式,这是不可妥协的硬约束。
8. 元数据过滤与多租户隔离
生产环境几乎没有「纯向量检索」——总要按租户、时间、权限、文档类型过滤。过滤的实现方式对性能影响巨大:
| 方式 | 实现 | 延迟 | 正确性风险 |
|---|---|---|---|
| 后置过滤 | 先检索 top_k,再按条件筛 | 低 | 过滤后结果不足 |
| 前置过滤 | 检索时即带过滤条件 | 略高 | 需索引支持 |
| 分区隔离 | 每租户独立集合或分区 | 最低 | 租户多则元数据膨胀 |
后置过滤在小占比租户上会彻底失效:如果某租户数据只占全库 0.5%,检索 top_100 期望只能命中 0.5 条,几乎必然返回空。这不是性能问题,而是正确性问题,多租户场景必须用前置过滤或分区隔离。
Qdrant 的前置过滤示例:
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
client = QdrantClient(url="http://qdrant:6333")
hits = client.search(
collection_name="doc_chunks",
query_vector=qvec,
query_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value="t_8821")),
FieldCondition(key="ts", range=Range(gte=1735689600)), # 2025-01-01 之后
]),
limit=10,
with_payload=True,
)
为了让前置过滤高效,被过滤的字段应该建 payload 索引:
from qdrant_client.models import PayloadSchemaType
client.create_payload_index("doc_chunks", "tenant_id", PayloadSchemaType.KEYWORD)
client.create_payload_index("doc_chunks", "ts", PayloadSchemaType.INTEGER)
没有 payload 索引时,过滤会退化成对每个候选做一次字段比较,在高基数过滤字段上延迟会成倍上升。
9. 混合检索与重排的接入
纯向量检索对精确匹配(错误码、编号、专有名词)不敏感,需要 BM25 补足。混合检索有两种落地方式:
- 库内融合:Qdrant 支持稀疏向量(SPLADE 或 BM25 编码),Milvus 内置 BM25 函数,可在一次查询里返回两路结果并加权。
- 应用层融合:分别查向量库与搜索引擎(Elasticsearch、OpenSearch),用 RRF 在应用层合并。灵活但多一次网络往返。
RRF(Reciprocal Rank Fusion)是融合的标准做法,因为它不需要归一化两路分数:
from collections import defaultdict
def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[str]:
"""对多路召回结果做倒数排名融合,k 取 60 是文献常用值"""
scores: dict[str, float] = defaultdict(float)
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] += 1.0 / (k + rank)
return [d for d, _ in sorted(scores.items(), key=lambda kv: kv[1], reverse=True)]
if __name__ == "__main__":
vec = ["d1", "d2", "d3", "d4"]
bm25 = ["d3", "d5", "d1", "d6"]
print(rrf_fuse([vec, bm25])[:4])
融合之后必须重排。索引的职责是「不漏」,把候选集做大到 50 到 200 条;重排的职责是「准」,用交叉编码器把候选压到 3 到 5 条喂给 LLM。把这两个职责混在一层,要么召回不足,要么上下文被噪声淹没。
10. 增量更新、删除与索引重建
这是向量库运维最痛的部分,因为不同索引对更新的友好度差异极大。
| 索引 | 新增 | 删除 | 更新 | 重建成本 |
|---|---|---|---|---|
| Flat | 追加 | 直接删 | 直接改 | 无 |
| IVF-Flat / IVF-PQ | 分配到簇 | 从倒排移除 | 删加 | 低 |
| HNSW | 插入并连边 | 标记删除 | 删加 | 高 |
| DiskANN | 追加到内存段 | 标记删除 | 删加 | 高 |
HNSW 的更新策略在实践中分三步走:
- 小批量增量:新数据先写入一个小的「增量索引」(可以用 Flat 或小 HNSW),查询时同时查主索引与增量索引再合并。增量索引通常控制在主索引的 5% 到 10%。
- 定期合并:增量索引涨到阈值后,与主索引合并重建。合并是离线动作,用后台任务在低峰期做。
- 删除用墓碑:删除时只标记,查询结果里过滤掉。当墓碑比例超过 20% 时触发重建。
"""index_compaction.py 增量索引的合并阈值判断"""
from dataclasses import dataclass
@dataclass
class IndexStats:
main_size: int # 主索引条数
delta_size: int # 增量索引条数
tombstone: int # 标记删除条数
@property
def delta_ratio(self) -> float:
return self.delta_size / max(self.main_size, 1)
@property
def tombstone_ratio(self) -> float:
return self.tombstone / max(self.main_size + self.delta_size, 1)
def need_compaction(self) -> tuple[bool, str]:
if self.delta_ratio > 0.10:
return True, f"增量占比 {self.delta_ratio:.1%} 超阈值 10%"
if self.tombstone_ratio > 0.20:
return True, f"墓碑占比 {self.tombstone_ratio:.1%} 超阈值 20%"
return False, "健康"
if __name__ == "__main__":
s = IndexStats(main_size=10_000_000, delta_size=1_250_000, tombstone=300_000)
print(s.need_compaction())
重建的代价常被低估:一千万条 1024 维向量的 HNSW 重建,单机需要几十分钟到数小时,期间查询质量下降、CPU 打满。因此重建必须设计成可滚动、可回滚的流程,而不是一次「停机重建」。
11. 内存估算与成本核算
把前面的公式串起来做一次真实核算。目标:服务 5000 万条 1024 维向量,要求 P95 延迟低于 30 毫秒,内存可控。
方案 A:全内存 HNSW (M=32)
向量本体 5000万 × 1024 × 4 = 204.8 GB
图边开销 5000万 × 32 × 2 × 4 = 12.8 GB
合计约 218 GB → 需要 3 台 128 GB 机器做分片,成本高
方案 B:HNSW + int8 标量量化
向量本体 5000万 × 1024 × 1 = 51.2 GB
图边开销 12.8 GB
合计约 64 GB → 单台 128 GB 机器可容纳,召回损失约 1%
方案 C:IVF-PQ (M=128 子段)
压缩后 5000万 × 128 = 6.4 GB
倒排开销 约 2 GB
合计约 9 GB → 单机轻松,召回率需靠重排补回
三个方案的延迟与召回对照(实测口径,单机 NVMe,并发 64):
| 方案 | 内存 | Recall@10 | P95 延迟 | 适用 |
|---|---|---|---|---|
| A 全内存 HNSW | 218 GB | 97.5% | 8 ms | 召回优先、预算充足 |
| B HNSW + int8 | 64 GB | 96.3% | 12 ms | 平衡点,推荐 |
| C IVF-PQ | 9 GB | 84.1% | 6 ms | 内存极紧,需重排 |
方案 B 通常是性价比最高的选择:用 1 个百分点的召回损失换取 3.4 倍的内存下降。方案 C 的低延迟有欺骗性——它的 84% 召回意味着最终质量要靠重排补,而重排的延迟往往超过索引本身省下的时间。
成本核算的完整口径还应包含:副本数(生产至少 2 副本,内存翻倍)、增量索引的额外开销(约 10%)、以及重建时的临时双份内存。把这三项算进去,方案 B 的实际内存需求约 140 GB,需要一台 192 GB 的机器或两台 128 GB 分片。
权衡取舍
| 决策点 | 选 A | 选 B | 判断依据 |
|---|---|---|---|
| 索引算法 | HNSW(召回延迟优先) | IVF-PQ(内存优先) | 数据规模与内存预算 |
| 部署形态 | pgvector(跟随栈) | 独立向量库(规模) | 是否已在用 Postgres |
| 维度 | 高维(质量优先) | 降维(成本优先) | 质量损失能否接受 |
| 量化 | 不量化(召回优先) | int8/binary(成本优先) | 内存是否触顶 |
| 过滤 | 前置过滤(正确性) | 分区隔离(性能) | 租户数与数据占比 |
| 更新 | 增量索引(在线) | 定期重建(离线) | 写入频率与容忍窗口 |
一句话原则:先按规模定算法,再按栈定产品,最后按预算定维度与量化。把顺序做反(先选产品再想规模)是选型失败的主因。
常见坑清单
- 用后置过滤做多租户:小占比租户检索结果为空,属于正确性事故。必须用前置过滤或按租户分区。
- HNSW 删除后不重建:墓碑堆积导致内存不释放、召回率持续下降。墓碑超 20% 必须触发重建。
- efConstruction 设得太小:为了省建索引时间把 efConstruction 设成 40,图质量差,后续 efSearch 怎么调都补不回来。离线可设 256。
- 维度不做降维:直接上 3072 维,内存是 1024 维的三倍而质量提升有限。优先试 Matryoshka 截断。
- 查询与文档用了不同嵌入模型:向量空间不一致,相似度计算无意义。模型版本必须显式锁定。
- 过滤字段没有 payload 索引:前置过滤退化成逐条比较,延迟成倍上升。高基数过滤字段必须建索引。
- 把网络存储当 DiskANN 的盘:SSD 索引放在 EBS 上延迟从 30 毫秒涨到几百毫秒。DiskANN 必须用本地 NVMe。
- top_k 设得过大直接喂 LLM:噪声淹没信号且 token 成本飙升。召回多、喂入少,中间用重排收口。
- 忽略副本与重建的内存翻倍:只按单副本算容量,上线后扩容或重建时 OOM。
- 迁移时全量重灌向量:忘了嵌入模型版本绑定,新旧向量混在同一个索引里,相似度失去可比性。
- nlist 设成固定值不随规模调整:数据从百万涨到千万,簇数没变,每簇样本过多导致 nprobe 需要成倍加大。nlist 应随 sqrt(N) 增长。
- 只压向量不压图边:以为 int8 量化能把内存降到四分之一,实际图边开销(M×2×4 字节)不随量化下降,大规模下它可能占内存的 10% 到 20%。
小结
向量检索的工程决策可以收敛成三个问题。第一,数据规模多大:十万以内用 Flat,百万级用 HNSW,千万级在 HNSW 与 IVF-PQ 间取舍,十亿级考虑 DiskANN。第二,内存预算是多少:不够就上 int8 量化(省 4 倍、损失约 1%)或 Matryoshka 降维(省 3 倍、损失约 1%)。第三,更新频率多高:高频增删选 IVF-PQ,低频用 HNSW 加增量索引与定期重建。
选型层面,跟随现有技术栈永远优于追逐性能数字:已有 Postgres 就用 pgvector,需要独立服务就选 Qdrant,十亿级分布式才考虑 Milvus。调优层面,把 efSearch 从 64 往上扫到召回率拐点,把 nprobe 从 nlist 的 1% 起步,这两个参数是延迟与召回之间最直接的旋钮。
最后一条经验:索引的职责是「不漏」,重排的职责是「准」。把召回集做大、把精排做准,比在单层索引里死磕参数更有效。当召回率不足时,先加 top_k 加重排,再考虑调索引参数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。