
1. 项目背景与整体设计思路1.1 这个系统到底解决什么问题先说说为什么会有这个选题。做过企业级项目的人都知道公司内部的知识散落在各个角落——Confluence 页面、Word 文档、PDF 手册、数据库里的 FAQ 表甚至某些老员工脑子里。新人想查一个“报销流程”或者“接口鉴权规则”往往要在五六个系统之间来回翻翻到了还不一定是当前有效版本。传统做法是搭一个全文检索但全文检索的本质是“关键词匹配”你搜“怎么申请年假”它可能给你返回十篇提到“年假”两个字的文档真正能回答你问题的段落却淹没在里面。RAGRetrieval-Augmented Generation检索增强生成这套范式的价值就在这儿它把“检索”和“生成”串起来。系统先从知识库里找出与问题最相关的若干文本片段再把这些片段作为上下文交给大模型让模型基于这些真实资料组织出自然语言答案。这样既避免了模型凭空编造幻觉又比纯检索多了“理解并归纳”的能力。这个毕业设计要做的就是把这套链路工程化落地成一个可运行、可演示、可扩展的 Web 系统。适合谁参考我认为有三类人一是正在做毕设、需要一套完整可跑通代码的计算机专业学生二是想在自己团队内部搭一个轻量知识问答工具的后端或全栈工程师三是刚接触 RAG、想找一个端到端项目把概念串起来的学习者。整套技术栈选的是 SpringBoot Vue.js MySQL属于国内高校和中小企业最主流的组合资料多、坑少、上手快。1.2 技术选型背后的取舍逻辑为什么是 SpringBoot 而不是 Python 的 FastAPI 或 Flask很多人第一反应是“RAG 生态不是 Python 更成熟吗”。这话没错LangChain、LlamaIndex 这些框架确实在 Python 侧更丰富。但毕设场景有个现实约束答辩老师、后续维护的学弟学妹大概率更熟悉 Java 那一套。而且 SpringBoot 在工程化方面——依赖注入、事务管理、统一异常处理、权限拦截——成熟度极高写出来的代码结构清晰适合作为“教学范本”。至于 RAG 的核心逻辑用 HTTP 调用大模型 API 或者对接本地推理服务即可语言并不是瓶颈。前端选 Vue.js 配 ElementUI理由很直接组件库齐全表格、上传、对话框、分页这些管理后台常用组件开箱即用省去大量造轮子的时间。MySQL 作为元数据库存用户、知识库、文档元信息、对话历史这些结构化数据稳定且部署简单。真正存放文档向量的是向量库这块我在下一节细说。这里要强调一个设计原则元数据与向量数据分离。MySQL 只负责“这个文档叫什么、谁传的、什么时候传的、属于哪个知识库”而文档切片后的向量和原文片段放在专门的向量存储里。这样做的好处是MySQL 的备份、迁移、查询逻辑不受向量数据膨胀的影响职责边界清晰。1.3 整体架构分层拆解系统从上到下可以分成四层我用文字描述一下数据流向方便你建立全局观。第一层是前端交互层Vue 单页应用包含登录注册、知识库管理、文档上传、问答对话、历史记录几个页面。用户提问后前端把问题发给后端后端返回答案和引用的原文片段前端把片段以卡片形式展示在答案下方增强可信度。第二层是应用服务层SpringBoot 主程序。它承担了请求鉴权、参数校验、业务编排的职责。核心服务类包括文档解析服务、文本切片服务、向量化服务、检索服务、对话服务。这一层是毕设代码的主体也是答辩时最容易被追问的地方。第三层是数据与模型层包含 MySQL、向量库、大模型服务三部分。MySQL 存结构化数据向量库存切片向量大模型服务负责生成答案。三者通过接口解耦方便替换。第四层是基础设施层包括文件存储可以用 MinIO 或本地磁盘、日志、配置中心。文件存储这块如果只是毕设演示本地磁盘完全够用但如果想做得规范一点MinIO 是个不错的选择它兼容 S3 协议SpringBoot 集成也简单。提示架构分层不是为了好看而是为了“可替换”。比如你后期想把大模型从在线 API 换成 Ollama 本地推理只需要改模型服务这一层的实现类上层业务代码几乎不动。这种可替换性在答辩时是加分项。2. 核心细节解析与实操要点2.1 文档解析别小看这一步文档解析是整条链路的第一环也是最容易被低估的一环。很多人以为“读个 PDF 还不简单”实际动手才发现扫描版 PDF 是图片直接读出来是乱码Word 里的表格读出来格式全丢Markdown 里的代码块被当成普通文本切碎。这些都会直接影响后续检索质量。我的处理策略是按文件类型分流。对于.txt和.md直接按字符流读取保留段落结构。对于.docx用 Apache POI 的 XWPF 组件读取重点处理段落和表格——表格内容我会转成“表头: 值”的键值对文本避免信息丢失。对于.pdf用 PDFBox 提取文本如果提取出来的字符数异常少比如一页不到 50 个字符基本可以判定是扫描件这时候要么提示用户上传可复制文本的版本要么接入 OCR 组件但 OCR 在毕设里属于加分项而非必选项量力而行。这里有个实操心得解析后的文本一定要做清洗。常见问题包括连续多个换行、页眉页脚重复出现、特殊符号乱码。我一般用正则把连续三个以上换行压成两个把形如“第 X 页 共 Y 页”的行删掉。清洗看似琐碎但能显著提升切片质量。2.2 文本切片粒度决定检索上限切片Chunking是 RAG 里最讲究经验的环节。切得太粗一个切片里混了好几个主题检索出来噪声大切得太细一个完整语义被拆散模型拿到手里拼不出完整答案。我采用的是递归字符切片 重叠窗口的方案。具体参数目标切片长度 500 个字符重叠 50 个字符。为什么是 500这是经过几轮实测后的折中值。中文里 500 字大约是一到两个自然段语义相对完整重叠 50 字是为了防止关键信息正好卡在切片边界上被切断。切片时优先按段落分隔符切段落太长再按句号、问号、分号切最后才按固定长度硬切。// 切片核心逻辑示意 public ListString splitText(String text, int chunkSize, int overlap) { ListString chunks new ArrayList(); int start 0; while (start text.length()) { int end Math.min(start chunkSize, text.length()); // 尝试在句末标点处回退保证语义完整 if (end text.length()) { int lastPunct findLastPunctuation(text, start, end); if (lastPunct start) { end lastPunct 1; } } chunks.add(text.substring(start, end)); start end - overlap; if (start 0) start 0; if (end text.length()) break; } return chunks; }每个切片除了文本本身还要带上元数据所属文档 ID、切片序号、原始位置。这些元数据在检索命中后用于溯源展示让用户知道答案是从哪篇文档的哪一段来的。2.3 向量化与向量库选型向量化就是把文本切片转成一串浮点数向量语义相近的文本在向量空间里距离更近。中文场景下我推荐用bge-small-zh或text-embedding系列的中文模型维度一般是 512 或 768。如果追求部署简单可以调用在线 embedding 接口如果想离线跑用 Ollama 拉一个 embedding 模型也很方便。向量库的选择上毕设场景我建议用Milvus 的单机版或者Chroma。Milvus 功能全、性能好但部署稍重Chroma 轻量Python 侧用得多Java 侧可以通过 HTTP 调用。如果不想引入额外组件甚至可以用 MySQL 存向量然后自己算余弦相似度——数据量小的时候几千条以内完全可行但数据量上万后性能会明显下降。我的建议是毕设演示用 Chroma 或 Milvus 单机版既显得专业又不至于把环境搞得太复杂。注意向量维度必须和 embedding 模型输出维度一致否则写入向量库时会直接报错。这个坑我见过不止一个同学踩换模型时忘了同步改配置。2.4 检索策略从“能搜到”到“搜得准”最基础的检索是向量相似度检索把用户问题也转成向量在向量库里找 Top-K 个最相似的切片。但纯向量检索有个短板它对“精确匹配”不敏感。比如用户问“错误码 E0434352 是什么意思”向量检索可能返回一堆语义相近但错误码不同的内容。所以我在实现里加了混合检索向量检索和关键词检索各取一批结果再用 RRFReciprocal Rank Fusion算法融合排序。关键词检索用 MySQL 全文索引或者 HanLP 分词后做倒排匹配。HanLP 在 SpringBoot 里集成很简单加依赖、配分词器即可。混合检索的命中率比纯向量检索有明显提升尤其是在包含专有名词、编号、缩写的场景下。检索出来后还有一步重排序Rerank可以用一个小的交叉编码模型对 Top-20 结果重新打分取前 5 个送给大模型。这一步在算力有限时可以省略但对答案质量影响不小。3. 实操过程与核心环节实现3.1 环境准备与项目骨架搭建先把地基打好。JDK 用 17SpringBoot 3.x 的最低要求Maven 用 3.8 以上MySQL 用 8.0Node.js 用 18。这里提醒一句SpringBoot 版本不要盲目追新。我见过有同学用了最新的 3.4.x结果某些依赖还没适配启动就报错。稳妥起见用 3.2.x 或 3.3.x 的稳定版。项目结构我习惯这样分knowledge-qa/ ├── qa-backend/ # SpringBoot 后端 │ ├── src/main/java/com/example/qa/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务层 │ │ ├── mapper/ # 数据访问层 │ │ ├── entity/ # 实体类 │ │ ├── config/ # 配置类 │ │ └── common/ # 通用工具、统一返回 │ └── src/main/resources/ │ ├── application.yml │ └── mapper/ # MyBatis XML └── qa-frontend/ # Vue 前端 ├── src/ │ ├── views/ │ ├── api/ │ └── router/ └── package.json数据库建表至少需要这几张user用户、knowledge_base知识库、document文档、chunk切片如果向量库不存原文的话、chat_session会话、chat_message消息。字段设计上document表要有status字段标记解析状态待解析、解析中、已完成、失败方便前端轮询展示进度。3.2 文档上传与异步解析链路文档上传不能同步处理因为解析一个大 PDF 可能要几十秒前端会超时。我的做法是上传接口只负责把文件存到 MinIO 或本地磁盘往document表插一条状态为“待解析”的记录然后立即返回。后台用一个Async注解的异步方法或者简单的线程池去执行解析、切片、向量化、入库这一整套流程每完成一步更新一次状态。Async(parseExecutor) public void parseAndIndex(Long documentId) { Document doc documentMapper.selectById(documentId); try { updateStatus(documentId, PARSING); String rawText fileParser.parse(doc.getFilePath(), doc.getFileType()); String cleaned textCleaner.clean(rawText); ListString chunks textSplitter.split(cleaned, 500, 50); updateStatus(documentId, INDEXING); for (int i 0; i chunks.size(); i) { float[] vector embeddingService.embed(chunks.get(i)); vectorStore.insert(doc.getKbId(), documentId, i, chunks.get(i), vector); } updateStatus(documentId, COMPLETED); } catch (Exception e) { log.error(解析失败, documentId{}, documentId, e); updateStatus(documentId, FAILED); } }线程池要配置合理的核心线程数和队列容量避免大量文档同时上传把内存打爆。我一般设核心 2、最大 4、队列 100对毕设场景足够。3.3 问答接口的完整实现问答接口是系统的门面。请求进来后流程是这样的先根据kbId确定检索范围把用户问题向量化执行混合检索拿到候选切片重排序后拼装 Prompt调用大模型最后把答案和引用片段一起返回。Prompt 的拼装很关键。我用的模板大致是你是一个知识库助手请严格根据以下参考资料回答用户问题。 如果参考资料中没有相关信息请明确告知“知识库中未找到相关内容”不要编造。 参考资料 {context} 用户问题{question}这里强调“严格根据参考资料”和“不要编造”是为了压制模型的自由发挥倾向。实测下来加了这两句约束后幻觉率明显下降。流式输出SSE是提升体验的关键。大模型生成答案需要几秒到十几秒如果等全部生成完再返回用户会以为系统卡死了。用 SpringBoot 的SseEmitter把模型返回的 token 逐个推给前端前端边收边渲染体验流畅很多。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String question, RequestParam Long kbId) { SseEmitter emitter new SseEmitter(120000L); executor.execute(() - { try { ListChunk contexts retrievalService.retrieve(question, kbId, 5); String prompt promptBuilder.build(contexts, question); llmService.streamGenerate(prompt, token - { try { emitter.send(SseEmitter.event().data(token)); } catch (IOException e) { emitter.completeWithError(e); } }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }3.4 前端关键页面与交互细节前端用 Vue3 ElementUI或 Element Plus。核心页面有三个知识库列表页、文档管理页、问答对话页。问答对话页的交互我打磨了几轮。用户输入问题后先显示一个“思考中”的加载态收到第一个 token 后切换成流式渲染。答案下方用折叠面板展示引用的原文片段每个片段标注来源文档名和相似度分数。这样用户既能快速看到答案又能在存疑时点开原文核对。文档管理页要处理上传进度和解析状态。上传用 ElementUI 的el-upload组件解析状态通过定时轮询后端接口刷新。状态用不同颜色的标签区分灰色待解析、蓝色解析中、绿色已完成、红色失败。失败的要提供“重新解析”按钮。实操心得前端调用流式接口时不要用 axios因为 axios 对 SSE 的支持不友好。直接用浏览器原生的EventSource或者用fetch配合ReadableStream手动解析。我一开始用 axios 踩了坑数据一直收不全换成 EventSource 后一次通过。4. 常见问题与排查技巧实录4.1 检索命中率低的排查思路这是被问得最多的问题“为什么我明明上传了文档问相关问题却答不出来”排查要按链路一步步来。先确认文档是否真的解析成功。去数据库看document表的status字段如果是 FAILED去看日志里的异常堆栈。常见原因是 PDF 是扫描件、文件编码不是 UTF-8、或者文件路径含中文导致读取失败。如果解析成功但检索不到检查切片是否写入了向量库。可以写一个测试接口直接按文档 ID 查向量库里的切片数量数量为 0 说明向量化环节断了。再检查 embedding 服务是否正常返回有时候在线接口限流或超时会导致向量全是零向量这种向量检索出来全是噪声。如果切片都在但检索结果不相关那就是检索策略的问题。先看 Top-K 设的是多少太小可能漏掉正确切片建议设 10 到 20 再重排。再看切片粒度如果切片太长比如 1000 字以上一个切片里主题混杂相似度会被稀释。这时候把切片长度调小到 300 到 500 试试。现象可能原因排查动作文档状态一直“解析中”异步线程池满或异常未捕获查线程池日志加异常兜底检索结果全是无关内容向量为零向量或维度不匹配检查 embedding 接口返回核对维度配置答案答非所问Prompt 约束不足或上下文噪声大加强 Prompt 约束启用重排序流式输出中断SSE 超时或前端解析错误调大超时时间改用 EventSource4.2 大模型调用超时与限流应对在线大模型接口普遍有超时和限流。超时方面把 HTTP 客户端的读超时设到 60 秒以上因为长答案生成确实需要时间。限流方面要做重试机制遇到 429 状态码时指数退避重试比如等 1 秒、2 秒、4 秒再试最多重试三次。如果预算有限或者想离线演示用 Ollama 在本地跑一个小参数模型比如 7B 级别是很好的选择。SpringBoot 通过 HTTP 调用 Ollama 的/api/generate接口即可和调用在线接口的代码结构几乎一样换个 base URL 和请求体格式就行。本地模型生成速度慢一些但胜在稳定、免费、不依赖网络。4.3 数据库与部署相关的坑MySQL 8.0 安装时字符集一定要选utf8mb4否则中文和特殊符号会乱码。连接串里加上useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai。如果遇到 SSL 连接错误在连接串末尾加useSSLfalse即可本地开发没必要开 SSL。SpringBoot 打包成 jar 后部署配置文件外置是个好习惯。把application.yml放在 jar 同级目录启动时用--spring.config.location指定这样改配置不用重新打包。前端打包用npm run build生成静态文件可以放到 Nginx 里也可以直接塞进 SpringBoot 的static目录一起打成 jar后者更适合毕设演示一个 jar 走天下。注意如果前端路由用了 history 模式直接塞进 SpringBoot 会导致刷新页面 404。解决办法是配一个转发控制器把所有非 API 路径转发到index.html。这个坑很经典答辩演示时刷新页面白屏就尴尬了。4.4 性能优化的几个实用手段向量检索是性能大头。如果切片数量在十万级以内Milvus 单机版完全扛得住。可以给向量库建 IVF 索引查询时只扫描部分聚类速度提升明显。MySQL 侧给document.kb_id、chat_message.session_id这些外键字段建索引避免全表扫描。缓存方面高频问题的答案可以缓存。用 SpringBoot 的Cacheable注解把“问题 知识库 ID”作为 key答案缓存十分钟。这样重复提问直接命中缓存既快又省模型调用费用。不过要注意知识库更新后要清缓存否则会返回过期答案。最后再分享一个小技巧给检索结果加相似度阈值。如果 Top-1 的相似度低于某个值比如 0.6说明知识库里大概率没有相关内容这时候直接返回“未找到相关内容”而不是硬塞给模型让它编。这个阈值需要根据你用的 embedding 模型实测调整不同模型的分数分布不一样。我试过 bge 系列0.6 是个比较稳妥的起点。