ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

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提供了ChatClient、EmbeddingModel、VectorStore、Document这些抽象可以帮你屏蔽不同大模型API和不同向量数据库的差异这部分直接用很香。但Spring AI并不会替你解决业务问题你的文档怎么解析、怎么切分、检索结果怎么重排、Prompt怎么写、流式响应怎么处理这些都得自己设计。所以我在项目里的做法是底层借助Spring AI的抽象层来对接模型和向量库上层自己封装Retriever、IngestPipeline、ContextAssembler这些面向业务的组件。从工程角度看一条完整的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一个是VectorStore。TextSplitter的职责很纯粹输入一个Document输出一堆Chunk。VectorStore则负责把Chunk向量化并写入向量数据库。Spring AI里已经有VectorStore的抽象但TextSplitter我很少直接用它默认的实现因为不同业务文档的切分策略差异太大了这个后面会细说。retriever模块是RAG区别于普通对话系统的关键。我的设计是让所有检索器都实现同一个接口public interface Retriever { ListRetrievalResult retrieve(String query, int topK); }RetrievalResult里包含命中的Chunk、相似度得分、召回来源是向量检索、关键词检索还是别的。这个接口的意义在于上层生成模块根本不关心结果是怎么来的它只需要拿到一个按相关度排好序的列表。实际项目里VectorRetriever负责用Embedding做语义检索KeywordRetriever负责用BM25这类算法做关键词匹配再通过HybridRetriever把两者合并最后交给Reranker精排。这条链路在后面的章节会展开讲。generator模块反而是所有这些模块里最“薄”的一层它只做三件事把检索结果组装进Prompt、控制Token预算、调用大模型生成回答。很多项目把这个模块做得特别厚什么业务逻辑都往里塞这是不对的。生成模块应该保持无状态、可测试给它一个query和一组RetrievalResult它返回一段流式输出这样就足够干净了。包结构定好之后我一般会让团队成员遵守一条规矩core模块不允许依赖ingest和retrievergenerator只依赖core和retriever的接口不依赖具体实现。这样约束下来后续任何人想加一种新的文档格式、换一个向量库、接一个新的大模型API都只需要动对应模块内部不会牵一发动全身。3. 检索层源码细节Embedding一致性、多路召回与重排策略检索是RAG的灵魂。很多RAG项目效果差问题不是大模型不行而是检索回来的内容根本不对。这段我挑三个在源码设计中最容易出问题的点来讲Embedding一致性、切分策略、多路召回与重排。3.1 入库和查询必须保证Embedding一致这是最容易翻车的点Embedding模型负责把文本转成向量。向量检索的本意是语义相近的文本向量在高维空间里距离也近。但这句话有一个大前提——入库用哪个Embedding模型查询就必须用同一个。我曾经在一个项目里看到过这样的问题第一天用A模型的Embedding接口把三千篇文档都向量化了第二天因为额度问题换了B模型但没有重建索引结果检索回来的结果和用户的问题驴唇不对马嘴。原因很简单两个模型的向量空间根本不一致余弦相似度再高也没有意义。为了从机制上避免这个问题我的做法是在VectorStore里引入索引版本号和模型标识。每次向量化写入时记录当时的embeddingModelName和模型版本。查询的时候如果检测到当前模型与索引里的模型不一致直接拒绝检索并提示需要重建索引。虽然这个检查不能帮你自动重建索引但它至少避免了“悄悄跑偏”的情况。入库流程的核心代码大致是这样的逻辑public void ingest(Document document) { ListChunk chunks textSplitter.split(document); Listfloat[] 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 ListRetrievalResult retrieve(String query, int topK) { ListRetrievalResult vectorHits vectorRetriever.retrieve(query, topK * 2); ListRetrievalResult keywordHits keywordRetriever.retrieve(query, topK * 2); MapString, 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(ListRetrievalResult 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生态里最方便的方式就是SSEServer-Sent Events配合WebFlux返回FluxString。Controller层可以这样写GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString 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可控制、模型可替换再慢慢去优化细节。等基础链路稳定了你自然会知道下一步该往哪里加东西。本文还有配套的精品资源点击获取
返回列表