☰
基于Spring AI与PGvector构建生产级RAG检索链路实战
2026/9/29 16:26:01 网站建设 项目流程

1. 知识库落地之后,Java 程序员真正的战场在哪里

很多团队把 RAG 想得太简单了。文档切一切、向量存一存、检索拼一拼,Demo 跑通那一刻感觉大功告成,结果一上生产就露馅:用户问“上季度华东区的退货政策调整对经销商结算有什么影响”,系统返回三段毫不相干的员工手册片段;问“这个接口的超时配置是多少”,它把三年前的架构评审纪要翻了出来。问题不在模型,也不在向量数据库,而在于知识库只是原料,RAG 才是一道需要反复调味的菜。

我接触过不少 Java 团队做 RAG 项目,技术栈从 LangChain4j 换到 Spring AI,向量库从 Redis 换到 PGvector,折腾一圈后发现效果提升有限。根因往往不是工具选错了,而是检索链路的设计被严重低估。知识库已经有了,Java 程序员要做的不是“接一个大模型就完事”,而是围绕检索质量、上下文组装、生成约束这三个环节做工程化打磨。Spring AI 在这件事上给 Java 生态提供了一个相对顺手的抽象层,尤其是VectorStore、Advisor、ChatClient这几个核心接口,把 RAG 的骨架搭得很清楚,但骨架不等于肌肉,肌肉得自己练。

这篇文章面向的是已经有一个知识库(不管是 Confluence 导出的 Markdown、PDF 手册,还是数据库里的工单记录),想用 Spring AI 把它变成可用的 RAG 服务的 Java 开发者。我会从整体设计思路讲到 PGvector 的实操配置,再到检索命中率的调优和常见坑的排查,尽量把每一步的“为什么”说清楚。读完你至少能搭出一套可上测试环境的 RAG 链路,并且知道效果不好的时候该往哪个方向调。

2. 整体架构设计与技术选型思路

2.1 为什么是 Spring AI 而不是直接调 HTTP 接口

有些同学觉得 RAG 无非就是“向量检索 + 拼 Prompt + 调模型”,用RestTemplate直接发请求也能做,何必引入框架。这个想法在小规模验证阶段没问题,但一旦进入迭代期就会痛苦。直接调接口意味着你要自己管理 Embedding 模型的批量请求、向量维度的对齐、检索结果的元数据过滤、多轮对话的上下文裁剪,这些逻辑散落在各个 Service 里,改一处牵动全身。

Spring AI 的价值在于它把这些环节抽象成了可替换的组件。EmbeddingModel负责文本转向量,VectorStore负责存储和相似度检索,ChatClient负责对话编排,Advisor负责在请求前后插入检索逻辑。你换一个向量库,比如从 PGvector 换到 Milvus,理论上只需要换VectorStore的实现类,业务代码不动。这种可替换性在 RAG 项目里特别重要,因为检索效果不好时,你往往需要快速换 Embedding 模型或调整相似度算法来做对比实验,框架帮你省下的胶水代码时间非常可观。

另一个现实考量是 Spring 生态的整合成本。大部分 Java 后端项目已经在用 Spring Boot,数据库连接池、配置管理、健康检查、可观测性这些基础设施都是现成的。Spring AI 的 starter 能直接复用这些能力,比如把向量库的连接配置纳入application.yml统一管理,把检索耗时纳入 Micrometer 指标。这些看起来是小事,但在生产环境里,可观测性决定了你排查问题的速度。

2.2 PGvector 作为向量库的取舍

向量数据库的选择是 RAG 项目里第一个需要认真做的决定。市面上选项很多,Milvus、Qdrant、Weaviate、Chroma 各有拥趸,但我给大多数 Java 团队的建议是:如果你的数据量在百万级向量以内,且已经在用 PostgreSQL,优先考虑 PGvector。

理由很直接。第一,运维成本几乎为零。你不需要额外部署和维护一个向量数据库集群,PGvector 就是一个 PostgreSQL 扩展,CREATE EXTENSION vector就完事了。第二,事务一致性有保障。知识库的原文和向量存在同一个数据库里,更新文档时可以在一个事务里同时改原文和向量,不会出现“原文更新了但向量还是旧的”这种脏数据问题。第三,SQL 的灵活性。你可以在检索时直接 join 业务表做权限过滤,比如“只检索当前用户有权限访问的文档”,这在专用向量库里往往要绕一圈。

当然 PGvector 也有它的边界。当向量数量超过千万级,或者 QPS 要求很高时,它的性能会成为瓶颈,这时候再考虑迁移到专用向量库。但绝大多数企业内部知识库的场景,文档量在几万到几十万这个量级,PGvector 完全够用。过早引入重型向量库,反而增加了架构复杂度和故障面。

2.3 RAG 链路的四个核心环节

把 RAG 拆开看,一条完整的链路包含四个环节,每个环节都有它的工程难点。

文档摄取与切分是第一步。原始文档可能是 PDF、Word、Markdown 或者网页,需要先解析成纯文本,再切成合适大小的片段。切分策略直接影响检索质量,切得太碎会丢失上下文,切得太大会引入噪声。常见做法是按语义边界切分,比如按标题层级或段落,同时保留一定的重叠区域。

向量化与存储是第二步。用 Embedding 模型把每个文本片段转成向量,连同原文和元数据一起存入 PGvector。这里要注意的是 Embedding 模型的选择,不同模型对中文的支持差异很大,而且一旦选定模型,后续所有查询都必须用同一个模型做向量化,否则向量空间不对齐,检索结果会完全错乱。

检索与重排是第三步,也是最能体现工程水平的地方。基础做法是拿用户问题做向量化,然后在 PGvector 里做余弦相似度检索,取 Top-K 个片段。但纯向量检索有个明显短板:它对关键词的精确匹配不敏感。用户问“接口超时时间”,向量检索可能返回一堆讲“性能优化”的片段,但真正写着“timeout: 3000ms”的那段反而排不上来。所以生产级 RAG 通常会做混合检索,把向量检索和全文检索的结果融合,再用重排模型精排。

上下文组装与生成是最后一步。检索到的片段不能一股脑塞给模型,需要做去重、排序、截断,还要在 Prompt 里明确告诉模型“只根据以下资料回答,资料里没有的就说不知道”。这一步的 Prompt 设计直接决定了模型会不会胡编。

3. 核心细节解析与实操要点

3.1 文档切分:别让切分策略毁掉检索质量

文档切分是 RAG 里最容易被忽视、却最影响效果的环节。我见过太多项目直接用固定长度切分,比如每 500 个字符切一刀,结果把一句话从中间劈开,检索出来的片段读起来莫名其妙。

合理的切分策略应该遵循几个原则。优先按语义边界切分,Markdown 按标题层级切,PDF 按段落切,代码文档按函数或类切。保留重叠区域,相邻片段之间重叠 10% 到 20% 的内容,避免关键信息正好落在切分点上被割裂。控制片段长度,太短则信息不足,太长则噪声过多,一般 300 到 800 个 token 是比较舒服的区间。

Spring AI 提供了TokenTextSplitter和DocumentSplitter这类工具,但默认参数不一定适合你的文档。我的经验是,针对不同类型的文档用不同的切分器。技术手册按章节切,FAQ 按问答对切,会议纪要按发言段落切。切分完之后最好人工抽查几十个片段,看看有没有明显的语义断裂。

注意:切分时一定要保留元数据,比如来源文件名、章节标题、页码。这些元数据在检索后可以用来做过滤和引用展示,没有它们,用户看到答案也不知道出处,信任度会大打折扣。

3.2 Embedding 模型的选择与批量处理

Embedding 模型决定了向量空间的质量,进而决定检索的上限。选模型时主要看三个维度:中文语义理解能力、向量维度、推理成本。

中文场景下,一些专门针对中文优化的模型表现明显好于通用模型。向量维度方面,维度越高表达能力越强,但存储和检索成本也越高。常见的维度在 768 到 1536 之间,PGvector 支持最高 2000 维的索引,但实际选择要权衡。推理成本则关系到你能否在合理时间内完成全量文档的向量化,以及查询时的响应延迟。

批量处理是实操中必须注意的点。文档量大时,逐条调用 Embedding 接口会非常慢,而且容易触发限流。Spring AI 的EmbeddingModel支持批量调用,但批量大小要控制好,太大容易超时,太小则效率低。我一般设置每批 16 到 32 条,配合重试机制处理偶发的失败。

还有一个容易踩的坑:文档更新时的向量同步。如果原文改了但向量没更新,检索出来的就是过期信息。解决方案是在文档表上加一个版本号或更新时间戳,向量化任务只处理变更过的文档。PGvector 里可以用ON CONFLICT做 upsert,保证同一个文档片段的向量被覆盖而不是重复插入。

3.3 PGvector 的表结构与索引设计

PGvector 的使用本身不复杂,但表结构和索引设计有几个细节值得说。

首先是向量列的定义。vector(1536)这样的类型声明里,维度必须和 Embedding 模型输出一致,写错了插入时会直接报错。其次是索引类型,PGvector 支持 IVFFlat 和 HNSW 两种近似最近邻索引。IVFFlat 构建快、内存占用低,但召回率略低;HNSW 查询快、召回率高,但构建慢、内存占用大。数据量在十万级以内时,不建索引直接暴力检索也能接受;超过十万级再考虑建 HNSW 索引。

索引的参数也需要调。IVFFlat 的lists参数一般设为行数的平方根左右,HNSW 的m和ef_construction影响构建质量和速度。这些参数没有万能值,需要根据你的数据分布做实验。

-- 建表:原文、向量、元数据放在一起 CREATE TABLE knowledge_chunk ( id BIGSERIAL PRIMARY KEY, doc_id VARCHAR(64) NOT NULL, chunk_index INT NOT NULL, content TEXT NOT NULL, metadata JSONB, embedding vector(1536), created_at TIMESTAMP DEFAULT NOW() ); -- HNSW 索引,适合查询频繁的场景 CREATE INDEX idx_chunk_embedding ON knowledge_chunk USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- 元数据过滤用的 GIN 索引 CREATE INDEX idx_chunk_metadata ON knowledge_chunk USING gin (metadata);

提示:向量相似度用余弦距离时,索引的 operator class 要选vector_cosine_ops,用欧氏距离则选vector_l2_ops。选错了索引不会被使用,查询会退化成全表扫描,性能差距可能是几十倍。

3.4 检索策略:从纯向量到混合检索

纯向量检索的问题前面提过,它对精确关键词不敏感。解决办法是混合检索,把向量检索和全文检索的结果做融合。

PostgreSQL 本身就有全文检索能力,tsvector和tsquery可以处理关键词匹配。把向量检索的 Top-N 和全文检索的 Top-N 合并,用 RRF(Reciprocal Rank Fusion)算法重新排序,效果通常比单一检索好很多。RRF 的思路很简单:每个文档的最终得分是它在各个检索结果中排名的倒数和,排名越靠前得分越高。

-- 混合检索示意:向量检索 + 全文检索,用 RRF 融合 WITH vector_search AS ( SELECT id, content, ROW_NUMBER() OVER (ORDER BY embedding <=> :queryVec) AS rank FROM knowledge_chunk ORDER BY embedding <=> :queryVec LIMIT 20 ), text_search AS ( SELECT id, content, ROW_NUMBER() OVER (ORDER BY ts_rank(to_tsvector(content), :query) DESC) AS rank FROM knowledge_chunk WHERE to_tsvector(content) @@ :query LIMIT 20 ) SELECT id, content, COALESCE(1.0 / (60 + v.rank), 0) + COALESCE(1.0 / (60 + t.rank), 0) AS score FROM vector_search v FULL OUTER JOIN text_search t USING (id) ORDER BY score DESC LIMIT 10;

这个查询里 60 是 RRF 的平滑常数,经验值,不用改。实际项目中我会把这段逻辑封装成一个 Repository 方法,Spring AI 的VectorStore接口可以自定义实现,把混合检索塞进去。

3.5 Prompt 组装:让模型老实回答

检索回来的片段怎么拼进 Prompt,直接决定模型会不会胡编。我的做法是在 System Prompt 里立三条规矩:只根据提供的资料回答、资料里没有的明确说不知道、回答时标注引用来源。

片段之间用清晰的分隔符隔开,每个片段前面带上来源标识。片段数量不要贪多,Top-5 到 Top-8 通常足够,塞太多反而稀释了关键信息,还增加了 token 成本。如果片段之间有重复内容,先去重再拼。

Spring AI 的QuestionAnswerAdvisor封装了这套逻辑,但默认的 Prompt 模板比较通用。我建议自定义 Advisor,把 Prompt 模板换成适合自己业务场景的版本。比如客服场景要强调“语气友好”,技术文档场景要强调“给出具体配置参数”。

4. 实操过程与核心环节实现

4.1 环境准备与依赖配置

先把依赖理清楚。Spring AI 的版本迭代比较快,建议用当前稳定的 release 版本。核心依赖包括 Spring AI 的 OpenAI starter(或者你用的其他模型 starter)、PGvector 的 VectorStore starter,以及 PostgreSQL 驱动。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency>

配置文件里把模型和向量库的连接信息填好。Embedding 模型和对话模型可以分开配置,因为它们的选型逻辑不同:Embedding 看重语义表达,对话模型看重推理和指令遵循。

spring: ai: openai: api-key: ${AI_API_KEY} embedding: options: model: text-embedding-3-small chat: options: model: gpt-4o-mini temperature: 0.2 datasource: url: jdbc:postgresql://localhost:5432/knowledge username: postgres password: ${DB_PASSWORD}

注意:temperature在 RAG 场景下建议调低,0.1 到 0.3 之间比较合适。温度高了模型容易自由发挥,把检索到的资料改得面目全非。

4.2 文档摄取与向量化的完整流程

文档摄取我一般做成一个独立的批处理任务,而不是放在请求链路里。流程是:读取原始文档、解析成文本、切分成片段、批量向量化、写入 PGvector。

@Service public class DocumentIngestService { private final VectorStore vectorStore; private final TokenTextSplitter splitter; public void ingest(String docId, String rawText, Map<String, Object> meta) { // 1. 切分 List<Document> chunks = splitter.split( new Document(rawText, meta) ); // 2. 给每个片段补上 docId 和序号 for (int i = 0; i < chunks.size(); i++) { chunks.get(i).getMetadata().put("doc_id", docId); chunks.get(i).getMetadata().put("chunk_index", i); } // 3. 批量写入,VectorStore 内部会调用 EmbeddingModel vectorStore.add(chunks); } }

TokenTextSplitter的默认参数是每片 800 token、重叠 350 token,这个重叠比例偏高,实际用的时候可以调到 100 到 200。切分前最好把文本里的多余空行、页眉页脚清理掉,这些噪声会污染向量。

全量向量化的时候要注意限流。如果文档有几千个片段,一次性提交会打爆 Embedding 接口。我的做法是分批提交,每批之间加一个短暂的 sleep,同时用CompletableFuture做并发控制,把并发数限制在 3 到 5 之间。

4.3 检索链路的代码实现

检索链路的核心是把用户问题向量化,然后从 PGvector 里捞出最相关的片段。Spring AI 的VectorStore.similaritySearch提供了基础能力,但生产环境我建议自己封装一层,把混合检索和元数据过滤加进去。

@Service public class RagRetrievalService { private final JdbcTemplate jdbcTemplate; private final EmbeddingModel embeddingModel; public List<Chunk> retrieve(String query, int topK, String userId) { // 1. 问题向量化 float[] queryVec = embeddingModel.embed(query); String vecStr = toPgVector(queryVec); // 2. 混合检索 + 权限过滤 String sql = """ WITH vector_search AS ( SELECT id, content, metadata, ROW_NUMBER() OVER (ORDER BY embedding <=> ?::vector) AS rank FROM knowledge_chunk WHERE metadata->>'dept' = ? ORDER BY embedding <=> ?::vector LIMIT ? ), text_search AS ( SELECT id, content, metadata, ROW_NUMBER() OVER (ORDER BY ts_rank( to_tsvector('simple', content), plainto_tsquery('simple', ?) ) DESC) AS rank FROM knowledge_chunk WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', ?) AND metadata->>'dept' = ? LIMIT ? ) SELECT id, content, metadata, COALESCE(1.0/(60+v.rank),0) + COALESCE(1.0/(60+t.rank),0) AS score FROM vector_search v FULL OUTER JOIN text_search t USING (id) ORDER BY score DESC LIMIT ? """; return jdbcTemplate.query(sql, rowMapper, vecStr, userId, vecStr, topK * 2, query, query, userId, topK * 2, topK); } }

这段 SQL 里metadata->>'dept'是权限过滤的示例,实际项目中你的过滤条件可能更复杂。全文检索用的是simple配置,因为中文分词需要额外装扩展,如果没装,simple至少能保证英文和数字的匹配。

4.4 对话生成与引用标注

检索到片段后,组装 Prompt 调用对话模型。Spring AI 的ChatClient用起来很顺手,配合自定义 Advisor 可以把检索逻辑透明地插入对话流程。

@Configuration public class RagConfig { @Bean public ChatClient ragChatClient(ChatClient.Builder builder, RagRetrievalService retrieval) { return builder .defaultSystem(""" 你是一个知识库助手。请严格根据以下资料回答用户问题。 资料中没有的信息,直接回答"资料中未提及",不要编造。 回答时在句末用 [来源: 文件名] 标注引用。 """) .defaultAdvisors(new RagAdvisor(retrieval)) .build(); } }

RagAdvisor的实现思路是在before阶段拿用户问题去检索,把检索结果拼进 Prompt 的上下文。Spring AI 的 Advisor 接口提供了adviseRequest和adviseResponse两个钩子,检索逻辑放在请求前,引用后处理放在响应后。

生成阶段还有一个细节:流式输出。用户等 5 秒才看到完整答案,体验很差。Spring AI 支持stream()返回Flux<String>,配合 SSE 推给前端,首字延迟能降到几百毫秒。流式输出时引用标注的处理要小心,因为 token 是逐块返回的,引用信息最好在流结束后单独推送。

5. 常见问题与排查技巧实录

5.1 检索命中率低的排查路径

检索效果不好是最常见的问题,排查要按链路顺序来,不要一上来就换模型。

第一步,检查向量化是否正常。把同一个问题向量化两次,看结果是否一致;把两个语义相近的句子向量化,看余弦相似度是否合理。如果相似度普遍偏低或偏高,可能是模型选错了或者维度对不上。

第二步,检查切分质量。随机抽 20 个片段,人工判断它们是否语义完整。如果大量片段是半句话,说明切分参数需要调。

第三步,检查检索语句。把用户问题和检索到的 Top-5 片段打印出来,看是否真的相关。如果不相关,试试调大 Top-K,或者加上全文检索做混合。

第四步,检查 Prompt。有时候检索结果是对的,但模型没用好。把检索片段和最终答案对照,看模型是否忽略了关键信息。

下面这张表是我整理的常见症状和对应排查方向,实际排查时按这个顺序走能省不少时间。

症状可能原因排查动作
检索结果完全不相关向量模型不匹配或维度错误检查 Embedding 模型是否一致,向量维度是否对齐
关键词问题检索不到纯向量检索对精确词不敏感加入全文检索做混合
答案遗漏关键信息片段太长噪声多或 Top-K 太小缩短片段长度,增大 Top-K
答案胡编乱造Prompt 约束不够或温度过高强化 System Prompt,降低 temperature
响应特别慢索引未生效或片段过多检查索引是否被使用,减少送入模型的片段数

5.2 向量维度不一致导致的静默失败

这个坑我踩过。项目初期用了一个 Embedding 模型,后来换了个维度不同的模型,但忘了重新向量化历史数据。结果查询时新问题用新模型向量化,历史数据还是旧模型的向量,检索出来的结果看似有返回,实际全是噪声。这种错误不会报异常,只会静默地降低效果,非常隐蔽。

解决办法是在向量表里记录每条向量用的模型标识,查询时校验模型是否一致。或者更简单,换模型时强制全量重新向量化,把旧数据清掉。

5.3 上下文超长与 token 成本控制

检索片段塞太多会导致 Prompt 超长,轻则响应变慢,重则超出模型上下文限制直接报错。控制方法有几个:限制 Top-K,一般 5 到 8 个片段足够;限制单片段长度,超过阈值的片段做截断;去重,相似度高于 0.95 的片段只保留一个;动态裁剪,按 token 总数倒序累加,超过预算就停。

成本方面,Embedding 调用和对话调用都要计费。文档向量化是一次性成本,可以接受;查询时的 Embedding 调用是持续的,如果 QPS 高,可以考虑加一层查询缓存,相同问题直接返回缓存的向量。

5.4 文档更新后的向量同步问题

知识库是活的,文档会更新、会删除。如果向量不同步,用户就会检索到过期信息。我的做法是在文档表上加updated_at字段,用一个定时任务扫描变更,只对变更的文档重新向量化。删除文档时,同步删除对应的向量记录,用doc_id做关联。

提示:重新向量化时,先删旧向量再插新向量,中间有个短暂的空窗期。如果对一致性要求高,可以给向量记录加版本号,查询时只取最新版本,旧版本异步清理。

5.5 权限过滤不能只靠应用层

多部门共用知识库时,权限过滤是刚需。有些实现是在应用层检索完再过滤,这样有两个问题:一是检索结果可能被过滤光,二是过滤逻辑容易漏。正确做法是把权限条件下推到 SQL 里,在向量检索和全文检索的 WHERE 子句里就加上部门或角色过滤,这样检索出来的结果天然就是有权限的。

PGvector 的元数据用 JSONB 存储,配合 GIN 索引,过滤性能可以接受。如果权限规则特别复杂,可以考虑把权限信息单独建表,检索时 join 进去。

6. 效果调优与进阶方向

6.1 重排模型的引入时机

混合检索之后,如果效果还不够,可以考虑引入重排模型。重排模型的作用是对检索回来的候选片段做精排,它比向量相似度更能理解查询和片段之间的语义关系。典型流程是:向量检索召回 Top-50,重排模型打分后取 Top-5 送入生成。

重排模型的引入有成本,一是延迟增加,二是需要额外部署。我的建议是先用混合检索,效果不满意再上重排。如果上重排,候选集不要太大,20 到 50 条比较合适,太大则延迟不可接受。

6.2 查询改写提升召回

用户的问题往往口语化、有歧义,直接拿去检索效果不好。查询改写是在检索前对问题做一次处理,比如把“这个接口超时咋配”改写成“接口超时时间配置方法”,或者生成多个查询变体分别检索再合并结果。

Spring AI 可以用对话模型做查询改写,成本很低。改写后的查询和原查询一起检索,召回率通常有明显提升。这个技巧在用户提问比较随意的场景下特别有效。

6.3 评估体系的建立

RAG 效果好不好,不能靠感觉,要有评估数据。我一般会准备一个测试集,包含几十到上百个问题和对应的标准答案,每次调整检索策略后跑一遍,看命中率和答案准确率的变化。

评估指标主要有几个:检索命中率,标准答案所在的片段是否出现在 Top-K 里;答案准确率,生成的答案是否包含标准答案的关键信息;引用准确率,标注的来源是否真的支持答案。这些指标不需要很精确,能反映趋势就行。

没有评估体系,调优就是盲人摸象,改了一个参数不知道是变好还是变坏。建立评估体系的投入,在项目进入迭代期后会加倍回报。

6.4 从 RAG 到 Agentic RAG 的演进

基础 RAG 是“一次检索一次生成”,但有些问题需要多步推理。比如“对比 A 产品和 B 产品的退货政策差异”,需要先分别检索两个产品的政策,再做对比。这种场景下,可以让模型自己决定检索什么、检索几次,这就是 Agentic RAG 的思路。

Spring AI 的工具调用能力可以支撑这种模式,把检索封装成一个 tool,让模型在对话过程中自主调用。实现复杂度比基础 RAG 高不少,建议先把基础链路做扎实,再考虑往这个方向演进。

我在实际项目里的体会是,RAG 的效果提升不是靠某一个“银弹”,而是靠切分、检索、重排、Prompt 这几个环节各提升一点,累积起来才有质变。每次只改一个变量,用评估数据验证,稳扎稳打。知识库已经有了,剩下的功夫都在这些细节里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询