Administrator
发布于 2025-12-24 / 830 阅读
11

向量检索与结构化查询的混合架构

向量检索选型:我们算了一笔账,最后没上专业向量库

十月份做知识库检索重构,团队里争论要不要上 Milvus 或者 Qdrant。我们已经在用 PostgreSQL(存业务数据 + 元数据),pgvector 扩展是顺手的选择,但大家都担心性能不行。

最后我们做了完整的压测和成本核算,结论是继续用 pgvector。这篇记录测试数据、混合查询的 SQL 写法,以及什么情况下应该换专业向量库。

先说数据规模

选型的前提是量级,不同量级结论完全不同。我们的情况:

项目数值
文档总数21 万篇
向量切片数184 万条
向量维度1024(bge-large-zh)
日均查询量34 万次
峰值 QPS142
数据日增量约 2,400 条

这个量级属于「中等偏小」。185 万条 1024 维向量,纯向量数据 7.5 GB,加上 HNSW 索引大概 12 GB。

压测对比

我们搭了三套环境,用真实的生产查询日志回放(采样 5 万条真实 query)。硬件都是 32C64G + NVMe SSD。

方案P50P95P99召回率@10内存占用写入吞吐
pgvector HNSW8ms31ms74ms96.2%18 GB1,100 条/秒
pgvector IVFFlat12ms48ms121ms91.4%11 GB2,400 条/秒
Milvus 2.53ms9ms22ms98.1%26 GB8,500 条/秒
Qdrant 1.134ms13ms29ms97.6%21 GB6,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
配置召回率@10P95 延迟索引大小构建耗时
m=16, ef_c=64, ef_s=40(默认)94.1%18ms9.2 GB34 分钟
m=16, ef_c=64, ef_s=10096.2%31ms9.2 GB34 分钟
m=16, ef_c=128, ef_s=10097.4%32ms9.2 GB61 分钟
m=32, ef_c=128, ef_s=10098.3%47ms14.1 GB96 分钟

我们选了第二行。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;

性能优化清单

除了索引参数,还做了这些:

  1. 降维。从 1024 维降到 768 维(换 bge-base-zh),召回率只掉 1.3pp,但索引小 25%、查询快 18%、内存省 28%。这个取舍很划算。
  2. 向量量化。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 / P997ms / 19ms / 48ms
端到端首字延迟 P951.34s
检索环节占比1.4%
召回率@10(离线评估)95.8%
PG 实例 CPU峰值 47%
索引总大小5.1 GB
额外硬件成本内存 32G→64G,约 ¥420/月

一天 34 万次查询,CPU 峰值 47%,还有一倍以上的余量。

就写到这。如果哪天你也被《向量检索与结构化查询的混合架构》里同一个坑绊住,回来翻这篇,能省半小时。

参考