Spring AI + PGVector:PostgreSQL向量扩展深度实战指南
2026/4/30大约 8 分钟
Spring AI + PGVector:PostgreSQL向量扩展深度实战指南
适读人群:已有PostgreSQL使用经验、想搭建生产级RAG系统的Java工程师 阅读时长:约18分钟 文章价值:从零到生产,掌握PGVector的核心特性与Spring AI完整集成,包括索引优化与性能调优
为什么选PGVector而不是专用向量库
团队leader老赵找我聊架构选型,他们要做一个文档智能检索系统,有几十万篇文档要向量化存储。
他问我用Milvus还是Qdrant?
我反问他:你们现在数据库用什么?
他说PostgreSQL。
我说那就用PGVector。
他有点困惑:"PGVector不是玩具级别的吗?"
我说生产环境亿级向量我不推荐,但几十万到几百万的量,PGVector不仅完全够用,还有一个不可替代的优势:你不需要额外维护一套数据库系统,向量数据和业务数据在同一个库里,JOIN查询天然支持。
这篇文章,从安装配置到生产调优,把PGVector + Spring AI完整走一遍。
PGVector核心能力概览
IVFFlat vs HNSW对比:
| 特性 | IVFFlat | HNSW |
|---|---|---|
| 查询速度 | 较快 | 非常快 |
| 精度(Recall) | 中等(可调) | 高(>95%) |
| 构建速度 | 快 | 慢(但一次性) |
| 内存占用 | 低 | 高 |
| 适用场景 | 大数据量、内存受限 | 高精度要求 |
| 推荐用量 | 100万+ | 100万以下 |
环境搭建
Docker启动PGVector
# docker-compose.yml
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: pgvector-db
environment:
POSTGRES_DB: vectordb
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
command:
- "postgres"
- "-c"
- "max_connections=200"
- "-c"
- "shared_buffers=256MB"
- "-c"
- "work_mem=4MB"
volumes:
pgdata:初始化SQL:
-- init.sql
CREATE EXTENSION IF NOT EXISTS vector;
-- 知识文档表
CREATE TABLE IF NOT EXISTS knowledge_docs (
id BIGSERIAL PRIMARY KEY,
doc_id VARCHAR(64) UNIQUE NOT NULL,
title VARCHAR(512),
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI ada-002维度
metadata JSONB DEFAULT '{}',
source VARCHAR(256),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- HNSW索引(余弦相似度)
CREATE INDEX IF NOT EXISTS idx_docs_embedding_hnsw
ON knowledge_docs
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 元数据GIN索引(支持JSONB查询)
CREATE INDEX IF NOT EXISTS idx_docs_metadata
ON knowledge_docs USING gin (metadata);
-- 全文搜索索引
CREATE INDEX IF NOT EXISTS idx_docs_content_fts
ON knowledge_docs
USING gin (to_tsvector('chinese', content));Spring Boot依赖
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>application.yml配置:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/vectordb
username: admin
password: secret123
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-ada-002
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
# 自动初始化schema
initialize-schema: falseSpring AI集成配置
@Configuration
public class PGVectorConfig {
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate,
EmbeddingModel embeddingModel) {
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
// 表名(可以自定义)
.tableName("knowledge_docs")
// 向量维度
.dimensions(1536)
// 索引类型
.indexType(PgVectorStore.PgIndexType.HNSW)
// 距离函数
.distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
// 不自动初始化(我们手动建表)
.initializeSchema(false)
.build();
}
@Bean
public EmbeddingModel embeddingModel(OpenAiEmbeddingModel openAiEmbeddingModel) {
return openAiEmbeddingModel;
}
}文档导入与向量化
@Service
@Slf4j
public class DocumentIngestionService {
private final VectorStore vectorStore;
private final TextSplitter textSplitter;
public DocumentIngestionService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
// 按句子分割,块大小800字符,重叠100字符
this.textSplitter = new TokenTextSplitter(800, 100, 5, 10000, true);
}
/**
* 导入单个文档
*/
public void ingestDocument(String docId, String title,
String content, Map<String, Object> metadata) {
log.info("开始导入文档: docId={}, title={}", docId, title);
// 1. 创建Document对象
Document rawDoc = new Document(content, metadata);
rawDoc.getMetadata().put("doc_id", docId);
rawDoc.getMetadata().put("title", title);
rawDoc.getMetadata().put("ingested_at", LocalDateTime.now().toString());
// 2. 文本切分
List<Document> chunks = textSplitter.apply(List.of(rawDoc));
// 添加chunk序号(方便上下文拼接)
for (int i = 0; i < chunks.size(); i++) {
chunks.get(i).getMetadata().put("chunk_index", i);
chunks.get(i).getMetadata().put("total_chunks", chunks.size());
}
log.info("文档切分完成: docId={}, chunks={}", docId, chunks.size());
// 3. 批量向量化并存储(Spring AI自动调用EmbeddingModel)
vectorStore.add(chunks);
log.info("文档导入完成: docId={}", docId);
}
/**
* 批量导入(带进度追踪)
*/
public void batchIngest(List<DocumentDTO> documents) {
int total = documents.size();
AtomicInteger processed = new AtomicInteger(0);
// 分批处理,每批20个,避免一次性请求embedding接口过多
Lists.partition(documents, 20).forEach(batch -> {
batch.forEach(doc -> {
try {
ingestDocument(doc.getId(), doc.getTitle(),
doc.getContent(), doc.getMetadata());
} catch (Exception e) {
log.error("文档导入失败: docId={}", doc.getId(), e);
}
});
int done = processed.addAndGet(batch.size());
log.info("批量导入进度: {}/{}", done, total);
});
}
/**
* 删除文档(按doc_id删除所有相关chunks)
*/
public void deleteDocument(String docId) {
vectorStore.delete(List.of(docId));
log.info("文档已删除: docId={}", docId);
}
}核心检索实现
基础相似度检索
@Service
@Slf4j
public class VectorSearchService {
private final VectorStore vectorStore;
private final JdbcTemplate jdbcTemplate;
/**
* 基础向量检索
*/
public List<Document> search(String query, int topK) {
return vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.7)
);
}
/**
* 带元数据过滤的检索
* 例如:只在特定类别的文档中检索
*/
public List<Document> searchWithFilter(String query,
String category,
int topK) {
// Spring AI的FilterExpression语法
Filter.Expression filter = new Filter.Expression(
Filter.ExpressionType.EQ,
new Filter.Key("category"),
new Filter.Value(category)
);
return vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.65)
.withFilterExpression(filter)
);
}
/**
* 混合检索:向量相似度 + 全文搜索
* 直接使用原生SQL,发挥PGVector和PostgreSQL的全部能力
*/
public List<SearchResult> hybridSearch(String query,
float vectorWeight,
int topK) {
String sql = """
WITH vector_search AS (
SELECT
id,
content,
metadata,
1 - (embedding <=> :queryVector::vector) AS vector_score,
0 AS rank
FROM knowledge_docs
ORDER BY embedding <=> :queryVector::vector
LIMIT :topK * 2
),
text_search AS (
SELECT
id,
content,
metadata,
ts_rank(to_tsvector('simple', content),
plainto_tsquery('simple', :query)) AS text_score,
0 AS rank
FROM knowledge_docs
WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', :query)
LIMIT :topK * 2
),
combined AS (
SELECT
id, content, metadata,
(:vectorWeight * COALESCE(v.vector_score, 0) +
(1 - :vectorWeight) * COALESCE(t.text_score, 0)) AS combined_score
FROM vector_search v
FULL OUTER JOIN text_search t USING (id)
)
SELECT id, content, metadata, combined_score
FROM combined
ORDER BY combined_score DESC
LIMIT :topK
""";
// 先获取query的embedding向量
float[] queryEmbedding = getQueryEmbedding(query);
String vectorStr = toVectorString(queryEmbedding);
return jdbcTemplate.query(sql,
Map.of(
"queryVector", vectorStr,
"query", query,
"vectorWeight", vectorWeight,
"topK", topK
),
(rs, rowNum) -> SearchResult.builder()
.id(rs.getLong("id"))
.content(rs.getString("content"))
.score(rs.getFloat("combined_score"))
.build()
);
}
private String toVectorString(float[] embedding) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < embedding.length; i++) {
if (i > 0) sb.append(",");
sb.append(embedding[i]);
}
sb.append("]");
return sb.toString();
}
}RAG完整流程整合
@Service
@Slf4j
public class RagService {
private final ChatClient chatClient;
private final VectorSearchService searchService;
public RagService(ChatClient.Builder builder,
VectorSearchService searchService) {
this.chatClient = builder
.defaultSystem("""
你是一个专业的知识库问答助手。
请严格基于提供的参考文档回答问题。
如果文档中没有相关信息,请明确告知用户,不要凭空编造。
回答时请标注信息来源(文档标题)。
""")
.build();
this.searchService = searchService;
}
public RagResponse query(String userQuestion) {
log.info("RAG查询: question={}", userQuestion);
// 1. 检索相关文档(混合检索)
List<SearchResult> docs = searchService.hybridSearch(
userQuestion, 0.7f, 5);
if (docs.isEmpty()) {
return RagResponse.builder()
.answer("很抱歉,知识库中没有找到与您问题相关的信息。")
.sources(List.of())
.build();
}
// 2. 构建上下文
String context = buildContext(docs);
// 3. 构建prompt
String prompt = String.format("""
请根据以下参考文档回答问题。
参考文档:
%s
问题:%s
""", context, userQuestion);
// 4. 调用LLM
String answer = chatClient.prompt()
.user(prompt)
.call()
.content();
// 5. 提取来源信息
List<String> sources = docs.stream()
.map(d -> d.getMetadata().getOrDefault("title", "").toString())
.distinct()
.collect(Collectors.toList());
return RagResponse.builder()
.answer(answer)
.sources(sources)
.retrievedDocs(docs.size())
.build();
}
private String buildContext(List<SearchResult> docs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
SearchResult doc = docs.get(i);
String title = doc.getMetadata().getOrDefault("title", "文档" + (i+1)).toString();
sb.append(String.format("【文档%d:%s】\n%s\n\n", i+1, title, doc.getContent()));
}
return sb.toString();
}
}性能调优实践
HNSW索引参数调优
-- 创建高精度HNSW索引(适合100万以下向量)
CREATE INDEX idx_docs_embedding_hnsw_highperf
ON knowledge_docs
USING hnsw (embedding vector_cosine_ops)
WITH (
m = 32, -- 每层最大连接数,越大精度越高、内存越多
ef_construction = 128 -- 构建时候选集大小,越大越精确
);
-- 查询时设置ef_search(影响查询精度和速度的平衡)
SET hnsw.ef_search = 100; -- 默认40,越大越精确越慢
-- 查看索引使用情况
EXPLAIN ANALYZE
SELECT content, embedding <=> '[...]'::vector AS distance
FROM knowledge_docs
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;性能参数对照:
| 场景 | m | ef_construction | ef_search | Recall | QPS(万条数据) |
|---|---|---|---|---|---|
| 开发测试 | 16 | 64 | 40 | ~92% | ~500 |
| 生产平衡 | 32 | 128 | 100 | ~97% | ~300 |
| 高精度 | 64 | 256 | 200 | ~99% | ~150 |
连接池与查询优化
@Configuration
public class DatabaseOptimizationConfig {
/**
* PGVector推荐的连接池配置
*/
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public HikariDataSource dataSource() {
HikariDataSource ds = new HikariDataSource();
// 向量查询是CPU密集型,连接数不要太多
ds.setMaximumPoolSize(20);
ds.setMinimumIdle(5);
// 向量查询可能比较慢,socket超时适当放宽
ds.setConnectionTimeout(30000);
ds.setIdleTimeout(600000);
// 开启预编译语句缓存
ds.addDataSourceProperty("prepStmtCacheSize", "250");
ds.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
return ds;
}
}监控与维护
-- 查看向量表大小
SELECT
pg_size_pretty(pg_total_relation_size('knowledge_docs')) AS total_size,
pg_size_pretty(pg_indexes_size('knowledge_docs')) AS index_size,
COUNT(*) AS row_count
FROM knowledge_docs;
-- 查看索引使用情况
SELECT
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'knowledge_docs';
-- VACUUM分析(定期运行,维护统计信息)
VACUUM ANALYZE knowledge_docs;踩坑记录
| 坑点 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 检索结果为空 | 明明有数据,搜不到 | 相似度阈值设太高 | 先设0.5调试,再慢慢收紧 |
| 写入很慢 | 10条/秒 | 每条都触发HNSW索引更新 | 批量写入,临时关闭索引 |
| 内存告警 | 内存用量突然飙升 | HNSW加载到内存 | 提前做好内存规划,100万向量约4GB |
| 中文检索差 | 中文问题匹配度低 | ada-002对中文不够友好 | 换用中文优化的embedding模型 |
| 维度不匹配 | 存储时报错 | 换了embedding模型但维度不同 | 迁移数据前先drop重建表 |
小结
PGVector在几十万到几百万量级,是非常务实的向量存储方案。核心优势不是性能最强,而是运维复杂度最低——你不需要学一套新的数据库,不需要维护额外的服务,向量查询和业务查询可以在同一个事务里。
老赵他们最终用这套方案,一台32GB内存的PostgreSQL实例支撑了40万文档的检索,P99查询延迟在80ms以内。成本比Milvus独立集群方案低了70%。
不是说Milvus不好,是要根据实际规模选择合适的工具。
