毕业设计知识库问答:为什么我选RAG而不是微调,附Spring Boot+Vue实现
发布时间:2026/10/1 5:39:11来源:尧图网络
1. 为什么我最终选了RAG而不是微调来做知识库问答毕业设计选题那会儿我在微调一个大模型和搭一套RAG系统之间纠结了差不多两周。导师给的方向是智能知识库问答听起来两个方案都能做但真正动手之后才发现这俩路子的成本、可控性和落地难度完全不在一个量级。先说结论对于绝大多数本科和硕士毕业设计场景RAG是性价比最高的选择。原因很直接——微调需要GPU资源、需要高质量标注数据、需要反复调参而且一旦知识更新就得重新训练RAG只需要把文档切好、向量化存起来知识库更新就是重新灌一次数据的事。我实验室的机器只有一张3060跑7B模型的LoRA微调勉强能跑但效果调不稳定最后果断转向RAG。RAG的全称是Retrieval-Augmented Generation检索增强生成。用大白话讲就是用户问一个问题系统先去知识库里翻书找到最相关的几段内容再把这些内容连同问题一起丢给大模型让大模型看着材料答题。这样做的好处是模型不用记住所有知识它只负责理解和组织语言事实性的东西交给检索来保证。这套系统我最终用Spring Boot Vue MySQL做主体框架向量检索部分用轻量级方案落地。选Spring Boot是因为生态成熟、资料多、答辩时老师也熟悉选Vue是因为前端上手快Element Plus组件能省掉大量样式工作MySQL存业务数据用户、知识库元信息、对话记录向量数据单独处理。下面我把整个设计和实现过程拆开讲包括我踩过的坑和最后怎么绕过去的。提示如果你的毕设时间只有两三个月不要一上来就想着上LangChain4j或者Agentic RAG那套复杂架构。先把文档上传→切分→向量化→检索→生成回答这条最小闭环跑通再考虑加花活。2. 系统整体架构与模块拆解2.1 四层架构的划分逻辑我把整个系统分成四层这个划分不是照搬教科书而是根据哪部分容易变、哪部分稳定来定的。接入层负责和用户打交道就是Vue写的前端页面包括登录注册、知识库管理、对话界面。这一层变化最频繁产品经理今天想加个历史记录侧边栏明天想改个气泡样式所以必须和后面解耦。应用层是Spring Boot的Controller和Service处理业务逻辑用户认证、知识库CRUD、对话会话管理、调用检索和生成。这一层是系统的大脑也是我花时间最多的地方。检索层是RAG的核心负责把用户问题转成向量、去向量库找相似内容、做重排序。这一层我单独抽出来因为它和业务逻辑耦合度低将来换向量库不影响上层。数据层分两块MySQL存结构化数据向量存储存embedding。分开存的原因是这两类数据的查询模式完全不同硬塞进一个库会很难受。层级核心技术主要职责变更频率接入层Vue3 Element Plus页面交互、状态管理高应用层Spring Boot Spring Security业务逻辑、权限、会话中检索层Embedding模型 向量检索语义检索、重排低数据层MySQL 向量存储持久化低2.2 数据库表设计的几个关键决策MySQL这边我设计了六张核心表这里重点说三个容易设计错的。知识库表knowledge_base除了基本的id、name、description我加了一个embedding_model字段记录这个库用的是哪个向量模型。为什么要加因为不同模型产出的向量维度不一样如果将来换模型老数据必须知道是用什么模型生成的否则检索时维度对不上直接报错。这个坑我在测试阶段踩过一次当时把384维的模型换成768维的整个库的向量全部作废只能重新灌数据。文档分块表document_chunk这是RAG的命脉。字段包括chunk_id、doc_id、content、chunk_index、token_count、vector_id。vector_id是向量库里的主键用来做关联。chunk_index记录这个块在原文中的顺序检索命中后可以往前找一块、往后找一块做上下文扩展。对话消息表chat_message存用户问题和系统回答另外加了一个source_chunks字段JSON类型记录这条回答引用了哪些文档块。这个字段在答辩时特别加分因为老师会问你怎么证明回答是有依据的你直接把引用来源展示出来就行。CREATE TABLE document_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id BIGINT NOT NULL, kb_id BIGINT NOT NULL, content TEXT NOT NULL, chunk_index INT NOT NULL, token_count INT DEFAULT 0, vector_id VARCHAR(64), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_kb (kb_id), INDEX idx_doc (doc_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意content字段一定要用utf8mb4不然遇到emoji或者特殊符号会截断。我有个同学的毕设就是因为用了utf8用户上传的文档里有特殊字符插入直接报错排查了半天。2.3 前后端交互的接口约定前后端分离最容易出问题的就是接口约定。我一开始没定好规范前端同学其实也是我自己写的时候字段名一会儿驼峰一会儿下划线调试起来很痛苦。后来统一成请求和响应都用JSON字段名统一小驼峰时间统一用时间戳。对话接口是核心设计成流式返回SSE。为什么不用普通HTTP一次性返回因为大模型生成一段话要好几秒用户盯着空白页面会以为卡死了。流式返回能让文字一个字一个字蹦出来体验好很多。Spring Boot这边用SseEmitter实现前端用EventSource接收。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String question, RequestParam Long kbId) { SseEmitter emitter new SseEmitter(180000L); chatService.asyncChat(question, kbId, emitter); return emitter; }超时时间我设了180秒因为有些复杂问题检索加生成确实要一两分钟。设太短会中途断开设太长又占资源180秒是我实测下来比较平衡的值。3. RAG检索链路里最容易被忽略的细节3.1 文档切分不是越碎越好刚开始我以为切分很简单按固定字数切就行了。结果检索效果很差经常答非所问。后来才明白切分的核心是保证语义完整性。我试过三种策略第一种是固定长度切分每500字一块重叠50字。简单粗暴但经常把一句话从中间切断比如公司的年假政策是在一块末尾每年10天在下一块开头检索到哪一块都不完整。第二种是按段落切分遇到换行就切。这个对格式规整的文档效果好但遇到那种一大段没有换行的PDF就歇菜了。第三种是我最终采用的递归字符切分优先按段落切段落太长就按句子切句子还长就按逗号切最后才按字数硬切。这样能最大程度保住语义单元。LangChain4j里有现成的DocumentSplitters.recursive()方法直接调就行。DocumentSplitter splitter DocumentSplitters.recursive( 500, // 每块最大字符数 50, // 块间重叠字符数 new OpenAiTokenizer() ); ListTextSegment segments splitter.split(document);重叠50字这个参数很关键。为什么要有重叠因为一个问题可能横跨两个块如果块之间没有重叠检索时可能两个块都只命中一半信息。重叠就是给检索留的安全边际。3.2 Embedding模型的选择与本地化Embedding模型负责把文本转成向量。我一开始想调在线API但考虑到毕设要能离线演示、答辩时网络不一定靠谱最后选了本地部署的方案。本地embedding模型我对比了几个模型维度中文效果资源占用我的评价text-embedding-ada-0021536好需联网效果好但要APIbge-small-zh512较好低轻量首选bge-base-zh768好中效果和资源平衡m3e-base768好中中文优化好最后我选了bge-base-zh768维在中文语义相似度任务上表现稳定用ONNX Runtime跑在CPU上单条文本编码大概50毫秒批量处理可以接受。如果你的机器内存小于8G建议用bge-small-zh512维速度快一倍。这里有个实操细节embedding模型和检索时的query必须用同一个模型。我见过有人建库用A模型查询用B模型结果检索出来的东西驴唇不对马嘴。因为不同模型的向量空间是不通的就像两个人说不同语言根本对不上。3.3 向量检索的相似度计算与Top-K向量存好之后检索就是算相似度。常用的有余弦相似度和欧氏距离。文本向量一般用余弦相似度因为它只看方向不看长度对文本长度不敏感。Top-K的K值怎么定我一开始设10发现召回的内容太多噪声大大模型容易被带偏。后来改成5效果好一些。但K5又有个问题如果知识库里相关内容本来就少可能漏掉。最后我用了动态K值先取Top 10然后用一个相似度阈值我设的0.6过滤低于阈值的丢掉剩下的作为上下文。这样既保证召回又控制噪声。ListEmbeddingMatchTextSegment matches embeddingStore.findRelevant( queryEmbedding, 10, // 最大返回数 0.6 // 最小相似度 );0.6这个阈值不是拍脑袋定的。我拿测试集跑了一遍统计正确命中的相似度分布发现大部分在0.65以上误召回的集中在0.5到0.6之间所以0.6是个比较合适的分界线。你的数据不同阈值要自己测。3.4 重排序让最相关的排到最前面向量检索有个天然缺陷它只看语义相似度不看这个内容是不是真的能回答问题。比如用户问年假怎么申请检索可能返回一段讲年假天数规定的内容语义上很相似但不是用户要的。解决办法是加一层重排序Rerank。用一个专门的交叉编码器模型把问题和每个候选块拼在一起打分分数高的排前面。这个模型比embedding模型慢但只对Top 10做重排开销可以接受。我用的是bge-reranker-base实测下来Top 3的准确率比不加重排提升了大概20个百分点。这个提升在答辩演示时非常明显老师随便问个问题都能答到点子上。提示重排序是RAG系统里投入产出比最高的优化点之一。如果你时间有限优先做这个比调切分参数见效快。4. Spring Boot后端的工程化落地4.1 项目分层与依赖注入的实践Spring Boot项目最怕写成一锅粥所有逻辑堆在Controller里。我按标准的三层架构来Controller管接收请求和返回响应Service管业务逻辑Mapper管数据库操作。但RAG相关的逻辑我单独抽了一个rag包里面放检索、切分、向量化的服务。依赖注入这块有个坑值得说。我一开始在Service里直接new了一个EmbeddingModel结果每次请求都重新加载模型内存直接爆了。正确做法是把模型作为单例Bean注入Configuration public class RagConfig { Bean public EmbeddingModel embeddingModel() { return new BgeEmbeddingModel(models/bge-base-zh); } Bean public EmbeddingStoreTextSegment embeddingStore() { return new InMemoryEmbeddingStore(); } }这样模型只加载一次所有Service共享。如果你用Bean默认是单例的不用额外加Scope。但要注意EmbeddingStore如果是有状态的比如内存向量库多线程并发写入要加锁否则会出问题。4.2 文档上传与异步处理用户上传文档后如果同步做切分和向量化一个几十页的PDF可能要处理半分钟前端一直转圈体验很差。我改成了异步处理上传接口只负责存文件、建记录返回一个taskId后台用线程池慢慢处理前端轮询任务状态。Async(ragTaskExecutor) public void processDocument(Long docId, String filePath) { try { Document doc loadDocument(filePath); ListTextSegment segments splitter.split(doc); for (TextSegment segment : segments) { Embedding embedding embeddingModel.embed(segment).content(); String vectorId embeddingStore.add(embedding, segment); saveChunk(docId, segment, vectorId); } updateDocStatus(docId, COMPLETED); } catch (Exception e) { updateDocStatus(docId, FAILED); log.error(文档处理失败, e); } }线程池我配的核心线程数4、最大8、队列容量100。为什么这么配因为向量化是CPU密集型线程太多反而互相抢资源。4核机器上4个线程基本能跑满CPU。4.3 会话上下文的管理多轮对话是知识库问答的刚需。用户问年假多少天系统答10天用户接着问那怎么申请这个那指代的就是年假。如果每轮都独立检索第二问就丢了上下文。我的做法是把最近3轮对话拼成一个query去检索。不是把历史对话直接丢给大模型而是先用来检索找到相关文档后再连同当前问题一起给模型。这样既保留了上下文又不会让历史对话干扰检索。String contextualQuery buildContextualQuery(history, currentQuestion); // 例如用户之前问年假多少天。现在问那怎么申请历史轮数不能太多3轮是个经验值。太多会把检索带偏而且token消耗大。如果对话很长可以考虑用大模型先做个问题改写把指代消解掉但那是进阶做法毕设阶段3轮拼接够用了。4.4 异常处理与降级策略RAG系统依赖好几个外部组件embedding模型、向量库、大模型。任何一个挂了整个问答就不可用。我做了几层降级第一层检索失败降级为纯生成。如果向量库连不上直接把问题丢给大模型虽然可能答不准但至少不报错。第二层大模型超时降级为返回检索结果。如果模型生成超过30秒还没返回就把检索到的原文片段直接展示给用户附一句以下是与您问题相关的内容。第三层全部失败返回友好提示。不要让用户看到堆栈信息返回系统繁忙请稍后重试。try { ListTextSegment contexts retrievalService.retrieve(question, kbId); String answer llmService.generate(question, contexts); return answer; } catch (RetrievalException e) { log.warn(检索失败降级为纯生成, e); return llmService.generate(question, Collections.emptyList()); } catch (LlmTimeoutException e) { log.warn(生成超时返回检索原文, e); return buildFallbackAnswer(contexts); }这套降级逻辑在答辩演示时救过我一次。当时演示环境网络抽风大模型API调不通但因为降级到了检索原文老师还是看到了系统在工作没当场翻车。5. Vue前端的交互设计与踩坑5.1 对话界面的流式渲染流式渲染是前端体验的关键。后端用SSE推前端用EventSource接。但EventSource有个限制它只支持GET请求不能带请求体。如果问题很长URL会超长。我的解决办法是把问题先POST存到后端返回一个临时token再用EventSource带着token去GET。// 第一步提交问题 const { data } await axios.post(/api/chat/prepare, { question, kbId }) // 第二步建立SSE连接 const eventSource new EventSource(/api/chat/stream?token${data.token}) eventSource.onmessage (e) { if (e.data [DONE]) { eventSource.close() return } answerText.value e.data }渲染时要注意大模型返回的是markdown格式得用markdown-it渲染。但流式过程中markdown可能不完整比如代码块只返回了一半直接渲染会乱。我的做法是流式过程中用纯文本显示等[DONE]之后再整体渲染成markdown。5.2 知识库管理的文件上传文件上传看着简单坑不少。第一个坑是大文件超时。Spring Boot默认上传限制是1MB超过就报错。要在配置文件里改spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB第二个坑是文件类型校验。不能只信前端传的扩展名后端要校验真实类型。我遇到过把.exe改成.pdf上传的解析直接崩。用Apache Tika检测真实MIME类型比较靠谱。第三个坑是中文文件名乱码。前端上传时文件名要encodeURIComponent编码后端再解码否则中文名会变成乱码。5.3 状态管理与路由守卫Vue这边我用Pinia管状态主要管三样用户登录态、当前选中的知识库、对话历史。路由守卫用来拦截未登录用户跳转到登录页。有个细节刷新页面后Pinia状态会丢失。因为Pinia存在内存里刷新就没了。解决办法是把关键状态同步到localStorage初始化时读回来。但token这种敏感信息不要放localStorage放httpOnly的cookie更安全。// 持久化插件 pinia.use(({ store }) { const saved localStorage.getItem(store.$id) if (saved) store.$patch(JSON.parse(saved)) store.$subscribe((_, state) { localStorage.setItem(store.$id, JSON.stringify(state)) }) })5.4 移动端适配的取舍毕设一般要求能在手机上演示。我没做原生App直接用响应式布局。Element Plus的组件大部分支持响应式但对话气泡在窄屏上要调整。我用媒体查询把侧边栏在小屏时隐藏改成抽屉式弹出。media (max-width: 768px) { .sidebar { position: fixed; transform: translateX(-100%); transition: transform 0.3s; } .sidebar.open { transform: translateX(0); } }说实话毕设阶段移动端适配做到能用就行不用追求完美。老师演示时大概率还是用电脑。6. 实测效果与几个真实的坑6.1 检索命中率的优化过程我拿50个测试问题跑了一轮评测记录Top 3里有没有正确答案。优化前后对比优化阶段Top 1命中率Top 3命中率主要改动初始版本42%58%固定切分无重排改递归切分54%70%语义完整性提升加重叠58%74%跨块问题改善加重排序72%86%相关性排序优化调阈值76%88%过滤噪声从42%到76%最大的功臣是重排序和切分策略。这两个改动加起来代码量不到100行但效果提升明显。所以如果你时间紧优先做这两块。6.2 大模型幻觉的抑制RAG虽然能减少幻觉但不能完全消除。我遇到过模型明明检索到了正确内容却自己编了一个答案的情况。后来在prompt里加了强约束请严格根据以下参考资料回答问题。 如果参考资料中没有相关信息请直接回答根据现有资料无法回答该问题不要编造。 回答时请标注引用的资料编号。 参考资料 [1] ... [2] ...加了这段之后编造情况少了很多。另外把temperature调到0.1让输出更确定。temperature是控制随机性的参数越高越有创意越低越保守。知识库问答要的是准确不是创意所以调低。6.3 性能瓶颈与优化系统跑起来后我发现最慢的环节是embedding计算。用户提问后要先把问题向量化这一步在CPU上要50到100毫秒。如果并发用户多就会排队。优化手段有两个一是缓存高频问题的向量用Caffeine做本地缓存相同问题直接命中二是批量处理如果有多个问题同时来攒一批一起算比一个个算快。Bean public CacheString, float[] queryCache() { return Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(1, TimeUnit.HOURS) .build(); }Caffeine是Spring Boot推荐的本地缓存库性能比Guava Cache好。1000条缓存大概占几MB内存很划算。6.4 答辩演示的注意事项最后说几个答辩相关的经验。第一准备离线环境。答辩教室的网络你控制不了大模型API可能调不通。我提前把模型部署到本地演示时拔网线也能跑。第二准备几个必答问题。比如年假政策是什么报销流程怎么走这些问题的答案你提前验证过演示时稳。第三准备好引用展示。老师问你怎么保证回答准确你点开引用来源展示原文片段比说一百句都管用。还有个小技巧演示时不要问太开放的问题比如公司怎么样这种问题检索不到具体内容模型容易瞎答。问具体的、知识库里明确有的问题效果最好。这套系统从零到能跑我大概花了六周时间其中两周在踩坑和调优。如果你也在做类似的方向我的建议是先把最小闭环跑通哪怕检索效果一般先让整个流程能走通然后再逐项优化。不要一开始就追求完美那样很容易卡在某个细节上出不来。
网站建设高端定制企业官网