news 2026/9/8 19:08:24

Spring Boot中实现RAG问答系统:源码架构设计与踩坑实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot中实现RAG问答系统:源码架构设计与踩坑实践

简介:这是一份供Java开发者参考的Spring项目检索增强生成(RAG)设计源码,面向希望将AI大模型能力引入企业级Spring应用、并借RAG机制构建专家知识库的开发者和架构师。压缩包共46个文件,大小2.52MB,其中21个Java源文件承担后端业务逻辑,FTL模板与CSS样式负责页面渲染,PNG图片用于界面展示或文档示意,Maven构建文件及配置文件支撑项目编译、运行与依赖管理,同时附带的说明文档和命令行脚本便于快速启动与自动化部署。整体目录结构清晰,适合作为二次开发或课程设计的基础框架。已有614人下载学习。通过该源码,读者可以观察Spring环境下AI大模型接入的完整工程实现,理解从查询解析、知识库检索到答案生成的流程组织,同时结合模块划分与资源配置,快速定位关键环节并做定制扩展。 几个月前,一个做内部知识库的团队找到我,说他们想上一套大模型问答系统。当时他们跑的Demo特别简单:把用户问题直接丢给大模型API,再把几十页产品文档一股脑塞进Prompt。结果大家都猜得到——响应又慢又贵,更麻烦的是,问到某个具体参数时,模型居然一本正经地编了一个不存在的配置项。这个场景太典型了,几乎每个在Spring项目里接大模型的人都会撞上。要解决这个问题,正确的路子就是RAG,检索增强生成。我在这篇文章里想做的,就是基于Java和Spring生态,把RAG的完整源码设计思路拆开讲清楚:从整体工程结构、核心接口设计,到切分策略、Embedding处理、多路召回、重排,再到Prompt编排和流式响应,最后是几个让我印象特别深的坑。

这篇文章不是把某个开源项目贴一遍,而是讲清楚“如果让你从零在Spring Boot项目里设计一套RAG功能,代码该按什么思路去写”。它适合已经在用Java后端、想让项目具备大模型问答能力,但又不想用Python重写一套服务的人。我也会结合Spring AI给出的官方抽象,谈一谈哪些地方可以直接用,哪些地方一定要自己做二次封装。

1. 为什么在Spring项目里坚持自建RAG链路,而不是把知识全部交给大模型

先聊动机。很多人一开始都会有这个疑问:大模型本身不是知道很多吗,为什么还要检索?这是因为大模型的知识是“训练时固化”的,它不掌握你们公司内部的系统手册、项目文档、运维规范,也不了解最近几天才更新的业务数据。你把这些东西直接塞进Prompt,效果大概率是:上下文太长、费用暴涨、回答质量随文档顺序剧烈波动,而且模型会优先被片段中的噪音信息带偏。

RAG的逻辑其实特别朴素:先根据用户问题,去你自己的知识库里检索出最相关的几个片段,再把“问题 + 片段”作为Prompt交给大模型,让模型依据片段作答。这个过程把“生成”和“知识来源”解耦了,回答有没有依据、依据来自哪份文档,都可以被追踪。

这里有一个很现实的选型问题:Spring生态下做RAG,到底该不该引入Spring AI?我的结论是:引入,但必须清楚它的边界。Spring AI提供了ChatClientEmbeddingModelVectorStoreDocument这些抽象,可以帮你屏蔽不同大模型API和不同向量数据库的差异,这部分直接用很香。但Spring AI并不会替你解决业务问题:你的文档怎么解析、怎么切分、检索结果怎么重排、Prompt怎么写、流式响应怎么处理,这些都得自己设计。所以我在项目里的做法是:底层借助Spring AI的抽象层来对接模型和向量库,上层自己封装RetrieverIngestPipelineContextAssembler这些面向业务的组件。

从工程角度看,一条完整的RAG链路可以拆成四条线:文档加载与清洗、文本切分与向量化写入、检索与重排、生成编排。后面所有源码设计都是围绕这四条线展开的。这四条线之间边界越清楚,后面替换实现就越轻松,比如今天用通义千问,明天换成私有化部署的本地模型,或者今天用MySQL存向量,明天迁移到更专业的向量库,都不至于伤筋动骨。

2. 工程骨架:按“文本处理、索引、检索、生成”四条主线拆模块

我设计源码的第一步永远是定包结构。一个能让人一眼看懂的RAG项目,模块边界应该和业务链路完全对齐。实际项目中我一般这么拆:

com.example.rag ├── core // Document、Chunk、RetrievalResult 等基础模型 ├── ingest // FileParser、TextSplitter、EmbeddingClient、VectorStore ├── retriever // Retriever、VectorRetriever、KeywordRetriever、HybridRetriever、Reranker ├── generator // ContextAssembler、PromptTemplate、ChatService └── web // ChatController,负责HTTP/SSE入口

core模块是整个系统的地基,里面只有纯Java对象,不依赖任何Spring以外的框架。很多团队做RAG最容易犯的错误就是:还没想清楚Chunk长什么样,就开始写向量入库逻辑。其实RAG里最核心的单元不是文档,而是切分之后的块Chunk。一个Chunk至少应该包含:唯一ID、所属文档ID、正文内容、字符级别的元数据(来源文件名、页码、章节标题)、以及切分顺序。元数据尤其重要,因为后面做权限过滤、来源展示、引用溯源,全都靠它。

ingest模块负责把原始文档变成可以检索的索引。这一条链路里最关键的两个接口,一个是TextSplitter,一个是VectorStoreTextSplitter的职责很纯粹:输入一个Document,输出一堆ChunkVectorStore则负责把Chunk向量化并写入向量数据库。Spring AI里已经有VectorStore的抽象,但TextSplitter我很少直接用它默认的实现,因为不同业务文档的切分策略差异太大了,这个后面会细说。

retriever模块是RAG区别于普通对话系统的关键。我的设计是让所有检索器都实现同一个接口:

public interface Retriever { List<RetrievalResult> retrieve(String query, int topK); }

RetrievalResult里包含命中的Chunk、相似度得分、召回来源(是向量检索、关键词检索还是别的)。这个接口的意义在于:上层生成模块根本不关心结果是怎么来的,它只需要拿到一个按相关度排好序的列表。实际项目里VectorRetriever负责用Embedding做语义检索,KeywordRetriever负责用BM25这类算法做关键词匹配,再通过HybridRetriever把两者合并,最后交给Reranker精排。这条链路在后面的章节会展开讲。

generator模块反而是所有这些模块里最“薄”的一层,它只做三件事:把检索结果组装进Prompt、控制Token预算、调用大模型生成回答。很多项目把这个模块做得特别厚,什么业务逻辑都往里塞,这是不对的。生成模块应该保持无状态、可测试,给它一个query和一组RetrievalResult,它返回一段流式输出,这样就足够干净了。

包结构定好之后,我一般会让团队成员遵守一条规矩:core模块不允许依赖ingestretrievergenerator只依赖coreretriever的接口,不依赖具体实现。这样约束下来,后续任何人想加一种新的文档格式、换一个向量库、接一个新的大模型API,都只需要动对应模块内部,不会牵一发动全身。

3. 检索层源码细节:Embedding一致性、多路召回与重排策略

检索是RAG的灵魂。很多RAG项目效果差,问题不是大模型不行,而是检索回来的内容根本不对。这段我挑三个在源码设计中最容易出问题的点来讲:Embedding一致性、切分策略、多路召回与重排。

3.1 入库和查询必须保证Embedding一致,这是最容易翻车的点

Embedding模型负责把文本转成向量。向量检索的本意是:语义相近的文本,向量在高维空间里距离也近。但这句话有一个大前提——入库用哪个Embedding模型,查询就必须用同一个。我曾经在一个项目里看到过这样的问题:第一天用A模型的Embedding接口把三千篇文档都向量化了,第二天因为额度问题换了B模型,但没有重建索引,结果检索回来的结果和用户的问题驴唇不对马嘴。原因很简单,两个模型的向量空间根本不一致,余弦相似度再高也没有意义。

为了从机制上避免这个问题,我的做法是在VectorStore里引入索引版本号和模型标识。每次向量化写入时,记录当时的embeddingModelName和模型版本。查询的时候,如果检测到当前模型与索引里的模型不一致,直接拒绝检索并提示需要重建索引。虽然这个检查不能帮你自动重建索引,但它至少避免了“悄悄跑偏”的情况。

入库流程的核心代码大致是这样的逻辑:

public void ingest(Document document) { List<Chunk> chunks = textSplitter.split(document); List<float[]> vectors = embeddingClient.embed(chunks); for (int i = 0; i < chunks.size(); i++) { VectorRecord record = new VectorRecord(); record.setChunkId(chunks.get(i).getId()); record.setEmbeddingModel(embeddingClient.modelName()); record.setEmbeddingVersion(embeddingClient.modelVersion()); record.setEmbedding(vectors.get(i)); vectorStore.upsert(record); } }

3.2 切分策略:固定窗口只是保底方案,结构感知切分才是归属

文本切分这件事,听起来简单,做起来非常考验细节。最基础的方案是固定长度切分,比如512个字符一块,相邻块重叠50个字符。这种方案实现简单、逻辑通用,但很容易把一句话、一个表格、一段代码拦腰截断,导致检索回来的片段语义残缺。

我在实际项目中采用的是“结构优先,窗口兜底”的两级切分策略:先用Markdown标题、段落、列表、表格这些文档结构把内容切成大块,然后再对长度超标的块按窗口滑动切分。比如一个超过1024字符的大段落,我会在句子边界处找切分点,并让相邻块保留一部分重叠内容。重叠的意义在于,不要把一句话的上下文信息全部丢给下一块。

关于切分长度,我还有一个参数想提醒一下:先看你的Embedding模型支持的最大token数,再反过来定字符阈值。国内不少Embedding模型支持的长度在512到1024个token之间,你可以先按4个字符约等于1个token估算,再留出20%的安全余量。比如模型最大支持512 token,那单块纯文本建议控制在1600到2000个字符以内。如果你依赖Spring AI的TokenTextSplitter,记得它内部是按token计数的,不是按字符,这个区别影响很大。

另外,中文场景里按“字符数”切分和按“token数”切分是不一样的,中文字符对应的token数通常比英文单词要低。所以不要拿英文社区那套“每块1000词”的经验直接套中文文档,最好用真实语料跑一遍统计再定参数。

3.3 多路召回:向量检索不是万能的,关键词和元数据过滤必须跟上

向量检索擅长处理“语义相似”的问题,但它对精确匹配非常不友好。比如用户问“CVE-2024-38819这个漏洞影响哪个版本”,如果你只做向量检索,很可能因为“CVE-2024-38819”这个精确编号在语义上和问题本身没有强关联,导致命中排到很后面。这种场景就需要关键词召回。所以成熟一点的RAG系统都是多路召回:一路走向量语义检索,一路走BM25关键词检索,必要时再加一路按标签、分类、权限字段做的元数据过滤。

我的HybridRetriever核心逻辑是把多路结果的分数归一化后合并:

public List<RetrievalResult> retrieve(String query, int topK) { List<RetrievalResult> vectorHits = vectorRetriever.retrieve(query, topK * 2); List<RetrievalResult> keywordHits = keywordRetriever.retrieve(query, topK * 2); Map<String, RetrievalResult> merged = new LinkedHashMap<>(); for (RetrievalResult hit : vectorHits) { merged.put(hit.getChunkId(), hit); } for (RetrievalResult hit : keywordHits) { merged.merge(hit.getChunkId(), hit, (a, b) -> a.withScore(a.getScore() + b.getScore())); } return reranker.rerank(query, merged.values(), topK); }

每路召回各自取前2倍数量的结果再合并,是为了避免某一路效果不佳时淹没掉另一路的有效结果。合并之后必须过一遍Reranker,这是多路召回里不能省的一步。最简单的方式是直接按合并分数排序,但这本质上还是各路的原始分,不同路的分数没有可比性。稍微好一点的做法是引入一个重排模型,把“用户问题 + 候选片段”拼在一起打一个相关性分,然后按这个分重新排序。很多开源的Rerank模型对中文的支持已经不错,效果提升非常明显。

4. 生成编排与上下文工程:Prompt模板、Token预算与流式响应

检索做得再好,最后还是要落到生成这一环。而这一个环节的代码量虽然不大,但直接决定用户体验和回答质量。

4.1 Prompt模板:指令、资料、问题,三段之间要有明确边界

我在generator模块里会维护一套独立的Prompt模板,核心结构分三段:系统指令、资料片段、用户问题。系统指令必须说清楚两条:一是只能依据资料片段回答,二是资料不足时必须承认不知道,禁止推测。

实际的Prompt看起来是这个思路:

System: 你是一名企业内部知识助手。请只依据下面提供的资料片段回答问题。 如果资料中没有足够的信息,请直接回复“资料中未找到相关信息”,不要编造答案。 User: [资料1]:…… [资料2]:…… [资料3]:…… 问题:…… 请回答,并在答案末尾标注依据了哪几条资料编号。

有一个很容易犯的错:把资料片段拼进System提示词。System区域是给模型定调性的,如果塞入大量动态片段,会让模型混淆“指令”和“内容”,而且System提示词在很多模型实现里可能被单独处理,片段过多时反而会影响指令遵循效果。所以我坚持把动态内容全部放在User区域,并用[资料N]这样的标记区分。让模型在答案末尾标注资料编号,是为了后续能在界面上展示引用来源,这对企业用户来说非常重要——领导看一眼就知道这个回答是查了哪份文档得来的。

4.2 Token预算:上下文窗口不是给你塞满的,要留出生成空间

大模型API都是按token计费的,而且模型的上下文窗口有上限。很多初学者拿到一个支持128K上下文的模型,就想把所有检索结果全塞进去。结果往往是被塞了一大堆低相关度片段之后,模型反而分不清主次,回答质量显著下降。

我在ContextAssembler里会显式地做Token预算控制,而不是盲目截断。做法是:先扣除系统指令和用户问题占用的token数,再预留一部分给模型生成回答,剩下的才是可用的资料片段空间。然后把检索结果按相关性从高到低逐个放入预算池,放不下的片段就丢弃。

public String assemble(List<RetrievalResult> hits, String question) { int maxToken = chatModel.maxContextTokens(); int reserved = estimateToken(systemPrompt) + estimateToken(question) + 1024; int budget = maxToken - reserved; StringBuilder ctx = new StringBuilder(); for (RetrievalResult hit : hits) { int t = estimateToken(hit.getContent()); if (t > budget) break; ctx.append("[资料").append(hit.getChunkId()).append("]:") .append(hit.getContent()).append("\n\n"); budget -= t; } return ctx.toString(); }

这里estimateToken不建议用content.length()去估算字符数,而要尽量用tokenizer或者按比例估算。如果拿不到精确tokenizer,中英文混合场景可以用length / 2 + 1做粗略估算,并预留足够的生成空间。生成空间预留多少个token取决于业务需要的回答长度,如果是给客服场景用,建议预留多一点,否则模型可能回答到一半就截断了。

4.3 流式响应:用SSE把“打字机”效果做出来,等待体验天差地别

大模型接口的响应时间一般都在几秒甚至十几秒,如果做成普通的同步HTTP请求,前端就卡在那里转圈,用户体验非常糟糕。所以Chat接口我坚持用流式输出。Spring生态里最方便的方式就是SSE(Server-Sent Events),配合WebFlux返回Flux<String>

Controller层可以这样写:

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestParam String question) { return chatService.stream(question) .map(text -> ServerSentEvent.builder(text).build()); }

前端通过EventSource或者fetch的流式读取就可以实现打字机效果。需要注意,如果你在Spring MVC的普通Servlet应用里接入流式输出,要对超时时间、线程池做额外配置。做流式输出时,检索和多路召回这些耗时操作应该放在流式返回之前,也就是先等检索完成、Prompt组装好、再开始流式吐字。千万不要把检索也做成异步流,否则前端会看到一段很长的静默期,体验反而更差。

5. 踩坑实录:从“答非所问”到可用系统,我踩过的四个坑

最后这一段,我把自己在这个项目里踩过、也帮别人排查过的几个典型问题列出来。这些问题单看都不难,但组合在一起,会直接影响一套RAG系统能不能从Demo走到生产环境。

第一个坑是表格被切分切坏了。当时系统里有一份设备参数表,切分的时候按照纯文本段落去处理,表格的行和列被拆得七零八落。用户问“某个型号的额定功率是多少”时,检索到的片段只有表头或者只有某一行数据,模型怎么答都不对。后来我把表格单独做了预处理:先把每行转成“字段名:值”的文本,再作为独立块入库。这个问题对文档类RAG项目特别普遍,千万别指望通用切分器帮你处理表格。

第二个坑是Embedding换模型后没有重建索引。上文中已经提过,这里再补充一句:线上如果遇到检索效果突然变差,第一时间检查索引里的模型版本和当前用的模型版本是否一致,这比排查数据、排查代码都快得多。为避免这个问题,我后来在运维层面加了一个小工具,专门扫描索引元数据里不一致的模型版本,并触发重建任务。

第三个坑是把所有检索结果无脑塞进上下文。项目上线初期,为了“不遗漏信息”,我把10条检索结果全部塞进Prompt,结果模型回答出来的内容冗长且自相矛盾。后来看了线上日志,发现有些低分片段和问题的相关性其实很低,它们带来的只有噪音。现在我宁可只保留3到5条高相关片段,回答质量反而稳定很多。这个县城经验其实和搜索很像:前端展示10条结果没问题,但给模型当上下文,质量永远要优于数量。

第四个坑是没有一套可量化的评估集就上线。做RAG最怕凭感觉调参:今天调大切分窗口,感觉回答变好了;明天换了个Rerank模型,又觉得变差了。没有评估集,你根本说不清是哪里变了。我的做法是:在第一周就整理出50到100对企业真实场景的问答对,每个问题标记了正确答案涉及的文档ID。之后每次调整切分、检索、重排、Prompt都跑一遍评估集,统计两个指标:检索命中率(正确答案涉及的文档有没有出现在top-k里)和回答正确率(人工或大模型打分)。这样每次改动是变好还是变坏,很快就一目了然。

所以如果你也要在Spring项目里落地RAG,我的建议是别一上来就追求多复杂的Agent编排。先把“文档切分 -> 向量化入库 -> 多路召回 -> 重排 -> 流式生成”这条最小闭环跑通,保证引用可追溯、Token可控制、模型可替换,再慢慢去优化细节。等基础链路稳定了,你自然会知道下一步该往哪里加东西。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 19:08:02

linux内核参数调整小结

调整 Linux 内核参数是优化系统性能、增强安全性和提高稳定性的重要手段。这些参数控制着内核的各种行为&#xff0c;包括内存管理、网络设置和进程调度等。通过合理配置内核参数&#xff0c;可以使系统更好地适应特定的应用需求和工作负载。内核参数的分类&#xff1a;内存管理…

作者头像 李华
网站建设 2026/9/8 19:06:57

Delphi 12.3下XLSReadWriteII 6.02.01安装实战与Excel读写性能优化

简介&#xff1a;这是一份面向 Delphi 开发者的 XLSReadWriteII 6.02.01 控件安装包&#xff0c;覆盖 D7 到 D12.2 等多个编译器版本&#xff0c;能在不安装 Office 的情况下直接读写 Excel 文档&#xff0c;特别适合需要做报表输出、批量导入导出与表格模板处理的桌面应用。压…

作者头像 李华
网站建设 2026/9/8 19:05:15

res-downloader 下载总卡在 99%?从建链到落盘讲透资源下载的完整链路

res-downloader 下载总卡在 99%&#xff1f;从建链到落盘讲透资源下载的完整链路 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader …

作者头像 李华
网站建设 2026/9/8 19:05:13

Demo与生产环境差异:FDE必避的“上线翻车”陷阱与自检清单

上周四我蹲在客户现场处理告警&#xff0c;业务群里的运营姑娘甩出一句话&#xff1a;“当初Demo演示的时候不是跑得好好的吗&#xff1f;怎么一上线全是问题&#xff1f;”这句话我相信很多FDE都听过&#xff0c;甚至自己心里也犯过嘀咕。FDE这个岗位&#xff0c;说白了就是要…

作者头像 李华
网站建设 2026/9/8 19:05:05

工业AI数据链路新范式:DolphinDB MCP Server让Agent直连时序数据库

开头之前在一个风电场的设备故障诊断项目里&#xff0c;我一度被数据链路折磨到怀疑人生。故障征兆早就出现在DolphinDB里的振动、温度和转速数据中了&#xff0c;但让算法工程师去写Python脚本连库取数&#xff0c;再清洗、再画图、再喂给大模型分析&#xff0c;一个来回少说两…

作者头像 李华
网站建设 2026/9/8 19:05:05

单片机毕业设计-基于 STM32 的物联网智能门锁 APP 监控系统设计 基于 STM32 的多验证方式智能门禁报警系统设计与实现(012507)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华