简介:这是一份供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 { List<RetrievalResult> retrieve(String query, int topK); }RetrievalResult里包含命中的Chunk、相似度得分、召回来源(是向量检索、关键词检索还是别的)。这个接口的意义在于:上层生成模块根本不关心结果是怎么来的,它只需要拿到一个按相关度排好序的列表。实际项目里VectorRetriever负责用Embedding做语义检索,KeywordRetriever负责用BM25这类算法做关键词匹配,再通过HybridRetriever把两者合并,最后交给Reranker精排。这条链路在后面的章节会展开讲。
generator模块反而是所有这些模块里最“薄”的一层,它只做三件事:把检索结果组装进Prompt、控制Token预算、调用大模型生成回答。很多项目把这个模块做得特别厚,什么业务逻辑都往里塞,这是不对的。生成模块应该保持无状态、可测试,给它一个query和一组RetrievalResult,它返回一段流式输出,这样就足够干净了。
包结构定好之后,我一般会让团队成员遵守一条规矩:core模块不允许依赖ingest和retriever,generator只依赖core和retriever的接口,不依赖具体实现。这样约束下来,后续任何人想加一种新的文档格式、换一个向量库、接一个新的大模型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可控制、模型可替换,再慢慢去优化细节。等基础链路稳定了,你自然会知道下一步该往哪里加东西。
本文还有配套的精品资源,点击获取