做 RAG 最绕不开的一环就是向量存储。试过专门的向量数据库,也试过内存式方案,最终在一个 Java 项目里我选择了 PostgreSQL + pgvector 来落地。用下来最大的感受是:省心。不需要多维护一套中间件,也不需要把 SQL 体系和向量检索拆成两套逻辑,在已有 PostgreSQL 的团队里几乎零成本接入。这篇文章把整个实施过程捋一遍,从建表到 Java 代码实现语义检索,再到 RAG 闭环里踩过的坑,适合已经有 Java 基础、想快速把向量检索能力嵌进现有系统的开发者参考。
1. RAG 场景下,为什么我最终选了 pgvector
1.1 RAG 链路对向量检索的真实要求
RAG 的核心逻辑很直白:外部知识不一定都在模型参数里,先把文档切块、转成向量、存进数据库,用户提问时再把问题也转成向量,检索出最相关的几个文本块,拼进提示词里交给大模型生成。这个流程里的关键一环就是向量检索,它的召回质量直接决定最终回答靠不靠谱。
刚开始我一直在纠结要不要单独引入一个专用向量数据库。后来仔细盘了一下需求才发现,大部分项目的向量数据量都在几万到几十万条这个区间,查询延迟要求也不像推荐系统那么苛刻,真正重要的是“能和现有业务代码流畅整合”。pgvector 恰好踩中了这个需求:它不改变 PostgreSQL 的使用习惯,表、索引、事务、权限全都沿用原有的能力,业务代码里一份 JDBC 连接就能同时处理关系数据和向量数据。
1.2 方案对比:pgvector 与专用向量库怎么选
简单对比一下我自己用过的几种方案。第一种是内存式向量检索,比如把向量全部加载到应用进程里,用暴力循环或者自建索引来检索。数据量小的时候确实很快,但一旦服务重启就要重新加载,而且和后端业务表做关联查询很别扭。第二种是独立部署一套向量数据库,功能确实更强,支持分布式、自带可视化控制台,但运维成本也随之拉高,备份、监控、权限、升级通通要单独管。第三种就是 pgvector,查询能力由 PostgreSQL 执行器统一调度,和业务表同库同事务。
团队已经有 DBA 的话,pgvector 的学习成本几乎为零。项目里如果只是给 RAG 提供“存向量 + 相似度召回”的能力,用不到分布式,真的没必要为这点需求额外维护一个组件。
1.3 全文检索与向量检索的本质差异
很多第一次接触 RAG 的人会问:PostgreSQL 自带全文检索,为什么还要再上一套向量?这两类检索逻辑是不同的。全文检索匹配的是“关键词是否出现”,向量检索匹配的是“语义是否相近”。比如用户搜“怎么重置密码”,如果文档里写的是“如何修改登录凭据”,全文检索大概率命中不了,但向量检索能把这两句本来含义接近的话映射到相邻位置,从而召回这段内容。
实际项目中两者经常互补。PostgreSQL 允许在同一张表上同时建全文索引和向量索引,查询时先用向量做语义召回,再用关键词做过滤或加权,效果比单用任何一种都稳。
2. 环境准备与全套基础配置
2.1 源码安装 pgvector 及版本匹配
pgvector 是一个 PostgreSQL 扩展,不是独立服务。安装前先把 PostgreSQL 装好,推荐 13 以上版本,越新的版本对索引和并发支持越完善。以常见的 Linux 环境为例,安装过程大概是这样的:
# 安装 PostgreSQL,如果已安装可以跳过 sudo apt update sudo apt install postgresql postgresql-contrib # 拉取 pgvector 源码并编译安装 git clone --branch v0.6.0 https://github.com/pgvector/pgvector.git cd pgvector make sudo make install这里有一个我踩过的坑:pgvector 编译依赖 PostgreSQL 的开发头文件。如果 PostgreSQL 是 14 版本,但系统里装的是 15 的 libpq-dev,编译时很容易出现版本不匹配,最后扩展装上了却提示 “could not access file $libdir/vector”。安装之前先用pg_config --version看一下当前默认路径对应的是哪个版本,和实际运行的 PostgreSQL 版本对上再动手。
2.2 数据库、扩展、连接串初始化
扩展最好是装到项目独立的数据库里,而不是直接装到默认的 postgres 库。操作命令很简单:
CREATE DATABASE rag_demo; \c rag_demo CREATE EXTENSION IF NOT EXISTS vector;确认扩展装好可以执行:
SELECT extname, extversion FROM pg_extension;连接串没什么特殊的地方,和普通 PostgreSQL 完全一致。比如 JDBC 的 URL 写jdbc:postgresql://localhost:5432/rag_demo就行,不需要额外的协议参数。
2.3 Java Maven 依赖与工程骨架
Java 端需要两个依赖:PostgreSQL JDBC 驱动和 pgvector-java 工具库。驱动版本尽量用 42.5.0 以上,低版本对自定义类型的支持不够好。Maven 配置如下:
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.1</version> </dependency> <dependency> <groupId>io.pgvector</groupId> <artifactId>pgvector-java</artifactId> <version>0.1.5</version> </dependency>工程结构我习惯按三层拆:Repository 只负责 SQL 操作;Service 负责 Embedding 调用、文本切分和 RAG 链路编排;Controller 或者消息入口负责接收请求。不要把向量化逻辑和持久化逻辑混在一个类里,后面换模型、改切分策略时能省很多事。
2.4 连接池与事务注意点
项目里如果用 HikariCP 或 Druid,连接池配置不需要特殊处理,vector 类型只是普通数据库类型。有一点要注意:如果批量写入文档块,尽量把一批插入放到同一个事务里,否则中途失败会出现“文档表有记录,但分块表缺数据”的脏状态。
@Transactional public void saveDocumentWithChunks(DocumentEntity doc, List<ChunkEntity> chunks) { // 先插入文档,再插入所有分块 }事务里执行大批量插入时,连接池的最大连接数没必要开很高,反而容易把数据库连接占满。保持默认的 10~20 个连接就够用。
3. 表结构设计与索引调优
3.1 文档表与分块表的拆与合
我选择把“文档元信息”和“文本块向量”拆成两张表。文档表存标题、路径、上传时间等静态信息;分块表存每个块的序号、内容、向量,带一个文档 ID 外键。靠外键关联,删除整篇文档时只需要按文档 ID 删分块记录,避免大文档散落一堆分块不好管理。
CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, source_path TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE chunks ( id BIGSERIAL PRIMARY KEY, doc_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, chunk_index INT NOT NULL, chunk_text TEXT NOT NULL, embedding vector(768) );如果业务很简单,也可以不分表。但实际做 RAG 时,文档和分块的比例经常是 1:N,合在一张表里会导致文档信息大量冗余,而且元数据和向量列的写频率不同,拆开更利于扩展。
3.2 向量维度选择与长度限制
vector(768)里的 768 是向量维度,由 Embedding 模型决定。开源 Embedding 模型有的输出 384 维,有的输出 768 维,远程 Embedding API 有的输出 1536 维。这个值必须在建表时确定,后期不能直接改。如果维度不匹配,插入数据时会报类似expected 1536 dimensions, not 768的错误。
维度不是越高越好。维度越高,单条向量占用的存储越大,检索耗时也越高。如果模型支持配置输出维度,建议先在测试集上跑一遍,找一个“召回够用、性能可控”的平衡点。另外 pgvector 对向量长度有硬限制,新版本最大 2000 维,历史版本是 1600 维。如果模型输出 4096 维,要么用 PCA 等降维手段,要么换一个输出维度更小的模型。
3.3 HNSW 索引参数怎么定
pgvector 提供两种索引:IVFFlat 和 HNSW。IVFFlat 的原理是先对向量空间做聚类,查询时只在少数几个聚类桶里搜索,建索引前需要有足够数据来训练聚类中心,比较适合数据量特别大、可以离线批量构建的场景。HNSW 基于跳表结构的图算法,构建时不需要预训练,插入即可索引,查询延迟和召回率在小到中等数据量下非常稳定。我的默认选择是 HNSW。
建索引语句:
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);HNSW 最核心的两个参数是 m 和 ef_construction。m 控制每个节点的邻居数,越大召回越准,但内存和索引体积也越大;ef_construction 控制构建阶段的候选队列长度。对 10 万级数据,从 m=16、ef_construction=64 开始调,基本能兼顾性能和召回。查询时的 ef 参数由 SQL 侧控制,这个后面会讲。
3.4 距离函数选择与算子对应关系
pgvector 支持三种距离运算:
| 距离函数 | SQL 算子 | 语义 | 常用场景 |
|---|---|---|---|
| L2 欧氏距离 | <-> | 数值差越小越近 | 图像、数值型向量 |
| 余弦距离 | <=> | 方向差异度量 | 文本语义检索 |
| 内积距离 | <#> | 点积取反 | 归一化向量 |
文本 Embedding 一般用余弦相似度,对应的索引算子必须写成vector_cosine_ops。如果你建索引时用的是vector_l2_ops,但查询时写embedding <=> ?,优化器没法直接用索引,只能全表扫描。这个不匹配问题在排查性能时非常容易出现,一定要盯住。
4. Java 端向量写入与检索实现
4.1 实体模型和 Repository 层
先定义分块实体:
public class ChunkEntity { private Long id; private Long docId; private Integer chunkIndex; private String chunkText; private List<Float> embedding; private Float similarity; // getter/setter 省略 }Repository 层用 Spring 的 JdbcTemplate 或者 MyBatis 都行,核心就是一个插入方法和一个相似度查询方法。JdbcTemplate 的写法最直观,下面会直接给出可运行的 SQL 片段。
4.2 向量的写入:字符串拼接与 PgVector
最直接的方式是拼字符串。pgvector 的向量列接受文本格式[0.1,0.2,0.3],所以可以把 List 转成字符串再插入:
String sql = """ INSERT INTO chunks (doc_id, chunk_index, chunk_text, embedding) VALUES (?, ?, ?, ?::vector) """; String vectorStr = embedding.stream() .map(String::valueOf) .collect(Collectors.joining(",", "[", "]")); jdbcTemplate.update(sql, docId, chunkIndex, chunkText, vectorStr);这种方式简单直白,但维度大的时候字符串拼接会占用不少内存。更推荐用 pgvector-java 提供的 PgVector 对象:
import io.pgvector.PGvector; PreparedStatement ps = connection.prepareStatement(sql); ps.setLong(1, docId); ps.setInt(2, chunkIndex); ps.setString(3, chunkText); ps.setObject(4, new PGvector(embedding)); ps.executeUpdate();PgVector 对象内部处理了序列化,代码更清晰,也避免字符串拼接过程中遗漏浮点数的指数形式、负号等问题。
4.3 相似度查询和结果映射
查询时用余弦距离排序,取相似度最高的前 N 条:
SELECT id, doc_id, chunk_index, chunk_text, 1 - (embedding <=> ?) AS similarity FROM chunks ORDER BY embedding <=> ? LIMIT ?;Java 端:
public List<ChunkEntity> searchSimilar(List<Float> queryVector, int topK) { String sql = """ SELECT id, doc_id, chunk_index, chunk_text, 1 - (embedding <=> ?) AS similarity FROM chunks ORDER BY embedding <=> ? LIMIT ? """; List<Float> vector = queryVector; return jdbcTemplate.query(sql, new Object[]{vector, vector, topK}, (rs, rowNum) -> { ChunkEntity e = new ChunkEntity(); e.setId(rs.getLong("id")); e.setDocId(rs.getLong("doc_id")); e.setChunkIndex(rs.getInt("chunk_index")); e.setChunkText(rs.getString("chunk_text")); e.setSimilarity(rs.getFloat("similarity")); return e; }); }这里有一个容易踩的类型坑:queryVector 必须是 List 或 float[],如果类型写成了 List 或 double[],pgvector-java 在类型转换时会直接抛异常。遇到过几次才记住,索性在工具类里做统一入口。
4.4 混合检索:关键词 + 语义召回
纯向量检索有时会漏掉精确匹配。比如用户提问里含有一个很具体的产品编号,向量检索召回的可能是同义表达,反而漏掉了包含编号的那条。这时候可以在 SQL 里叠加关键词过滤:
String sql = """ SELECT id, doc_id, chunk_index, chunk_text, 1 - (embedding <=> ?) AS similarity FROM chunks WHERE chunk_text ILIKE '%' || ? || '%' ORDER BY embedding <=> ? LIMIT ? """;危险是关键词太严格会导致召回为空,所以实际项目里我更喜欢先单纯向量召回 20 条,再在应用层判断是否包含关键词,命中词在 similarity 上加一个固定权重,比如 +0.1。这样既保证精确匹配优先,又不会因为关键词过滤把语义结果全部排掉。
4.5 批量导入的快速方案
批量导入大量文档时,最忌讳边插边维护 HNSW 索引。这个阶段每条记录都要更新图结构,性能会以数量级下降。实操经验是:先建表不建索引,批量插入完成后统一 create index。10 万条文本块,从“边插边建索引”到“先插后建索引”的耗时差距我实测有三到五倍。导入完成后执行一次ANALYZE chunks;,让优化器拿到最新统计信息,避免后续查询因统计信息缺失走错执行计划。
5. 把 RAG 闭环真正跑通
5.1 文本切分:块大小、重叠与边界策略
从原始文档到文本块是 RAG 效果的分水岭。块太大,每个向量承载的语义太多,检索到的内容可能夹带无关信息;块太小,语义碎片化,召回结果上下文不连贯。我一般控制在 300 到 800 个中文字符之间,优先按段落切,段落太长的再按句子切。
相邻块之间最好做一点重叠(overlap),比如上一块末尾的 50~100 字在下一块开头再出现一次。这样即使关键信息刚好落在切分边界上,也不会被整段丢掉。实测下来,重叠策略对召回率的提升非常明显,尤其是问答型文档。
5.2 Embedding 接口抽象与模型一致性
向量化能力无论来自远程服务还是本地模型,都该封装成统一接口:
public interface EmbeddingService { float[] embed(String text); }项目里文档入库和在线查询都要走这个接口。最容易出问题的是“模型漂移”:文档库是用 A 模型生成的向量,后来查询时换成了 B 模型,不同模型的向量空间不一致,余弦相似度就没有任何意义。如果确实要换 Embedding 模型,一定要把全量文档重新向量化,不能新旧向量混着用。
5.3 查询链路实现与提示词组装
把查询链路串起来,核心就是三步:问题向量化、向量检索、拼提示词。
@Service public class RagServiceImpl implements RagService { private final EmbeddingService embeddingService; private final ChunkRepository chunkRepository; private final LmClient lmClient; @Override public String query(String userQuestion) { float[] queryVector = embeddingService.embed(userQuestion); List<ChunkEntity> chunks = chunkRepository.searchSimilar(queryVector, 5); String context = chunks.stream() .map(ChunkEntity::getChunkText) .collect(Collectors.joining("\n\n")); String prompt = "请根据以下资料回答问题:\n" + context + "\n问题:" + userQuestion + "\n如果资料中没有相关信息,请直接说明无法回答。"; return lmClient.complete(prompt); } }这里的 LmClient 可以是对远程大模型服务的 HTTP 封装,也可以走本地推理进程,重点是不要在大模型调用层里混入检索逻辑,保持链路单一。
5.4 Rerank 与上下文截断
向量检索返回的 top 5 不一定是最适合生成的 top 5。想要效果更稳,先召回 20 条候选,再用更精细的排序逻辑或专门的 Rerank 模型压缩到 5 条。如果不用 Rerank 模型,至少做一个关键词加权,把与问题主题强相关的块排到前面。
上下文长度也要控制。文本块全拼进提示词大概率触发 token 超限,所以单次检索数量顶多取 5~10 块,每块入库时也要限制最大长度。超长的块先截断再向量化,要不然提示词会越来越臃肿。
6. 常见问题与排查经验实录
6.1 类型转换与维度不一致报错
最常见的报错是column "embedding" is of type vector but expression is of type text,原因是插入时 JDBC 把字符串绑成了 text 类型,没有走向量转换。解决办法是拼 SQL 时加上::vector,或者直接用 PgVector 对象。另一个高频报错是expected 768 dimensions, not 512,几乎都是混用了不同 Embedding 模型,统一入口后就能解决。
6.2 索引没被使用 / 执行计划怎么看
SQL 查询慢不一定是指数算法问题,先看执行计划:
EXPLAIN SELECT ... FROM chunks ORDER BY embedding <=> ? LIMIT 5;输出里如果出现Seq Scan on chunks,说明向量索引没生效。排查顺序是:有没有 LIMIT,没有 LIMIT 时优化器可能放弃索引;表数据量是不是太小,几千行的表走全表扫描反而更快;算子和索引类型是否匹配,比如建索引是vector_l2_ops,查询却用了<=>。把算子对齐后,执行计划通常会自动切到 Index Scan。
6.3 大批量导入慢与数据库参数
除了前面说的“先插后建索引”,还可以调整 PostgreSQL 的 maintenance_work_mem,让建索引时可以使用更多内存:
SET maintenance_work_mem = '2GB';写入过程中也可以临时关闭表的 WAL 日志,但正式环境不建议这么干,做完立刻恢复。更稳妥的方案是分批提交,比如每 500 条插一次,避免单事务积累大量未持久化数据。
6.4 召回结果差,怎么定位问题
召回差不一定是指数问题,先自检三件事:文档切分是否合理,块是不是过大或过碎;Embedding 模型是否统一;数据里是否存在大量相似文本导致头部被冗余内容占满。我遇到过一版效果很差,排查下来发现是切分时没有去重,同一个段落被切成了几十个高度重复的块,检索结果几乎全是重复内容。后来在入库前加了文本清洗和去重,效果立刻好了很多。
这里整理一个速查表,排查问题时可以顺手对照:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 查询慢 | 未建向量索引或算子不匹配 | 检查执行计划并统一算子 |
| 召回结果差 | 文本块切得过大或过小 | 调整块大小和重叠比例 |
| 插入报维度错 | 混用不同 Embedding 模型 | 统一走同一个 EmbeddingService |
| 批量导入极慢 | 边插边维护 HNSW 索引 | 先导数据再建索引 |
| 相似度全是负值 | 用了内积算子但向量未归一化 | 改用余弦距离或先归一化 |
| 前后回答不一致 | 检索上下文不稳定 | 加入关键词加权或 Rerank |
7. 性能实测与调优参考
7.1 数据量与查询延迟的关系
我在一个模拟项目里压过一组数据:5 万条文本块、768 维向量、HNSW 索引(m=16, ef_construction=64),单条相似度查询的 p95 延迟在 15ms 左右;到 20 万条时,p95 大概到 40ms 左右。这个水平对绝大多数 RAG 问答场景完全够用。如果你的数据量到了数百万级,单机 PostgreSQL 的压力会明显上升,届时再考虑读写分离或迁移到专用向量库。
7.2 PostgreSQL 参数调优
不需要太复杂的调整,几个关键参数值得关注:shared_buffers 如果机器内存允许,调到系统内存的 25%~40%;work_mem 不要一开始就调太高,否则连接多时会撑爆内存;maintenance_work_mem 在重建索引阶段可以临时调大。HNSW 索引本身是常驻内存的,注意观察 RSS 占用,别让索引和其他业务表把内存吃满。
7.3 后续扩展方向
pgvector 只是向量存储层,RAG 项目做好后还可以继续做几件事:给检索结果加“来源引用”,让用户能溯源到具体文档段落;建一个监控表记录每次问答的检索块和最终生成结果,定期抽检召回质量;对高频知识库做定时重向量化,确保新语义能被及时纳入。这些都是“在现有架构上做加法”,pgvector 不会成为瓶颈。
我个人实际操作中的体会是,技术选型不必追新,能把现有数据库能力用足就是最稳的路径。pgvector 不是万能的,但它解决了 Java 项目里“既要业务 SQL,又要语义检索”的尴尬,省掉了一套中间件的运维负担。等真正跑到百万级数据量、查询延迟扛不住的时候,再迁移到专用向量库也不迟,而且迁移时只要把 chunks 表的向量部分导出去就行,不会陷入绑定困境。希望这篇实战记录能给你一些参考。