第2146篇:向量数据库选型——Qdrant、Milvus与pgvector的工程对比
第2146篇:向量数据库选型——Qdrant、Milvus与pgvector的工程对比
适读人群:选型向量数据库的技术负责人和架构师 | 阅读时长:约18分钟 | 核心价值:从工程角度对比三款主流向量数据库,帮助你根据业务规模和技术栈做出合适的选择
"向量数据库到底选哪个?"
这个问题被问得最多,答案却不统一,因为它确实取决于你的具体场景。Qdrant、Milvus、pgvector各有适用场景,用错了会造成运维复杂度激增或性能不达标。
我们在三个不同规模的项目里分别用了这三款数据库,踩过的坑有一些共性,这篇文章把经验整理出来,帮助大家做选型决策。
三款向量数据库的定位对比
/**
* 向量数据库选型对比
*
* ===== pgvector =====
*
* 定位:PostgreSQL的扩展插件
*
* 优势:
* - 如果已有PostgreSQL,零增量运维成本
* - 向量和业务数据在同一个DB,事务一致性
* - SQL查询(向量查询 + 关系查询 JOIN)
* - 成熟的PostgreSQL生态(备份、HA、监控)
*
* 局限:
* - 向量检索性能不如专用向量数据库
* - 百万条以上开始变慢,千万条以上不推荐
* - 没有内置的多租户隔离
*
* 适用场景:
* - 中小规模(<500万条向量)
* - 团队已有PostgreSQL技术栈
* - 需要向量和业务数据混合查询
*
* ===== Qdrant =====
*
* 定位:专注向量搜索的数据库,Rust开发
*
* 优势:
* - 性能优秀,内存占用低(Rust实现)
* - 部署简单,单二进制文件
* - 过滤能力强(支持复杂的Payload过滤)
* - 多向量支持(同一条记录存多个向量)
* - REST + gRPC双协议
*
* 局限:
* - 相比Milvus功能稍少(没有内置数据处理)
* - 集群模式配置相对复杂
*
* 适用场景:
* - 中大规模(500万-1亿条)
* - 重视运维简单性
* - 需要强过滤能力(按元数据精确过滤)
*
* ===== Milvus =====
*
* 定位:企业级向量数据库,功能最全
*
* 优势:
* - 性能最强,支持GPU加速
* - 云原生架构,弹性扩容
* - 多种索引类型(IVF、HNSW、DiskANN等)
* - 完整的企业功能(RBAC、审计、多租户)
*
* 局限:
* - 部署复杂(依赖etcd、MinIO、消息队列)
* - 资源占用大
* - 运维成本高
*
* 适用场景:
* - 大规模(1亿条以上)
* - 企业级功能需求
* - 有专职运维团队
*/pgvector集成实践
/**
* pgvector集成
*
* 最适合的场景:已有PostgreSQL + 规模在500万条以内
*
* 核心:pgvector把向量存成一种特殊的列类型,
* 可以和普通SQL列混合查询
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PgVectorService {
private final JdbcTemplate jdbc;
/**
* 初始化pgvector表
*
* SQL建表语句(在数据库迁移脚本里执行):
*
* CREATE EXTENSION IF NOT EXISTS vector;
*
* CREATE TABLE knowledge_embeddings (
* id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
* source_id VARCHAR(255) NOT NULL,
* content TEXT NOT NULL,
* embedding vector(768), -- 768维向量
* department VARCHAR(100), -- 部门(用于过滤)
* doc_type VARCHAR(50), -- 文档类型
* created_at TIMESTAMPTZ DEFAULT NOW(),
* INDEX embedding_cosine_idx USING ivfflat (embedding vector_cosine_ops) -- 余弦相似度索引
* );
*/
/**
* 插入向量
*
* pgvector使用PostgreSQL原生的数组格式存储向量
*/
public void insertEmbedding(String sourceId, String content,
float[] embedding, Map<String, String> metadata) {
String sql = """
INSERT INTO knowledge_embeddings
(source_id, content, embedding, department, doc_type)
VALUES (?, ?, ?::vector, ?, ?)
ON CONFLICT (source_id) DO UPDATE SET
content = EXCLUDED.content,
embedding = EXCLUDED.embedding,
department = EXCLUDED.department
""";
String vectorStr = floatArrayToString(embedding);
jdbc.update(sql,
sourceId, content, vectorStr,
metadata.getOrDefault("department", ""),
metadata.getOrDefault("docType", "")
);
}
/**
* 向量相似度搜索
*
* 支持同时过滤元数据,这是pgvector的核心优势:
* 向量搜索 + SQL条件 无缝结合
*/
public List<SearchResult> search(float[] queryVector, int topK, String department) {
String vectorStr = floatArrayToString(queryVector);
// 余弦相似度搜索(<=> 是pgvector的余弦距离操作符)
// 余弦距离 = 1 - 余弦相似度,所以距离越小越相似
String sql = """
SELECT
source_id,
content,
department,
1 - (embedding <=> ?::vector) AS similarity
FROM knowledge_embeddings
WHERE (?::text IS NULL OR department = ?::text)
ORDER BY embedding <=> ?::vector
LIMIT ?
""";
return jdbc.query(sql,
new Object[]{vectorStr, department, department, vectorStr, topK},
(rs, rowNum) -> new SearchResult(
rs.getString("source_id"),
rs.getString("content"),
rs.getFloat("similarity"),
Map.of("department", rs.getString("department"))
)
);
}
/**
* 混合查询示例:向量搜索 + 业务表JOIN
*
* 这是pgvector独有的能力:向量搜索结果可以直接和业务数据JOIN
*/
public List<EnrichedSearchResult> searchWithDocumentInfo(
float[] queryVector, int topK) {
String vectorStr = floatArrayToString(queryVector);
String sql = """
SELECT
ke.source_id,
ke.content,
1 - (ke.embedding <=> ?::vector) AS similarity,
d.title AS doc_title,
d.author,
d.updated_at
FROM knowledge_embeddings ke
JOIN documents d ON ke.source_id = d.doc_id
WHERE d.status = 'ACTIVE'
ORDER BY ke.embedding <=> ?::vector
LIMIT ?
""";
return jdbc.query(sql,
new Object[]{vectorStr, vectorStr, topK},
(rs, rowNum) -> new EnrichedSearchResult(
rs.getString("source_id"),
rs.getString("content"),
rs.getFloat("similarity"),
rs.getString("doc_title"),
rs.getString("author")
)
);
}
/**
* 将float数组转换为pgvector格式字符串
* 格式:[0.1, 0.2, 0.3, ...]
*/
private String floatArrayToString(float[] vector) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < vector.length; i++) {
if (i > 0) sb.append(",");
sb.append(vector[i]);
}
sb.append("]");
return sb.toString();
}
public record SearchResult(String sourceId, String content,
float similarity, Map<String, String> metadata) {}
public record EnrichedSearchResult(String sourceId, String content,
float similarity, String docTitle, String author) {}
}Qdrant集成实践
/**
* Qdrant向量数据库集成
*
* Qdrant的强项:复杂Payload过滤
*
* 比如:找和查询最相似、且只在"技术部门"、
* 且是2024年后发布的、且评分>=4分的文档
*
* 这种复杂过滤在其他向量数据库里需要写复杂代码,
* Qdrant的过滤条件设计非常直观
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QdrantService {
private final QdrantClient client;
private static final String COLLECTION_NAME = "knowledge_base";
/**
* 初始化集合(Collection)
*/
public void initCollection(int vectorDimension) {
try {
// 检查集合是否存在
client.getCollectionInfoAsync(COLLECTION_NAME).get();
log.info("集合已存在: {}", COLLECTION_NAME);
} catch (Exception e) {
// 不存在,创建
CreateCollection request = CreateCollection.newBuilder()
.setCollectionName(COLLECTION_NAME)
.setVectorsConfig(VectorsConfig.newBuilder()
.setParams(VectorParams.newBuilder()
.setSize(vectorDimension)
.setDistance(Distance.Cosine) // 余弦相似度
.build()))
.build();
try {
client.createCollectionAsync(request).get();
log.info("集合已创建: {}, dim={}", COLLECTION_NAME, vectorDimension);
} catch (Exception ex) {
throw new RuntimeException("创建Qdrant集合失败", ex);
}
}
}
/**
* 批量插入向量
*
* Qdrant使用Point的概念,每个Point包含:
* - id(UUID)
* - vector(浮点数组)
* - payload(任意JSON,用于过滤)
*/
public void upsertPoints(List<PointData> points) {
List<PointStruct> pointStructs = points.stream()
.map(p -> PointStruct.newBuilder()
.setId(PointId.newBuilder().setUuid(p.id()).build())
.setVectors(Vectors.newBuilder()
.setVector(io.qdrant.client.grpc.Points.Vector.newBuilder()
.addAllData(toFloatList(p.vector()))
.build()))
.putAllPayload(buildPayload(p.payload()))
.build())
.toList();
try {
client.upsertAsync(COLLECTION_NAME, pointStructs).get();
log.debug("批量插入完成: count={}", points.size());
} catch (Exception e) {
throw new RuntimeException("Qdrant批量插入失败", e);
}
}
/**
* 带复杂过滤条件的向量搜索
*
* 示例:找最相似的文档,且满足:
* - department = "技术部"
* - doc_type IN ["manual", "spec"]
* - year >= 2024
* - quality_score >= 0.8
*/
public List<ScoredPoint> searchWithFilters(
float[] queryVector, int topK,
String department, List<String> docTypes,
int minYear, double minQualityScore) {
// 构建过滤条件(Qdrant的Filter支持must/should/must_not逻辑)
Filter.Builder filterBuilder = Filter.newBuilder();
// 必须匹配:department
if (department != null && !department.isBlank()) {
filterBuilder.addMust(Condition.newBuilder()
.setField(FieldCondition.newBuilder()
.setKey("department")
.setMatch(Match.newBuilder()
.setValue(Value.newBuilder().setStringValue(department))
.build())
.build())
.build());
}
// 必须匹配:doc_type在列表里
if (docTypes != null && !docTypes.isEmpty()) {
filterBuilder.addMust(Condition.newBuilder()
.setField(FieldCondition.newBuilder()
.setKey("doc_type")
.setMatch(Match.newBuilder()
.setKeywords(RepeatedStrings.newBuilder()
.addAllStrings(docTypes)
.build())
.build())
.build())
.build());
}
// 范围过滤:year >= minYear
if (minYear > 0) {
filterBuilder.addMust(Condition.newBuilder()
.setField(FieldCondition.newBuilder()
.setKey("year")
.setRange(Range.newBuilder()
.setGte(minYear)
.build())
.build())
.build());
}
SearchPoints searchRequest = SearchPoints.newBuilder()
.setCollectionName(COLLECTION_NAME)
.addAllVector(toFloatList(queryVector))
.setLimit(topK)
.setFilter(filterBuilder.build())
.setWithPayload(WithPayloadSelector.newBuilder()
.setEnable(true).build())
.build();
try {
List<ScoredPoint> results = client.searchAsync(searchRequest).get();
log.debug("搜索完成: results={}", results.size());
return results;
} catch (Exception e) {
throw new RuntimeException("Qdrant搜索失败", e);
}
}
private List<Float> toFloatList(float[] array) {
List<Float> list = new ArrayList<>(array.length);
for (float v : array) list.add(v);
return list;
}
private Map<String, Value> buildPayload(Map<String, Object> payload) {
Map<String, Value> result = new HashMap<>();
for (Map.Entry<String, Object> entry : payload.entrySet()) {
Object v = entry.getValue();
Value.Builder valueBuilder = Value.newBuilder();
if (v instanceof String s) valueBuilder.setStringValue(s);
else if (v instanceof Integer i) valueBuilder.setIntegerValue(i);
else if (v instanceof Long l) valueBuilder.setIntegerValue(l);
else if (v instanceof Double d) valueBuilder.setDoubleValue(d);
else if (v instanceof Boolean b) valueBuilder.setBoolValue(b);
else valueBuilder.setStringValue(v.toString());
result.put(entry.getKey(), valueBuilder.build());
}
return result;
}
public record PointData(String id, float[] vector, Map<String, Object> payload) {}
}性能对比与选型指导
/**
* 向量数据库性能对比参考数据
* (实测环境:8核16GB,100万条768维向量,batch查询)
*
* ===== 写入性能 =====
*
* pgvector: 约3,000条/秒(带索引)
* Qdrant: 约10,000条/秒(HNSW索引)
* Milvus: 约50,000条/秒(批量插入,IVF索引)
*
* ===== 查询性能(P99,Top-10)=====
*
* pgvector:
* 10万条:3ms
* 100万条:15ms
* 500万条:80ms(开始明显变慢)
*
* Qdrant:
* 10万条:2ms
* 100万条:5ms
* 500万条:12ms
* 1000万条:25ms
*
* Milvus:
* 1000万条:8ms
* 1亿条:20ms
* 10亿条:50ms(使用DiskANN索引)
*
* ===== 选型决策树 =====
*
* 向量数量 < 100万:
* 已有PostgreSQL → pgvector(零运维增量)
* 需要独立部署 → Qdrant(简单易用)
*
* 向量数量 100万 - 1000万:
* Qdrant(性能和运维的最佳平衡点)
*
* 向量数量 > 1000万:
* Milvus(企业级功能和性能)
* 或 Qdrant + 分片(成本更低,但运维复杂)
*
* 特殊需求:
* 需要和业务数据JOIN → pgvector
* 需要复杂Payload过滤 → Qdrant
* 需要GPU加速 → Milvus
* 需要多租户隔离 → Milvus(内置RBAC)
*/实践建议
从pgvector开始,不要一开始就上Milvus
Milvus功能强大,但部署需要etcd、MinIO、Pulsar等多个组件,对小团队来说运维成本很高。如果你的数据量在500万条以内,pgvector完全够用,而且几乎没有额外运维成本。等到数据量增长、pgvector开始成为瓶颈时,再迁移到Qdrant或Milvus。迁移方案:把数据从pgvector读出来,批量写入Qdrant,切换应用代码,下线旧数据库。我们的一个项目就是这个路径,400万条向量的迁移只用了2小时。
索引类型直接影响查询速度和内存占用
HNSW(Hierarchical Navigable Small World)是目前最常用的向量索引:查询速度快(毫秒级),但内存占用大(每条向量额外需要100-200字节)。IVF(倒排文件索引):内存占用小,但查询准确率略低(ANN搜索有损失),适合内存受限场景。DiskANN:把索引存磁盘,支持极大规模数据但延迟较高。根据你的内存预算和延迟要求选择。大多数场景用HNSW就够了,除非向量数量超过1亿条。
向量数据库不要存原始文本,要分层存储
向量库只存向量和最小化的元数据(用于过滤),原始内容存关系数据库或对象存储,通过sourceId关联。理由:向量库针对向量操作优化,存大量文本会降低效率;原始内容在关系数据库里更容易做全文检索、分页、权限控制。查询流程:向量库找topK的sourceId → 去关系数据库批量查内容 → 组装结果。这个分离架构让两个存储各司其职,也让系统更容易扩展。
