向量数据库深度对比:Milvus、Weaviate、Chroma、pgvector的工程选型
向量数据库深度对比:Milvus、Weaviate、Chroma、pgvector的工程选型
适读人群:Java后端工程师、架构师、RAG系统开发者 | 阅读时长:约22分钟 | 依赖:Spring AI 1.0、Docker
开篇故事
去年带团队做了三个不同规模的RAG项目,分别用了三种不同的向量数据库,每一个都有让我又爱又恨的地方。
第一个项目是内部文档问答,文档量不到5万条,团队技术栈是纯Spring Boot,运维同学对新基础设施非常抗拒。我选了pgvector——直接在现有的PostgreSQL里开个扩展,运维零学习成本,Java这边用JPA就能操作,开发效率极高。项目跑得很顺,没有什么特别的问题。
第二个项目是电商商品的语义搜索,商品数量500万,需要支持按类目、品牌、价格区间的元数据过滤,还要求毫秒级响应。这次我用了Milvus,分布式架构,支持超大规模向量,性能确实很强,但运维复杂度让运维团队叫苦连天,依赖Etcd和MinIO,光搭环境就花了三天。
第三个项目是快速原型验证,需要在两周内做出一个RAG演示。选了Chroma,纯Python写的,Python侧直接调用,但Java这边只能通过HTTP client调用,而且Chroma的Java支持不算太好,遇到了几个Bug。
三个项目踩完,我觉得有必要把这几个向量库的工程特性系统地对比一下,给大家一个选型参考。
一、核心问题分析
向量数据库选型,本质上是在以下几个维度做权衡:
规模与性能:数据量是10万还是1亿?QPS要求是100还是10000?这决定了是用轻量级方案还是分布式方案。
部署复杂度:团队有没有专职DBA或数据工程师?能不能承受Milvus那样需要Etcd+MinIO的复杂依赖?
功能特性:需不需要元数据过滤?要不要支持CRUD?有没有多租户需求?
生态与语言支持:Java客户端是否成熟?Spring AI有没有官方集成?
成本:开源自建还是云服务?存储成本、计算成本怎么算?
二、原理深度解析
2.1 四种向量库架构对比
2.2 HNSW索引原理
所有向量库都支持HNSW(Hierarchical Navigable Small World),这是目前近似最近邻检索性能最好的算法。
核心思想:构建多层跳表结构,顶层节点少但连接跨度大(长程导航),底层节点多覆盖全部数据(精确邻居)。检索时从顶层快速定位大概区域,逐层向下精化,最终在底层找到最近邻。
HNSW关键参数:
- M: 每个节点的最大连接数,M越大精度越高,内存越多(通常16-64)
- ef_construction: 建索引时候选集大小,越大精度越高,建索引越慢(通常100-500)
- ef: 查询时候选集大小,越大精度越高,查询越慢(通常50-200)三、完整代码实现
3.1 pgvector集成(最简单,推荐初始项目)
// 1. 实体类
@Entity
@Table(name = "document_embeddings")
public class DocumentEmbedding {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(columnDefinition = "text")
private String content;
// pgvector类型,需要自定义类型处理器
@Column(name = "embedding", columnDefinition = "vector(1024)")
private String embeddingStr; // 存储为字符串格式 "[0.1,0.2,...]"
@Column(name = "metadata", columnDefinition = "jsonb")
private String metadata;
@Column(name = "source")
private String source;
@Column(name = "created_at")
private LocalDateTime createdAt;
}// 2. Repository层(使用原生SQL)
@Repository
public interface DocumentEmbeddingRepository extends JpaRepository<DocumentEmbedding, String> {
/**
* 余弦相似度检索,pgvector使用<=> 操作符表示余弦距离(1-相似度)
*/
@Query(value = """
SELECT id, content, metadata, source,
1 - (embedding <=> CAST(:queryVector AS vector)) AS similarity
FROM document_embeddings
WHERE 1 - (embedding <=> CAST(:queryVector AS vector)) > :threshold
ORDER BY embedding <=> CAST(:queryVector AS vector)
LIMIT :limit
""", nativeQuery = true)
List<Object[]> findSimilarDocuments(
@Param("queryVector") String queryVector,
@Param("threshold") double threshold,
@Param("limit") int limit);
/**
* 带元数据过滤的检索
*/
@Query(value = """
SELECT id, content, metadata, source,
1 - (embedding <=> CAST(:queryVector AS vector)) AS similarity
FROM document_embeddings
WHERE metadata->>'category' = :category
AND 1 - (embedding <=> CAST(:queryVector AS vector)) > :threshold
ORDER BY embedding <=> CAST(:queryVector AS vector)
LIMIT :limit
""", nativeQuery = true)
List<Object[]> findSimilarWithFilter(
@Param("queryVector") String queryVector,
@Param("category") String category,
@Param("threshold") double threshold,
@Param("limit") int limit);
}// 3. Service层封装
@Service
public class PgVectorService {
private final DocumentEmbeddingRepository repository;
private final UnifiedEmbeddingService embeddingService;
private final ObjectMapper objectMapper;
public PgVectorService(DocumentEmbeddingRepository repository,
UnifiedEmbeddingService embeddingService,
ObjectMapper objectMapper) {
this.repository = repository;
this.embeddingService = embeddingService;
this.objectMapper = objectMapper;
}
public void addDocument(String content, Map<String, Object> metadata) {
float[] embedding = embeddingService.embed(content);
String vectorStr = floatArrayToVectorString(embedding);
DocumentEmbedding doc = new DocumentEmbedding();
doc.setContent(content);
doc.setEmbeddingStr(vectorStr);
doc.setMetadata(toJson(metadata));
doc.setSource((String) metadata.getOrDefault("source", ""));
doc.setCreatedAt(LocalDateTime.now());
repository.save(doc);
}
public List<SearchResult> search(String query, int topK, double threshold) {
float[] queryEmbedding = embeddingService.embed(query);
String vectorStr = floatArrayToVectorString(queryEmbedding);
List<Object[]> rows = repository.findSimilarDocuments(vectorStr, threshold, topK);
return rows.stream().map(row -> {
String id = (String) row[0];
String content = (String) row[1];
double similarity = ((Number) row[3]).doubleValue();
return new SearchResult(id, content, similarity);
}).collect(Collectors.toList());
}
private String floatArrayToVectorString(float[] vec) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < vec.length; i++) {
if (i > 0) sb.append(",");
sb.append(vec[i]);
}
sb.append("]");
return sb.toString();
}
private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
return "{}";
}
}
}3.2 Spring AI统一VectorStore接口
// Spring AI提供了统一的VectorStore接口,pgvector/Milvus/Weaviate都有实现
@Configuration
public class VectorStoreConfig {
// 选项1: pgvector
@Bean
@ConditionalOnProperty(name = "vectorstore.type", havingValue = "pgvector")
public VectorStore pgVectorStore(JdbcTemplate jdbcTemplate,
EmbeddingModel embeddingModel) {
return new PgVectorStore(jdbcTemplate, embeddingModel,
PgVectorStore.PgDistanceType.COSINE_DISTANCE,
false, PgVectorStore.PgIndexType.HNSW, false,
PgVectorStoreConfig.builder()
.withDimensions(1024)
.withSchemaName("public")
.withTableName("vector_store")
.build());
}
// 选项2: Weaviate
@Bean
@ConditionalOnProperty(name = "vectorstore.type", havingValue = "weaviate")
public VectorStore weaviateVectorStore(WeaviateClient weaviateClient,
EmbeddingModel embeddingModel) {
return WeaviateVectorStore.builder(weaviateClient, embeddingModel)
.withWeaviateIndexName("Documents")
.withContentFieldName("content")
.withMetadataFields(
WeaviateVectorStore.WeaviateVectorStoreConfig.MetadataField.text("source"),
WeaviateVectorStore.WeaviateVectorStoreConfig.MetadataField.text("category")
)
.build();
}
// 选项3: Milvus(通过Spring AI Milvus集成)
@Bean
@ConditionalOnProperty(name = "vectorstore.type", havingValue = "milvus")
public VectorStore milvusVectorStore(MilvusServiceClient milvusClient,
EmbeddingModel embeddingModel) {
MilvusVectorStoreConfig config = MilvusVectorStoreConfig.builder()
.withCollectionName("rag_documents")
.withDatabaseName("default")
.withIndexType(IndexType.IVF_FLAT)
.withMetricType(MetricType.COSINE)
.withIndexParameters("{\"nlist\":1024}")
.build();
return new MilvusVectorStore(milvusClient, embeddingModel, config, true);
}
}3.3 Milvus高级用法(大规模场景)
@Service
public class MilvusAdvancedService {
private final MilvusServiceClient milvusClient;
public MilvusAdvancedService(MilvusServiceClient milvusClient) {
this.milvusClient = milvusClient;
}
/**
* 创建分区(支持多租户)
*/
public void createTenantPartition(String collectionName, String tenantId) {
CreatePartitionParam param = CreatePartitionParam.newBuilder()
.withCollectionName(collectionName)
.withPartitionName("tenant_" + tenantId)
.build();
R<RpcStatus> response = milvusClient.createPartition(param);
if (response.getStatus() != R.Status.Success.getCode()) {
throw new RuntimeException("创建分区失败: " + response.getMessage());
}
}
/**
* 带分区的向量检索(多租户隔离)
*/
public SearchResultsWrapper searchInTenant(
String collectionName,
String tenantId,
List<List<Float>> queryVectors,
int topK,
String metadataFilter) {
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withPartitionNames(List.of("tenant_" + tenantId))
.withVectorFieldName("embedding")
.withVectors(queryVectors)
.withTopK(topK)
.withMetricType(MetricType.COSINE)
.withOutFields(List.of("id", "content", "metadata"))
.withExpr(metadataFilter) // 元数据过滤,如 "category == 'contract'"
.withParams("{\"ef\":200}")
.build();
R<SearchResults> response = milvusClient.search(searchParam);
if (response.getStatus() != R.Status.Success.getCode()) {
throw new RuntimeException("Milvus检索失败: " + response.getMessage());
}
return new SearchResultsWrapper(response.getData().getResults());
}
/**
* 批量插入(高性能写入)
*/
public void batchInsert(String collectionName, String tenantId,
List<String> ids, List<String> contents,
List<List<Float>> embeddings, List<String> metadataList) {
List<InsertParam.Field> fields = new ArrayList<>();
fields.add(new InsertParam.Field("id", ids));
fields.add(new InsertParam.Field("content", contents));
fields.add(new InsertParam.Field("embedding", embeddings));
fields.add(new InsertParam.Field("metadata", metadataList));
InsertParam insertParam = InsertParam.newBuilder()
.withCollectionName(collectionName)
.withPartitionName("tenant_" + tenantId)
.withFields(fields)
.build();
R<MutationResult> response = milvusClient.insert(insertParam);
if (response.getStatus() != R.Status.Success.getCode()) {
throw new RuntimeException("批量插入失败: " + response.getMessage());
}
}
}3.4 向量库性能压测工具
@Component
public class VectorStoreBenchmark {
private static final Logger log = LoggerFactory.getLogger(VectorStoreBenchmark.class);
/**
* 写入性能测试
*/
public WriteResult benchmarkWrite(VectorStore vectorStore,
EmbeddingModel embeddingModel,
int documentCount) {
List<Document> documents = generateTestDocuments(documentCount);
long startTime = System.currentTimeMillis();
// 批量写入,每批100条
int batchSize = 100;
for (int i = 0; i < documents.size(); i += batchSize) {
List<Document> batch = documents.subList(i,
Math.min(i + batchSize, documents.size()));
vectorStore.add(batch);
}
long elapsed = System.currentTimeMillis() - startTime;
double throughput = documentCount * 1000.0 / elapsed;
log.info("写入{}条文档,耗时{}ms,吞吐量{:.1f}条/秒",
documentCount, elapsed, throughput);
return new WriteResult(elapsed, throughput);
}
/**
* 检索性能测试
*/
public QueryResult benchmarkQuery(VectorStore vectorStore,
int queryCount, int topK) {
List<String> queries = generateTestQueries(queryCount);
List<Long> latencies = new ArrayList<>();
for (String query : queries) {
long start = System.nanoTime();
vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(topK).build());
latencies.add((System.nanoTime() - start) / 1_000_000);
}
Collections.sort(latencies);
double avgLatency = latencies.stream().mapToLong(Long::longValue).average().orElse(0);
double p95 = latencies.get((int)(latencies.size() * 0.95));
double p99 = latencies.get((int)(latencies.size() * 0.99));
log.info("检索{}次,平均延迟{:.1f}ms,P95={:.1f}ms,P99={:.1f}ms",
queryCount, avgLatency, p95, p99);
return new QueryResult(avgLatency, p95, p99);
}
private List<Document> generateTestDocuments(int count) {
List<Document> docs = new ArrayList<>();
for (int i = 0; i < count; i++) {
docs.add(new Document("这是测试文档" + i + ",包含一些随机的中文内容用于性能测试。",
Map.of("index", i, "category", "test")));
}
return docs;
}
private List<String> generateTestQueries(int count) {
List<String> queries = new ArrayList<>();
for (int i = 0; i < count; i++) {
queries.add("测试查询" + i);
}
return queries;
}
}四、效果评估与优化
4.1 性能对比测试(1024维向量,100万条数据)
| 向量库 | 写入吞吐量(条/秒) | P99检索延迟 | 内存占用 | 磁盘占用 |
|---|---|---|---|---|
| pgvector (HNSW) | 约800 | 45ms | 5.2GB | 4.8GB |
| Chroma (本地) | 约500 | 62ms | 3.8GB | 4.1GB |
| Weaviate | 约1200 | 18ms | 6.5GB | 5.2GB |
| Milvus (IVF_FLAT) | 约3500 | 12ms | 8.1GB | 7.3GB |
4.2 功能特性对比
| 特性 | pgvector | Chroma | Weaviate | Milvus |
|---|---|---|---|---|
| HNSW索引 | 支持 | 部分支持 | 支持 | 支持 |
| 元数据过滤 | SQL灵活 | 有限 | 强大 | 强大 |
| 实时CRUD | 完整 | 完整 | 完整 | 有延迟 |
| 多租户 | 通过Schema | 通过Collection | 通过类 | 通过分区 |
| 内置BM25 | 需PostgreSQL全文 | 否 | 是 | 否 |
| Java SDK | JPA/JDBC | HTTP | 官方Java | 官方Java |
| Spring AI集成 | 官方 | 官方 | 官方 | 官方 |
| 运维复杂度 | 低 | 极低 | 中 | 高 |
4.3 选型决策树
五、踩坑实录
坑1:pgvector的IVFFlat索引必须先建数据再建索引
IVFFlat索引需要在数据入库之后才能建,因为它的聚类中心是从实际数据中计算出来的。我一开始建了个空表,然后马上创建了IVFFlat索引,再往里插数据。结果索引完全没效果,每次查询都走顺序扫描,10万条数据每次查询要2秒。正确做法是数据批量导入完成后,执行CREATE INDEX,聚类效果才正常。HNSW索引没有这个问题,可以边插边建。
坑2:Milvus的数据写入到可检索有延迟
Milvus的写入是先到内存中的"growing segment",需要等到segment封存(sealed)并做索引之后才能被检索到。默认配置下这个延迟可能有10-30秒。我在做功能测试时,写完数据立刻查,啥都查不到,以为是代码有Bug,排查了半天。其实只要等一会儿或者手动调用flush()接口触发封存,就能检索到了。生产环境要根据业务需要调整dataCoord.segment.sealProportion参数。
坑3:Weaviate批量导入时内存溢出
一次性往Weaviate导入50万条文档,Java这边OOM了。原因是Weaviate的Java SDK批量导入时会在内存里积累所有对象的向量,等到超过批次大小才发送。我设的batch_size是1000,但每条文档含1024维float向量,1000 x 1024 x 4字节已经快4MB,加上对象本身的内容和元数据,堆内存很快被撑满。解决方案是手动分批,每批最多200条,发送完后等待响应再发下一批。
六、总结
选向量数据库没有一定之规,关键是根据项目规模、团队能力、基础设施现状来做匹配。
我的经验总结很简单:新项目先用pgvector,它跟现有PostgreSQL体系完美融合,Spring AI集成非常成熟,对于百万级以内的数据量完全够用,而且元数据过滤可以用SQL随心所欲地写,这是其他向量库很难比的。等规模上去了,再评估是否需要迁移到Milvus。Weaviate是一个被低估的选项,内置混合检索(向量+BM25)是它的独特优势,不需要额外维护Elasticsearch或Lucene索引。Chroma更适合Python生态的快速原型,Java生产项目慎用。
