Skip to main content

Ragent为什么从Milvus换成Pgvector

为什么从 Milvus 换成了 Pgvector

不少同学跟着 RAG 系列一路学下来,前面讲向量数据库的时候,花了挺大篇幅讲 Milvus——从 Docker Compose 启动、HNSW 索引参数到标量过滤,demo 代码一行一行跑完。结果翻开 Ragent 的源码,却发现项目里主力用的是 PostgreSQL 的 pgvector 扩展。

有同学在群里问我:“教程里用的 Milvus,怎么后面反而不用了?”

这个问题我必须得单独拎出来聊一聊,不然容易被误解 Milvus 不如 pgvector。

先把话说清楚:Milvus 没有被否定

在回答为什么换之前,先把一件事摆正:Ragent 从 Milvus 换到 pgvector,不是因为 Milvus 不好

RAG 系列里写过的那些 Milvus 优势,放在现在依然成立:

  • 专为向量检索而生,从存储布局到查询引擎都是围绕向量设计的
  • HNSW、IVF_FLAT、IVF_SQ8、DISKANN……索引类型几乎一网打尽,不同数据规模都能找到合适的算法
  • Java SDK 成熟,v2 API 设计清晰
  • 数据规模覆盖广,Standalone 单机模式可以撑百万级,集群模式能扛到十亿级
  • 支持标量字段过滤、Partition 分区、BM25 原生全文检索、混合检索 RRF 融合

所以在 RAG 教学里我依然推荐大家先用 Milvus 跑通一遍——它是一个功能最全的专业向量库,原理讲清楚了之后,换任何一个向量库都是变成另一个 API 的事。

但“最全”不等于“最合适”。Ragent 是一个真实要部署、要运维、要让读者能跑起来、要长期迭代的项目,选型的优先级和教学 demo 完全不一样。

一句话概括:教学上我推荐 Milvus,是因为它能把向量检索的原理和能力完整地展示出来;工程上 Ragent 选 pgvector,是因为在它要解决的问题上,pgvector 的综合成本更低。

Milvus 在真实项目里的代价

Milvus 的能力是实打实的,但这些能力不是免费的。把它丢进一个真实项目里,有几个代价你绕不过去。

1. 部署组件多,运维成本不低

一个 Milvus Standalone,不是你 docker run 一个镜像就完事的。翻开我们之前教程里那份 docker-compose.yml,你会看到至少拉起来四个容器:

组件作用能省吗
milvus-standaloneMilvus 服务本体不能
etcd存储 Milvus 的集群元数据不能
minio / rustfs对象存储,放索引文件和日志不能
attu(可选)可视化管理面板能,但你大概率会装

这还只是单机版。一旦上集群模式,还会多出 pulsar / kafka、proxy、query node、data node、index node 等一堆角色。每一个新组件,都是一份额外的运维负担:要加监控、要留日志、要考虑备份恢复、要做版本兼容。

对一个小团队或一个希望让读者能够快速跑起来的项目而言,拉起来四五个容器才能跑起来向量检索,心智成本真的不低。

2. 对部署环境有隐性要求

Milvus 的 Standalone 镜像对运行环境是挑食的。你去翻它的 docker-compose.yml,会看到这样一行:

security_opt:
- seccomp:unconfined

这不是随手加的——Milvus 里某些向量计算走的是比较底层的 SIMD 指令和内存映射,默认的 seccomp 策略会拦掉部分系统调用,必须放开才能正常跑。在一些受限的宿主环境(公司统一的 K8s 集群、企业内网服务器、某些云厂商的托管容器服务)里,放开 seccomp 需要走审批,这就是一道坎。

本地开发也有坑:Windows + WSL2 下挂载卷的路径有时候会让 etcd 起不来;Mac 下 arm64 架构对某些 Milvus 版本的镜像兼容性偶尔翻车;Linux 内核太老的机器上 pulsar / mmap 可能报奇怪的错。这些问题都能解决,但每一个都会消耗同学们不少时间。

我自己最初在 Linux 上跑 Milvus 也折腾过——一个容器起不来,查日志、翻 issue、改配置,花了一上午才搞定。最后发现是不兼容 Centos 的版本,如果有些同学线上服务器是这种,再换成本就很高了。

3. 资源占用大,尤其是内存

Milvus 的 HNSW 索引是常驻内存的。之前讲解里我给过一个粗估:100 万个 4096 维向量,HNSW(M=16)大约要 16~20 GB 内存。

这意味着——你的向量库数据一多,内存就得跟着涨。而且向量数据是和你的业务 MySQL、Redis 互相独占资源的,它不能和业务库共用那台 8 核 16G 的小机器,得单独规划机器预算。

对一个百万 chunk 以下的企业知识库项目来说,这笔预算是不是非花不可?其实未必。

4. 和业务数据库分离,事务一致性要自己兜底

这是 Ragent 换掉 Milvus 最根本的一个原因,单开一节讲。

跨库没有事务:一个真实场景

先看 Ragent 的知识库数据模型。删掉一篇文档的时候,要动的表有这些:

作用
t_knowledge_document文档主表(标题、来源、状态)
t_knowledge_chunk分块表(chunk 文本、位置、hash)
t_knowledge_document_chunk_log分块日志表(审计用)
t_knowledge_vector向量存储表(embedding + metadata)

在 Milvus 方案下,前三张表在 MySQL 里,最后一张向量数据在 Milvus。删除文档这个看似简单的操作,你会发现怎么写都别扭。

最直觉的写法是把 Milvus 调用放进 @Transactional 方法里:

@Transactional(rollbackFor = Exception.class)
public void deleteDocument(String docId) {
// 1. 删 MySQL 里的文档主表
documentMapper.deleteById(docId);
// 2. 删 MySQL 里的分块表(省略了日志表的删除)
chunkMapper.deleteByDocId(docId);
// 3. 删 Milvus 里的向量数据
milvusClient.delete(DeleteReq.builder()
.collectionName("customer_service_chunks")
.filter("doc_id == \"" + docId + "\"")
.build());
}

但 Milvus 根本不参与 JDBC 事务——@Transactional 管得住 MySQL 那两条 delete,管不住第 3 步。如果 step 3 抛异常,MySQL 倒是会回滚(数据没丢),但你白调了前两步;更麻烦的是 step 3 超时但实际 Milvus 侧已经执行了删除这种不确定场景——客户端不知道向量到底删没删,后续是补删还是不补,全靠猜。

所以实际工程中更常见的做法是:先提交 MySQL 事务,再异步删 Milvus

@Transactional(rollbackFor = Exception.class)
public void deleteDocument(String docId) {
documentMapper.deleteById(docId);
chunkMapper.deleteByDocId(docId);
// MySQL 事务提交后,再异步删 Milvus
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
milvusClient.delete(DeleteReq.builder()
.collectionName("customer_service_chunks")
.filter("doc_id == \"" + docId + "\"")
.build());
}
});
}

这样保证了 MySQL 数据的完整性,但引入了一个新问题:afterCommit 里的 Milvus 调用如果失败了怎么办? MySQL 事务已经提交了不能回滚,Milvus 里的向量还留着——变成了脏向量,下次检索还会被捞出来,命中一堆内容已不存在的记录。

反过来也一样尴尬:如果你选择先删 Milvus 再提交 MySQL,Milvus 删成功了但 MySQL 提交失败回滚——业务上看文档还在,但向量没了,永远检索不到。

不管你怎么编排顺序,只要数据分散在两个不参与同一事务的系统里,就必然存在中间状态不一致的窗口

解决方案当然有:本地消息表 + 补偿任务、分布式事务框架(Seata)、定时对账脚本。但每一个方案都在给项目增加复杂度——你要写消息表、要有后台 job 扫脏数据、要处理并发补偿、要考虑对账任务本身的幂等。这些都不是业务逻辑,但都是必须做的工程投入。

对一个企业知识库场景来说,数据一致性的优先级是非常高的——用户删掉一篇敏感文档,结果 5 秒后检索还能出来,这是很严重的合规问题。

解锁付费内容,👉 戳