第1911篇:pgvector深度实践——PostgreSQL向量扩展的索引调优与查询优化
第1911篇:pgvector深度实践——PostgreSQL向量扩展的索引调优与查询优化
很多团队在引入向量搜索的时候,第一反应是上专用向量数据库,Milvus、Weaviate、Qdrant,名字一个比一个好听。但等真正落地的时候才发现——运维多了一套系统,数据同步是个麻烦,事务一致性也保证不了。
我在一个电商推荐项目里就经历过这个弯路。最终我们把向量索引回迁到了 PostgreSQL + pgvector,稳定跑了半年,查询延迟比原来的 Milvus 部署还要低。今天把这段实战经验整理出来,希望能帮你少走点弯路。
一、pgvector 是什么,它能做到哪些事
pgvector 是 PostgreSQL 的一个开源扩展,核心能力是在 PG 里存储和查询高维向量。它支持:
- 精确近邻搜索(Exact KNN)
- 近似近邻搜索(ANN),基于 IVFFlat 和 HNSW 两种索引
- 三种距离度量:L2 欧氏距离、内积(inner product)、余弦相似度
最新版本(0.7.x)已经把 HNSW 索引做到相当成熟,在 1M 量级的数据集上,查询延迟轻松控制在 10ms 以内。
对于已经在用 PostgreSQL 的团队来说,这意味着:不需要额外引入一套向量数据库,向量字段和业务字段放在同一张表里,JOIN、事务、权限全部可以复用。
二、安装与基础配置
2.1 安装扩展
在 PostgreSQL 14+ 上安装 pgvector:
-- 数据库中启用扩展
CREATE EXTENSION IF NOT EXISTS vector;
-- 验证安装
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';如果是 Docker 部署,直接用官方镜像:
# docker-compose.yml
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: ai_app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:2.2 Java 依赖配置
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- pgvector Java 客户端 -->
<dependency>
<groupId>com.pgvector</groupId>
<artifactId>pgvector</artifactId>
<version>0.1.6</version>
</dependency>
</dependencies>三、数据建模:向量字段的正确姿势
3.1 建表与字段定义
-- 商品向量表
CREATE TABLE product_embeddings (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL UNIQUE,
title TEXT NOT NULL,
category VARCHAR(100),
embedding VECTOR(1536), -- OpenAI text-embedding-3-small 维度
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 创建普通索引加速 product_id 查询
CREATE INDEX idx_product_embeddings_product_id ON product_embeddings(product_id);这里有个细节值得注意:VECTOR(1536) 里的 1536 必须在建表时确定,后续不能动态修改。如果你的 embedding 模型换了维度,就得重建表或者新建一列。我们在项目里吃过这个亏,前期没想好模型选型,中途换了一次,迁移数据花了不少时间。
3.2 Java 实体类
import com.pgvector.PGvector;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "product_embeddings")
public class ProductEmbedding {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false, unique = true)
private Long productId;
@Column(nullable = false)
private String title;
private String category;
// 向量字段映射
@Column(columnDefinition = "vector(1536)")
@JdbcTypeCode(SqlTypes.VECTOR)
private float[] embedding;
@Column(name = "created_at")
private java.time.OffsetDateTime createdAt;
// getter/setter 省略
}四、索引选择:IVFFlat vs HNSW,这两个到底怎么选
这是 pgvector 里最容易踩坑的地方,我见过太多人不加思考直接用 IVFFlat,然后发现查询结果质量很差。
4.1 IVFFlat 索引
IVFFlat(Inverted File with Flat quantization)的核心思路是:先把向量聚类,查询时只在最近的几个聚类里搜索。
-- 创建 IVFFlat 索引(余弦相似度)
CREATE INDEX idx_product_embedding_ivfflat
ON product_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);lists 参数是聚类数量,官方建议:
- 数据量 < 1M:
lists = sqrt(行数) - 数据量 > 1M:
lists = 行数 / 1000
查询时通过 ivfflat.probes 控制搜索的聚类数量:
-- 搜索 10 个聚类(精度和速度的平衡点)
SET ivfflat.probes = 10;
SELECT product_id, title,
1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
FROM product_embeddings
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 20;IVFFlat 的核心问题:建索引之前数据要足够多,因为聚类中心是在建索引时确定的。如果建完索引后大量插入新数据,这些新数据落在哪个聚类里的分布会越来越不均匀,查询质量会下降。我们线上就遇到过,建索引时 50 万条数据,三个月后涨到 200 万,查询召回率肉眼可见地变差了。
4.2 HNSW 索引(推荐)
HNSW(Hierarchical Navigable Small World)是目前工业界用得最多的 ANN 算法,pgvector 0.5.0 之后正式支持。
-- 创建 HNSW 索引
CREATE INDEX idx_product_embedding_hnsw
ON product_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);两个关键参数:
m:每个节点的最大连接数,越大精度越高,索引越大,构建越慢。通常取 16 或 32。ef_construction:构建时的搜索宽度,越大精度越高,构建越慢。通常取 64~200。
查询时通过 hnsw.ef_search 控制搜索宽度:
SET hnsw.ef_search = 100;
SELECT product_id, title,
1 - (embedding <=> query_vector) AS similarity
FROM product_embeddings
ORDER BY embedding <=> '[...]'::vector
LIMIT 20;HNSW vs IVFFlat 的实测对比(我们项目 100 万条数据):
| 指标 | IVFFlat (probes=10) | HNSW (ef_search=100) |
|---|---|---|
| 查询延迟 P50 | 8ms | 5ms |
| 查询延迟 P99 | 45ms | 12ms |
| Recall@10 | 92% | 98.5% |
| 索引大小 | 1.2GB | 2.8GB |
| 增量插入后召回率 | 降至 85% | 稳定 98%+ |
HNSW 支持增量插入时不需要重建索引,这一点对于持续写入的业务场景至关重要。代价是索引更大,构建时间更长。如果你的数据集是静态的,IVFFlat 也完全够用。
五、Java Service 层的实战封装
5.1 Repository 层
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ProductEmbeddingRepository
extends JpaRepository<ProductEmbedding, Long> {
// 余弦相似度 Top-K 搜索
@Query(value = """
SELECT product_id, title, category,
1 - (embedding <=> CAST(:queryVector AS vector)) AS similarity
FROM product_embeddings
ORDER BY embedding <=> CAST(:queryVector AS vector)
LIMIT :topK
""", nativeQuery = true)
List<Object[]> findTopKBySimilarity(
@Param("queryVector") String queryVector,
@Param("topK") int topK
);
// 带过滤条件的向量搜索
@Query(value = """
SELECT product_id, title, category,
1 - (embedding <=> CAST(:queryVector AS vector)) AS similarity
FROM product_embeddings
WHERE category = :category
AND 1 - (embedding <=> CAST(:queryVector AS vector)) > :threshold
ORDER BY embedding <=> CAST(:queryVector AS vector)
LIMIT :topK
""", nativeQuery = true)
List<Object[]> findTopKByCategoryAndSimilarity(
@Param("queryVector") String queryVector,
@Param("category") String category,
@Param("threshold") double threshold,
@Param("topK") int topK
);
}5.2 向量搜索 Service
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class VectorSearchService {
private final ProductEmbeddingRepository repository;
private final EmbeddingClient embeddingClient;
/**
* 语义搜索:根据查询文本找相似商品
*/
public List<ProductSearchResult> semanticSearch(
String query, String category, int topK) {
long start = System.currentTimeMillis();
// 1. 获取查询向量
float[] queryEmbedding = embeddingClient.embed(query);
String vectorStr = toPostgresVectorString(queryEmbedding);
// 2. 执行向量搜索
List<Object[]> rawResults;
if (category != null && !category.isBlank()) {
rawResults = repository.findTopKByCategoryAndSimilarity(
vectorStr, category, 0.7, topK);
} else {
rawResults = repository.findTopKBySimilarity(vectorStr, topK);
}
long elapsed = System.currentTimeMillis() - start;
log.info("向量搜索完成,query={}, topK={}, 耗时={}ms,结果数={}",
query, topK, elapsed, rawResults.size());
// 3. 转换结果
return rawResults.stream()
.map(row -> ProductSearchResult.builder()
.productId(((Number) row[0]).longValue())
.title((String) row[1])
.category((String) row[2])
.similarity(((Number) row[3]).doubleValue())
.build())
.collect(Collectors.toList());
}
/**
* 批量写入向量(带冲突处理)
*/
public void upsertEmbeddings(List<ProductEmbeddingDTO> products) {
// 分批处理,避免单次事务过大
int batchSize = 100;
for (int i = 0; i < products.size(); i += batchSize) {
List<ProductEmbeddingDTO> batch =
products.subList(i, Math.min(i + batchSize, products.size()));
processBatch(batch);
log.info("已处理 {}/{} 条向量数据", i + batchSize, products.size());
}
}
private void processBatch(List<ProductEmbeddingDTO> batch) {
List<String> texts = batch.stream()
.map(ProductEmbeddingDTO::getTitle)
.collect(Collectors.toList());
List<float[]> embeddings = embeddingClient.embedBatch(texts);
List<ProductEmbedding> entities = new java.util.ArrayList<>();
for (int i = 0; i < batch.size(); i++) {
ProductEmbeddingDTO dto = batch.get(i);
ProductEmbedding entity = new ProductEmbedding();
entity.setProductId(dto.getProductId());
entity.setTitle(dto.getTitle());
entity.setCategory(dto.getCategory());
entity.setEmbedding(embeddings.get(i));
entities.add(entity);
}
repository.saveAll(entities);
}
/**
* 把 float[] 转成 PostgreSQL 向量字符串格式 [0.1, 0.2, ...]
*/
private String toPostgresVectorString(float[] vector) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < vector.length; i++) {
sb.append(vector[i]);
if (i < vector.length - 1) sb.append(",");
}
sb.append("]");
return sb.toString();
}
}六、查询性能调优:五个实战技巧
6.1 预设 session 参数
每次查询前用 SET 命令调整参数太麻烦,推荐在 JDBC 连接字符串或连接池初始化时统一设置:
// HikariCP 连接初始化 SQL
hikariConfig.setConnectionInitSql(
"SET hnsw.ef_search = 100; SET ivfflat.probes = 10;"
);或者在 PostgreSQL 配置文件里为特定用户设置默认值:
ALTER ROLE app_user SET hnsw.ef_search = 100;
ALTER ROLE app_user SET ivfflat.probes = 10;6.2 Pre-filtering vs Post-filtering
这是影响性能最大的一个坑。
错误做法(Post-filtering):
-- 先做向量搜索取 Top-K,再过滤类别
-- 问题:如果 category='手机' 的商品只占 5%,
-- 取 Top-20 之后可能一条都没有 '手机' 类别的
SELECT * FROM (
SELECT product_id, title, category,
embedding <=> '[...]'::vector AS distance
FROM product_embeddings
ORDER BY distance
LIMIT 20
) sub
WHERE category = '手机';正确做法(Pre-filtering):
-- 在向量搜索时加上 WHERE 条件
-- pgvector 会先过滤再做索引搜索
SELECT product_id, title,
1 - (embedding <=> '[...]'::vector) AS similarity
FROM product_embeddings
WHERE category = '手机'
ORDER BY embedding <=> '[...]'::vector
LIMIT 20;但要注意:Pre-filtering 在 category 基数很高(比如几千种类别)的情况下,每个类别的数据量很少,索引效果会退化。这时候需要为高频过滤字段单独建组合索引,或者考虑分区表。
6.3 使用 EXPLAIN ANALYZE 诊断查询
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT product_id, title,
1 - (embedding <=> '[0.1, ...]'::vector) AS similarity
FROM product_embeddings
WHERE category = '手机'
ORDER BY embedding <=> '[0.1, ...]'::vector
LIMIT 20;重点关注输出里的这几行:
Index Scan using idx_product_embedding_hnsw:说明用上了 HNSW 索引Seq Scan:没走索引,要检查为什么Buffers: shared hit=xxx:缓存命中情况
6.4 并行查询配置
对于大数据集,可以开启并行查询:
-- 允许向量查询并行
SET max_parallel_workers_per_gather = 4;
-- 在 postgresql.conf 中设置
-- max_parallel_workers = 8
-- max_parallel_workers_per_gather = 4但 HNSW 索引扫描本身不支持并行(截至 pgvector 0.7.x),并行主要作用在 Pre-filtering 的顺序扫描阶段。
6.5 向量数据的分区策略
当数据量超过 500 万行,单表性能会明显下降。推荐按时间或业务维度分区:
-- 按月分区的向量表
CREATE TABLE product_embeddings_partitioned (
id BIGSERIAL,
product_id BIGINT NOT NULL,
embedding VECTOR(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (created_at);
CREATE TABLE pe_2024_01 PARTITION OF product_embeddings_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE pe_2024_02 PARTITION OF product_embeddings_partitioned
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- 每个分区独立建 HNSW 索引
CREATE INDEX ON pe_2024_01 USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON pe_2024_02 USING hnsw (embedding vector_cosine_ops);七、混合搜索:向量 + 全文检索的组合拳
纯向量搜索有个天然的弱点:对关键字精确匹配不够敏感。用户搜"iPhone 15 Pro Max 256G 钛金色",向量搜索可能给你返回一堆语义相近但型号不对的手机。
最佳实践是混合搜索:
-- 混合搜索:向量相似度 + 全文检索 BM25 评分
WITH vector_results AS (
SELECT product_id,
1 - (embedding <=> '[...]'::vector) AS vec_score
FROM product_embeddings
ORDER BY embedding <=> '[...]'::vector
LIMIT 100
),
text_results AS (
SELECT product_id,
ts_rank(to_tsvector('chinese', title),
to_tsquery('chinese', '手机 & 苹果')) AS text_score
FROM product_embeddings
WHERE to_tsvector('chinese', title) @@
to_tsquery('chinese', '手机 & 苹果')
)
SELECT
COALESCE(v.product_id, t.product_id) AS product_id,
COALESCE(v.vec_score, 0) * 0.6 + COALESCE(t.text_score, 0) * 0.4 AS final_score
FROM vector_results v
FULL OUTER JOIN text_results t ON v.product_id = t.product_id
ORDER BY final_score DESC
LIMIT 20;这里的权重 0.6 和 0.4 是需要根据业务调整的。在我们的项目里,搜索类查询偏向向量(0.7),精确筛选类查询偏向全文(0.6),通过 A/B 实验跑出来的数据。
八、系统架构全貌
九、踩坑记录
坑1:建 HNSW 索引时内存不够
HNSW 索引构建是内存密集型操作,默认的 maintenance_work_mem 只有 64MB,建大表索引会非常慢。我们 100 万条记录,原来要建 3 小时,调大之后 20 分钟搞定:
SET maintenance_work_mem = '4GB';
CREATE INDEX idx_hnsw ON product_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);坑2:向量维度不匹配的运行时错误
存入 1536 维向量,查询时传了 1024 维,PostgreSQL 直接报错。在 Java 代码里加一个断言是有必要的:
private void validateEmbeddingDimension(float[] embedding) {
if (embedding.length != EXPECTED_DIMENSION) {
throw new IllegalArgumentException(
String.format("向量维度不匹配,期望 %d,实际 %d",
EXPECTED_DIMENSION, embedding.length));
}
}坑3:NULL 向量导致索引异常
embedding 字段为 NULL 时,向量索引会跳过该行,但查询结果可能出现意外的空值。建议在业务层做 NULL 校验,或者在数据库层加约束:
ALTER TABLE product_embeddings
ADD CONSTRAINT chk_embedding_not_null
CHECK (embedding IS NOT NULL);坑4:大批量写入时索引维护开销
写入 10 万条数据时,HNSW 索引的维护开销会让写入速度下降到每秒几百条。生产环境的解决方案是:批量写入时先禁用索引,写完再重建:
-- 批量写入前禁用索引
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_product_embedding_hnsw'::regclass;
-- 执行批量插入...
-- 重建索引
REINDEX INDEX CONCURRENTLY idx_product_embedding_hnsw;十、性能基准与生产建议
根据我们实际跑出来的数据,给出几个参考值:
| 场景 | 数据量 | 索引类型 | P50延迟 | P99延迟 | QPS |
|---|---|---|---|---|---|
| 小数据集精确搜索 | 10万 | 无索引 | 2ms | 15ms | 500 |
| 中数据集近似搜索 | 100万 | HNSW | 5ms | 12ms | 2000 |
| 大数据集近似搜索 | 1000万 | HNSW+分区 | 8ms | 25ms | 800 |
| 混合搜索 | 100万 | HNSW+GIN | 12ms | 35ms | 600 |
硬件配置:32核CPU,128GB内存,NVMe SSD,PostgreSQL 16。
生产建议:
- 先用 HNSW,除非数据完全静态
m=16, ef_construction=64是很好的起点,要精度调大 m,要速度调小 ef_search- 向量字段单独分区,不要和业务主表混在一起
- Embedding 计算是瓶颈,生产一定要缓存 embedding,避免重复调用
- 监控索引膨胀,定期 REINDEX CONCURRENTLY
pgvector 已经足够成熟,对于 1000 万以内的向量数据,完全不需要上专用向量数据库。技术选型的最优解不总是最新最炫的东西,而是团队能驾驭、运维成本可接受的那个。
