简介:面向毕业设计与课程设计场景的智能问答系统源码,基于Spring AI Alibaba技术栈实现RAG(检索增强生成)流程,适合计算机、软件工程、电子信息等专业学生作为课设或毕设参考。压缩包共14个文件,包含5个Java源文件、2个properties配置、pom.xml与mvnw等构建支持文件,以及README.md说明文档,整体仅17KB,属于轻量级纯代码项目,便于快速阅读和运行调试。目前已有143人学习下载。项目展示了Spring AI Alibaba技术栈的工程实践,通过检索与生成模型协同完成知识库问答,代码中涉及数据存储、文档解析、向量检索及提示词工程等关键环节。学习者可借此掌握RAG系统从配置到接口调用的完整链路,理解模块拆分与参数设计,同时获得一套可演示、可扩展的毕业设计选题原型,对于后续从事AI应用开发也有实际帮助。
1. 毕设与课设里的 Spring AI Alibaba RAG 智能问答系统,到底难在哪
如果你正在为毕业设计或课程设计挑题目,大概率会被“基于Spring AI Alibaba的RAG智能问答系统”这个方向吸引。它听起来完整:有后端框架、有AI模型、有知识库、有前端展示,工作量好描述,也方便答辩时讲故事。但真正动手后你会发现,问答本身反而是最简单的一步,难点全在“怎么让系统真的答对”——也就是RAG里的检索和内容组织。这篇文章我会按自己实际搭过这套方案的顺序,帮你把Spring AI Alibaba里做RAG的最小可跑通路径拆开讲,并重点说清楚参数怎么调、坑在哪儿、拿到什么效果才算能交差。
这套方案适合两类人:一类是要在几周内拿出可演示成果的学生,另一类是想在公司内部快速验证私有知识问答可行性的后端开发者。你不需要先精通大模型原理,但对Java、Spring Boot和基本的SQL要有底子。
2. 为什么用 Spring AI Alibaba 来做 RAG:一套接口省掉一半集成活
2.1 先认清 Spring AI Alibaba 是什么:不是黑匣子,是对齐了“模型接入”的抽象层
Spring AI Alibaba 是 Spring AI 生态里针对阿里云通义系列模型做的适配实现。它的价值不在于内置了多么神奇的RAG算法,而在于帮你把“换模型”“换向量库”“换切分策略”这类脏活统一成了同一套接口。常见做法是,你在代码里面向 ChatClient、EmbeddingModel、VectorStore 这三个抽象编程,底层到底连的是通义千问还是别的模型,对业务代码基本透明。
这对毕设来说很有用。因为答辩时老师大概率会问“你用的是哪个模型、为什么选这个”,你可以理直气壮地回答:用的是 DashScope 上托管的 qwen 系列模型,通过 Spring AI Alibaba 接入,后续如果想换其他厂商模型,改动集中在配置层,而不是把业务代码重写一遍。这个回答既体现了你对抽象的理解,又不用真的去证明它。
2.2 RAG 骨架:四个环节在框架里分别落在哪个组件上
RAG 全称 Retrieval-Augmented Generation,核心思想是:不让大模型凭空回答,而是先从你自己的知识库里检索出相关内容,再把这些内容作为上下文拼进 Prompt,让模型基于给定材料作答。Spring AI Alibaba 里这套流程的落点如下表:
| RAG 环节 | 做什么 | Spring AI Alibaba 里对应组件 | 常用实现 |
|---|---|---|---|
| 文档加载 | 把 pdf/txt/md 读成文本 | DocumentReader | PagePdfDocumentReader、TextDocumentReader |
| 文本切分 | 把长文档切成适合检索的块 | TextSplitter | TokenTextSplitter |
| 向量化 | 把文本块转成向量 | EmbeddingModel | DashScope 的 text-embedding-v3 |
| 向量存储 | 存向量并支持相似度查询 | VectorStore | PGVectorStore、RedisVectorStore |
| 生成回答 | 把检索结果拼进 Prompt 让模型回答 | ChatClient | 通义千问 qwen-plus / qwen-turbo |
这里面最容易让新手翻车的点是:VectorStore 和 EmbeddingModel 的向量维度必须一致。比如 text-embedding-v3 输出的是 1024 维向量,那你在建 PGVector 表时如果指定了 1536 维,写入时就会报维度不匹配。
另外说一个常见的认知误区:很多同学一听知识库就想到知识图谱或 ontology,想着要不要先建实体关系图。这属于 RAG 知识库与结构知识库的选型问题。做毕设单文档问答,向量库就够了;只有当你要回答“多跳推理”类问题、且数据本身有强关系结构时,才值得引入 kg。那个工作量不是一两周能补完的,别给自己挖坑。
2.3 最小工程结构:先想清楚文件怎么摆,再动手写代码
我一般会建议按下面的目录结构来组织,源码包直接拿过来改也行,但建议你至少理解每个文件为什么存在:
spring-ai-rag/ ├── pom.xml ├── src/main/resources/ │ ├── application.yml │ └── prompt/ │ └── rag-system.st ├── src/main/java/com/example/rag/ │ ├── RagApplication.java │ ├── config/ │ │ └── VectorStoreConfig.java │ ├── controller/ │ │ └── ChatController.java │ ├── service/ │ │ ├── DocumentIngestionService.java │ │ └── RagChatService.java │ └── datasource/ │ └── DataInitializer.java最关键的只有三个类加一个配置文件。DocumentIngestionService 负责把文档灌进向量库,RagChatService 负责问答时检索加生成,VectorStoreConfig 负责声明向量库客户端。你先把这个骨架搭起来,再往里面填代码,比拿到一堆源码文件却不知道从哪看起要高效得多。
3. 把本地文档做成知识库:读取、切分、向量化的完整流程
3.1 加载文档:DocumentReader 读取 pdf 和 txt 的最小代码
这一步的目标只有一个:把磁盘上的文件变成框架里的 Document 对象列表。Document 在 Spring AI 里就是“一段文本 + 元数据”,它本身不关心文件格式。下面给一个典型的读取 pdf 和 txt 的方法。
@Service public class DocumentIngestionService { private final VectorStore vectorStore; public DocumentIngestionService(VectorStore vectorStore) { this.vectorStore = vectorStore; } public void ingestPdf(String path) { // 常见做法是用 Spring AI 提供的 PdfDocumentReader,它会自动分页读取 PdfDocumentReader reader = new PdfDocumentReader(new FileSystemResource(path)); List<Document> docs = reader.get(); processDocuments(docs, "pdf"); } public void ingestText(String path) { // 普通文本不存在分页问题,整篇读进来,后面交给切分器 TextDocumentReader reader = new TextDocumentReader(new FileSystemResource(path)); List<Document> docs = reader.get(); processDocuments(docs, "txt"); } private void processDocuments(List<Document> docs, String sourceType) { // 随后统一做切分、向量化、写入向量库 List<Document> splitDocs = new TokenTextSplitter().apply(docs); // 给文档打上来源标签,后期排查检索结果时能知道命中来自哪个文件 splitDocs.forEach(doc -> { doc.getMetadata().put("sourceType", sourceType); doc.getMetadata().put("filePath", (String) doc.getMetadata().get("file_name")); }); vectorStore.add(splitDocs); } }这个代码里有几个点要说明。FileSystemResource 的路径在 Windows 和 mac 上写法不一样,Windows 用C:/data/xx.pdf,mac/Linux 用/Users/xx/data/xx.pdf,用相对路径最省事。PdfDocumentReader 在读取扫描版 PDF 时是拿不到文字的,它只抽文本层,扫描件需要先过 OCR,这个我在避坑章节再细说。最后 vectorStore.add 是一次性批量写入,数据量大时可以分批。
3.2 切分文档:TokenTextSplitter 是 RAG 效果的第一道分水岭
很多线上 rag 问答效果差的根源不是模型不行,而是切分得稀碎。TokenTextSplitter 的典型配置如下:
@Bean public TextSplitter textSplitter() { return new TokenTextSplitter( 800, // 每个文本块的目标 token 数 200, // 块与块之间重叠的 token 数 5, // 最小块长度,低于这个值会被丢弃 5000, // 单次调用模型的最大 token 数,防止超限 true // 是否保留与来源文档的关联信息 ); }参数怎么理解?第一个值 chunkSize 决定检索的“颗粒度”。太大,比如 2000 token,切出来的块包含太多无关信息,相似度检索时噪音多;太小,比如 200 token,又容易把一句话的上下文切断,模型拿到的是残缺逻辑。我一般把默认值设在 600 到 1000 之间,然后看具体文档类型再调。第二个值 overlap 是重叠量,它存在的意义是防止“上一块的结尾”恰好是“下一块的开头句话”被切断。实践里 overlap 设为 chunkSize 的 15% 到 25% 比较常见。
切分粒度直接决定后面的召回质量,这块值得多花点时间拿真实文档做实验,而不是照抄默认参数。如果你想在 mac 上搭建 rag 知识库做实验,用小一点的 pdf 先跑通,再上大文档。
3.3 写入向量库:PGVector 配置和维度对齐
向量库选择上,毕设场景最稳的是 PGVector,因为你有现成的 PostgreSQL,不用额外引入一套专用向量数据库。下面是一个在 Spring AI Alibaba 中配置 PGVectorStore 的常见做法:
@Configuration public class VectorStoreConfig { @Bean public VectorStore vectorStore(DataSource dataSource, EmbeddingModel embeddingModel) { // 建表语句会在首次初始化时自动执行,前提是数据库里创建了对应 schema return new PgVectorStore( dataSource, embeddingModel, PgVectorStore.PgVectorIndexConfig.builder() .withIndexType(PgVectorStore.PgVectorIndexType.HNSW) .withDimensions(1024) .build() ); } }这段配置里,withDimensions(1024) 必须与你用的 EmbeddingModel 输出维度严格一致。DashScope 的 text-embedding-v3 默认是 1024 维,如果你的 EmbeddingModel 用的是 v2 版本,那是 1536 维,直接写 1024 会在首次写入时报错。HNSW 索引适合数据量在几十万条以内的场景,查询快但构建时耗内存;数据量很小也可以用 IVFFlat,实验对比下来差异不大。
对应的 application.yml 里,数据源配置和平常的 Spring Boot 项目一样,关键是别忘把向量库插件打开。PGVector 在 PostgreSQL 里是一个扩展,首次使用前需要执行建扩展的语句,常见做法是在 DataInitializer 里加一行。
@Component public class DataInitializer implements ApplicationRunner { private final JdbcTemplate jdbcTemplate; @Override public void run(ApplicationArguments args) { // 仅对 PostgreSQL 生效,MySQL 不支持这个语法 jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector"); } }这一步很多人会漏,导致启动时报 “column embedding does not exist” 之类的错,其实不是代码问题,是扩展没装。
4. 把检索和回答串起来:ChatClient 是问答主流程的入口
4.1 先检索,再拼 Prompt:RAG 的核心调用顺序
问答服务是 RAG 真正面向用户的部分。流程一定是“先向量检索,后模型生成”,顺序不能反。下面是最常见的实现方式,我加上了检索结果的打印,方便你调试时看到底命中了什么。
@Service public class RagChatService { private final VectorStore vectorStore; private final ChatClient chatClient; public RagChatService(VectorStore vectorStore, ChatClient chatClient) { this.vectorStore = vectorStore; this.chatClient = chatClient; } public String answer(String question) { // 第一步:向量检索,效果不好先看这里 List<Document> hits = vectorStore.similaritySearch( SearchRequest.builder() .query(question) .topK(5) .similarityThreshold(0.5) .build() ); // 调试时把命中片段打出来:看检索到了什么,是调优的第一步 hits.forEach(doc -> { System.out.println("命中片段: " + doc.getContent()); System.out.println("来源文件: " + doc.getMetadata().get("filePath")); }); // 第二步:把命中结果拼成上下文 String context = hits.stream() .map(Document::getText) .collect(Collectors.joining("\n\n---\n\n")); // 第三步:让模型基于上下文回答 return chatClient.prompt() .system(spec -> spec.text(""" 你是一个知识库问答助手。请只根据下面提供的资料回答问题。 如果资料中没有答案,请直接回答“资料中未找到相关信息”,不要编造。 资料: {context} 问题:{question} """) .param("context", context) .param("question", question)) .call() .content(); } }这个代码里有三个参数需要认真理解。
topK 是召回条数,它决定有多少文本块被塞给模型回答。topK 太大会让 Prompt 很长,既费 token 又容易让模型抓不住重点;太小则可能漏掉答案。我从 5 开始调,如果发现模型老是答“未找到”,先加大 topK 到 8 试试;如果答得东拉西扯,再降回来。
similarityThreshold 是相似度阈值,低于这个分数的文本块会被过滤掉。它的作用是防止“明明没相关内容,硬要拿一堆低质量片段凑数”。这个值非常敏感,设 0.7 可能把有效答案过滤掉,设 0.3 又会放进一堆垃圾。我在真实场景下一般先不设阈值,只看返回的相似度分数分布,再决定阈值。
另外一个值得注意的点:这个 Prompt 里明确写了“如果资料中没有答案就直说”。这是 RAG 系统防止幻觉的最重要一道闸门,千万别省。答辩时老师会专门问这个问题,你可以顺势讲出“模型只会基于提供的资料作答,不依赖内部记忆”这句话。
4.2 给问答加一个 HTTP 入口:Controller 怎么接
有了 Service 之后,Controller 非常简单,但这层往往是演示时最出效果的部分。代码如下:
@RestController @RequestMapping("/api/chat") public class ChatController { private final RagChatService ragChatService; public ChatController(RagChatService ragChatService) { this.ragChatService = ragChatService; } @PostMapping("/ask") public Map<String, Object> ask(@RequestBody Map<String, String> payload) { String question = payload.get("question"); String answer = ragChatService.answer(question); return Map.of( "question", question, "answer", answer, "timestamp", System.currentTimeMillis() ); } @GetMapping("/health") public String health() { return "ok"; } }建议你在演示时先用 Swagger 或直接 curl 调用这个接口,再打开前端页面。这样如果前端有问题,你不会误以为是后端挂了。我在本地验证时常用这条命令:
curl -X POST http://localhost:8080/api/chat/ask \ -H "Content-Type: application/json" \ -d '{"question": "这个项目的技术栈是什么?"}'返回的 JSON 里如果 answer 字段引用了你资料里的原话,说明整条链路通了。
4.3 一次完整的问答链路里,模型到底看到了什么
这个问题我建议每个做毕设的同学都要能在答辩时讲清楚。用户提问后,系统做三件事:把问题转成向量,在 PGVector 里搜索最接近的 5 个文本块,把这 5 块文本拼进 system prompt,再把用户的问题作为 user message 发给 qwen 模型。
以“这个项目的技术栈是什么”为例,模型实际看到的 Prompt 是这样的:
你是知识库问答助手。 资料: 【块1】项目采用 Spring AI Alibaba 作为后端框架... 【块2】前端使用 Vue3...,后端使用 Java 17... 问题:这个项目的技术栈是什么?模型不会直接访问你的数据库,它只是“读”了你给的资料然后作答。这也解释了为什么 RAG 系统的效果天花板取决于检索质量——模型再强,资料没检索到,它也答不出来。这就是所谓 rag 瓶颈,几乎每个真实系统都要面对。你把这个道理讲清楚,答辩老师就知道你不是只会调 API。
5. RAG 智能问答系统的避坑指南:现象、原因、解决
这一章是我最想写给后来者的部分。以下每一条都是实际操作中高概率遇到的问题,按“现象→原因→解决”的格式来写,方便你排查时对照。
5.1 api-key 配置了,但请求时报 InvalidApiKey
现象:项目能启动,但第一次调用问答接口时抛异常,日志里出现类似 “InvalidApiKey” 或 “InvalidParameter” 的字样。
原因:绝大多数情况是 application.yml 里的 key 写错了位置,或者 key 前后有空格。Spring AI Alibaba 的配置前缀通常是spring.ai.alibaba.tongyi.api-key,但如果你同时引入了 Spring AI 的 OpenAI 适配器,两个配置前缀可能冲突。
解决:检查配置文件,确认没有多余空格,并确认你用的 key 是 DashScope 的 API Key 而不是阿里云账号的 AccessKey。这两个东西长得像但完全不一样。如果确认无误,重启服务试试,因为有些版本的配置读取是启动时加载一次的。
5.2 向量写入时报维度不匹配
现象:执行 vectorStore.add 时,控制台报 “expected 1024 dimensions, but got 1536” 或相反的错误。
原因:EmbeddingModel 输出的向量维度和你建表时指定的维度不一致。这通常是因为你换了 embedding 模型,但 PGVector 表是用旧维度创建的。
解决:确认当前使用的 embedding 模型维度,删除旧的向量表重建。常见做法是在 DataInitializer 里加一行删除旧表的逻辑,或者直接到数据库里执行DROP TABLE IF EXISTS vector_store,然后重启服务让它重新建表。千万别在生产环境这么干,但毕设阶段无所谓。
5.3 文档加载成功但检索结果永远答非所问
现象:问“系统支持哪些用户角色”,模型回答一段完全不相关内容,或者答“资料中未找到相关信息”,但你确定文档里有这段描述。
原因:这是最常见的 rag 瓶颈。问题出在切分粒度或检索阈值上,而不是模型。比如 chunkSize 设得太大,答案藏在某个大块的中间,相似度计算时被其他文字稀释了;或者 similarityThreshold 设得过高,真正的答案块被过滤掉了。
解决:先把 similarityThreshold 去掉或设成 0,把 topK 调到 10,打印所有命中的片段和分数,人眼判断哪些是相关块。如果相关块分数很高但仍然答错,问题在 Prompt 拼接;如果相关块根本没被召回,问题在切分。
5.4 mac 上 PostgreSQL 启动失败,服务一直起不来
现象:在 mac 本地开发,启动 Spring Boot 时报连接数据库失败,检查发现 PostgreSQL 服务没起来,或者psql命令找不到。
原因:mac 上 PostgreSQL 需要单独安装,常见的 Homebrew 安装方式不会自动启动服务。另外 Spring Boot 默认的用户名密码是postgres/postgres,如果本地数据库没设置密码,连接会失败。
解决:依次执行brew install postgresql、brew services start postgresql,然后进入 psql 创建用户和数据库,把连接串里的密码改成实际的。另外注意 PGVector 扩展在 mac 上也需要安装对应依赖,否则CREATE EXTENSION会报找不到文件,此时需要brew install pgvector。
5.5 模型回答得很流畅,但内容完全不在点上
现象:系统能正常回复,语句通顺,但答案和你知识库里的内容毫无关系,像在自由发挥。
原因:大概率是 Prompt 模板里没有写“仅凭资料作答”的强约束,模型把知识库内容当成了参考,而不是唯一依据。另一种可能是你检索出来的片段本身就跑偏了,但模型润色得很好,掩盖了问题。
解决:把 system prompt 里的约束从“请参考资料回答”改成“请只根据资料回答,资料中未提及的内容一律不要输出”。同时回到第 4.1 节的调试方法,确认检索片段本身是正确的。这属于 RAG 里典型的“生成掩盖检索问题”,你不看中间结果永远发现不了。
6. 进阶:先测检索、再测生成的调试习惯,能让你的系统少走一半弯路
最后一个想分享的技巧,是我自己踩了不少坑换来的习惯:任何 RAG 系统,都要把“检索质量”和“生成质量”分开验证。很多同学在最终效果不好时,第一反应是换模型、调 Prompt,却忘了问题可能出在检索环节。你可以在项目里加一个专门的调试接口,或者直接写个 main 方法,先脱离 ChatClient 单独跑相似度检索,把命中的片段和分数打印出来看。
这里给一张我平时调参用的对照表,按场景选择起点再微调:
| 症状 | 先调哪个参数 | 调整方向 |
|---|---|---|
| 答非所问,命中片段相关性差 | topK | 加大到 8,若仍然不行检查切分 |
| 能找到答案但夹带大量无关内容 | similarityThreshold | 从 0 逐步上调到 0.5 附近 |
| 答案太碎,缺少上下文 | chunkSize 与 overlap | 增大 chunkSize 或 overlap |
| 回答“未找到”但文档里明明有 | similarityThreshold | 先调低到 0.2,若恢复再逐步回调 |
我自己现在的做法是:每次改完切分或向量库配置,先跑 5 个固定问题,人眼检查检索结果,再把同样的 5 个问题走一遍完整问答,对比两次输出差异。这个方法笨但极其可靠,因为检索结果是参数调优最直接的反馈信号。
最后说句实在话,这个方向做完并不难,但做好需要你对每一个环节都较真。我在最初调试时也走过不少弯路,最大的教训就是“不要急着跑去调模型参数,先把检索结果打印出来看一眼”。希望这些经验能帮你少踩一些坑,把毕业设计的时间花在刀刃上,而不是浪费在和数据库连接和维度报错死磕上。
本文还有配套的精品资源,点击获取