向量检索选型:我们算了一笔账,最后没上专业向量库
十月份做知识库检索重构,团队里争论要不要上 Milvus 或者 Qdrant。我们已经在用 PostgreSQL(存业务数据 + 元数据),pgvector 扩展是顺手的选择,但大家都担心性能不行。
最后我们做了完整的压测和成本核算,结论是继续用 pgvector。这篇记录测试数据、混合查询的 SQL 写法,以及什么情况下应该换专业向量库。
先说数据规模
选型的前提是量级,不同量级结论完全不同。我们的情况:
| 项目 | 数值 |
|---|---|
| 文档总数 | 21 万篇 |
| 向量切片数 | 184 万条 |
| 向量维度 | 1024(bge-large-zh) |
| 日均查询量 | 34 万次 |
| 峰值 QPS | 142 |
| 数据日增量 | 约 2,400 条 |
这个量级属于「中等偏小」。185 万条 1024 维向量,纯向量数据 7.5 GB,加上 HNSW 索引大概 12 GB。
压测对比
我们搭了三套环境,用真实的生产查询日志回放(采样 5 万条真实 query)。硬件都是 32C64G + NVMe SSD。
| 方案 | P50 | P95 | P99 | 召回率@10 | 内存占用 | 写入吞吐 |
|---|---|---|---|---|---|---|
| pgvector HNSW | 8ms | 31ms | 74ms | 96.2% | 18 GB | 1,100 条/秒 |
| pgvector IVFFlat | 12ms | 48ms | 121ms | 91.4% | 11 GB | 2,400 条/秒 |
| Milvus 2.5 | 3ms | 9ms | 22ms | 98.1% | 26 GB | 8,500 条/秒 |
| Qdrant 1.13 | 4ms | 13ms | 29ms | 97.6% | 21 GB | 6,200 条/秒 |
Milvus 确实快 3~4 倍。但要看这个差距在端到端链路里的占比——我们一次检索的完整链路是:
用户提问 → [8ms] embedding 计算
→ [12ms] 向量检索 ← pgvector 8ms / Milvus 3ms
→ [45ms] rerank 重排 ← 本地 bge-reranker
→ [1.2s] LLM 生成 ← 流式首字延迟
────────────────────────
总计约 1.27s
向量检索这 5ms 的差距,在 1.27 秒的链路里占 0.4%。优化它对用户体验毫无影响。
这是第一个决策依据:先看瓶颈在哪,别一上来就换存储。我们当时如果没测就换,会付出很大成本换来 0.4% 的提升。
成本对比
这是第二个,也是更决定性的依据。
| 项目 | pgvector(复用现有 PG) | Milvus 独立集群 |
|---|---|---|
| 硬件成本 | +0(扩容现有 PG 内存 32G→64G) | 3 节点 × 32C64G,约 ¥14,400/月 |
| 运维投入 | +0(已有 PG 运维体系) | 新组件,监控/备份/升级/故障处理 |
| 数据同步 | 无(同一库,事务一致) | 需要 CDC 或双写,有延迟 |
| 学习成本 | SQL,团队都会 | 新 API、新概念(collection/partition/segment) |
| 人力成本 | 约 0.5 人月 | 约 2.5 人月(含上线后三个月的磨合) |
光硬件一年就差 17 万,再加人力。而我们这个项目的总预算是 60 万。
隐含成本里最容易被低估的是数据同步。我们的检索需要过滤「已下线文档」「无权限文档」「过期文档」,这些属性存在业务表且会频繁变化。如果向量库独立,就要做双向同步,出现不一致时排查很痛苦。用 pgvector 的话这些就是普通的 SQL JOIN,天然一致。
混合查询:pgvector 最大的优势
这是真正让我下定决心的技术原因。看一个真实的查询需求:
找和「退款流程」相关的文档,只要客服部门可见的、2025 年之后更新的、状态为已发布的,返回相似度最高的 10 条。
用 pgvector 就是一条 SQL:
SELECT d.id, d.title, d.updated_at,
d.embedding <=> :query_vec AS distance
FROM kb_doc d
JOIN doc_acl a ON a.doc_id = d.id
WHERE d.status = 'PUBLISHED'
AND d.updated_at >= '2025-01-01'
AND a.dept_code = :dept
AND d.embedding <=> :query_vec < 0.35 -- 相似度阈值
ORDER BY d.embedding <=> :query_vec
LIMIT 10;
换成专业向量库,这类「向量 + 结构化过滤」的混合查询要分两步:先在向量库里召回一批(比如 100 条),再拿 ID 回业务库过滤,最后可能不足 10 条还要再查一轮。Milvus 有 expr 过滤表达式,但只支持它自己存的标量字段,复杂权限逻辑还是得回主库。
我们统计过,实际查询里 83% 带至少一个结构化过滤条件。这是知识库场景和企业内部检索的典型特征——不像公开的语义搜索,可以纯向量。
HNSW 索引下的过滤陷阱
这里有个必须知道的坑。HNSW 是图索引,它的遍历过程无法利用 WHERE 条件做提前剪枝。上面那条 SQL 的实际执行可能是:
EXPLAIN ANALYZE SELECT ... FROM kb_doc d JOIN doc_acl a ...
-- 实际计划
Limit (cost=... rows=10) (actual time=6.4..71.3 rows=7 loops=1)
-> Index Scan using idx_embedding_hnsw on kb_doc d
Order By: (embedding <=> $1)
Filter: ((status = 'PUBLISHED') AND (updated_at >= ...))
Rows Removed by Filter: 3,412 <- 扫了 3419 条才凑够 10 条
Planning Time: 0.4 ms
Execution Time: 71.3 ms
Rows Removed by Filter: 3,412——如果过滤条件很严格(比如只有 0.5% 的文档符合权限),HNSW 要扫描大量节点才能凑够 LIMIT 的数量,性能会急剧下降,甚至退化成全表扫描。
我们遇到最极端的一个 case:某个部门只有 900 篇可见文档(占总量 0.4%),查询耗时 2.4 秒。
解决方案有两个,我们两个都用上了。
方案一:部分索引(Partial Index),把过滤条件下推到索引里:
-- 为高频的部门+状态组合建部分索引
CREATE INDEX idx_kb_vec_cs_dept ON kb_doc
USING hnsw (embedding vector_cosine_ops)
WHERE status = 'PUBLISHED' AND dept_code = 'CUSTOMER_SERVICE';
-- 查询时带上完全匹配的条件,规划器才会用上这个索引
SELECT ... FROM kb_doc
WHERE status = 'PUBLISHED' AND dept_code = 'CUSTOMER_SERVICE' -- 必须完全匹配
ORDER BY embedding <=> :vec LIMIT 10;
效果:2.4 秒 → 9ms。代价是每个组合一个索引,我们只对 6 个高频部门建了(覆盖 78% 的查询量),其余走通用索引。
方案二:迭代式放宽,在应用层做:
public List<Doc> hybridSearch(float[] vec, Filter f, int limit) {
// 先用严格过滤查,不够就放宽
List<Doc> result = query(vec, f, limit);
if (result.size() >= limit) return result;
// 过滤太严格导致召回不足:放宽相似度阈值,多召回再过滤
result = query(vec, f.withoutSimilarityThreshold(), limit * 8);
if (result.size() >= limit) return result.subList(0, limit);
// 还是不够,去掉时间限制
return query(vec, f.withoutTimeRange(), limit * 20).stream()
.limit(limit).toList();
}
这个是兜底,触发概率约 4%。
索引参数调优
pgvector 的 HNSW 有两个核心参数,默认值偏保守:
CREATE INDEX ON kb_doc USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 查询时控制搜索广度
SET hnsw.ef_search = 100; -- 默认 40
| 配置 | 召回率@10 | P95 延迟 | 索引大小 | 构建耗时 |
|---|---|---|---|---|
| m=16, ef_c=64, ef_s=40(默认) | 94.1% | 18ms | 9.2 GB | 34 分钟 |
| m=16, ef_c=64, ef_s=100 | 96.2% | 31ms | 9.2 GB | 34 分钟 |
| m=16, ef_c=128, ef_s=100 | 97.4% | 32ms | 9.2 GB | 61 分钟 |
| m=32, ef_c=128, ef_s=100 | 98.3% | 47ms | 14.1 GB | 96 分钟 |
我们选了第二行。ef_search 从 40 提到 100 是性价比最高的改动,召回率 +2.1pp,延迟只多 13ms。m 从 16 提到 32 收益最小(+0.9pp)但代价最大(索引大 53%,延迟翻倍)。
ef_search 是会话级参数,可以按查询设置。我们对两类查询用了不同值:
-- 交互式查询(用户等着看结果):低延迟优先
SET LOCAL hnsw.ef_search = 64;
-- 批处理/离线评估:召回率优先
SET LOCAL hnsw.ef_search = 200;
性能优化清单
除了索引参数,还做了这些:
- 降维。从 1024 维降到 768 维(换 bge-base-zh),召回率只掉 1.3pp,但索引小 25%、查询快 18%、内存省 28%。这个取舍很划算。
- 向量量化。pgvector 支持 halfvec 类型(半精度浮点),存储空间减半,查询快约 15%,召回率损失小于 0.5%:
ALTER TABLE kb_doc ALTER COLUMN embedding TYPE halfvec(768);
CREATE INDEX ON kb_doc USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);
3. 分区表。按 updated_at 做范围分区,最近一年的数据在热分区,可以被完整缓存。老数据查询频率极低。
CREATE TABLE kb_doc (...) PARTITION BY RANGE (updated_at);
CREATE TABLE kb_doc_2025 PARTITION OF kb_doc
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE kb_doc_2024 PARTITION OF kb_doc
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
-- ...
4. 连接池和并行度。向量查询是 CPU 密集的,我们把 PG 的 max_parallel_workers_per_gather 设为 0(向量索引扫描不支持并行,配置它只会浪费资源),同时把连接池上限从 50 降到 24(匹配 CPU 核数)。
这四项做完,P95 从 31ms 降到 19ms,索引从 9.2 GB 降到 5.1 GB。
什么情况下应该上专业向量库
我们的结论不适用于所有场景。以下几个信号出现时,应该考虑换:
| 信号 | 阈值(我们的经验) |
|---|---|
| 向量数量 | > 2000 万条(pgvector 单表会明显吃力) |
| 写入吞吐 | 持续 > 5000 条/秒(pgvector HNSW 写入是瓶颈) |
| 查询 QPS | > 2000(需要分布式横向扩展) |
| 结构化过滤复杂度 | 很低(几乎不用过滤,纯向量检索) |
| 多租户隔离 | 需要强隔离(Milvus 的 partition/collection 天然支持) |
反过来说,如果你的场景是「千万级以下 + 大量结构化过滤 + 已有 PostgreSQL」,pgvector 几乎一定是对的。
还有一点:pgvector 的写入性能是它最弱的一环。我们导入 184 万条数据花了 42 分钟(HNSW 索引构建占 34 分钟)。如果数据频繁全量重建,这个会很痛。我们的做法是增量写入 + 每季度重建一次索引,重建时走从库切换,切换窗口约 8 秒。
另外提醒一个容易忽略的点:HNSW 索引的构建是单线程且吃满内存的,构建期间 PG 实例的内存峰值会很高。我们第一次在主库上直接建索引,内存打满触发了 OOM,后来改成在从库建完再切主。
上线的最终数据
生产环境跑了两个月:
| 指标 | 数值 |
|---|---|
| 检索 P50 / P95 / P99 | 7ms / 19ms / 48ms |
| 端到端首字延迟 P95 | 1.34s |
| 检索环节占比 | 1.4% |
| 召回率@10(离线评估) | 95.8% |
| PG 实例 CPU | 峰值 47% |
| 索引总大小 | 5.1 GB |
| 额外硬件成本 | 内存 32G→64G,约 ¥420/月 |
一天 34 万次查询,CPU 峰值 47%,还有一倍以上的余量。
就写到这。如果哪天你也被《向量检索与结构化查询的混合架构》里同一个坑绊住,回来翻这篇,能省半小时。