ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于RAG的智能知识库问答系统:语义切分、混合检索与重排序实战

基于RAG的智能知识库问答系统:语义切分、混合检索与重排序实战 知识库问答系统这个方向我在过去两年里前后帮人看过不下十个毕业设计项目绝大多数都卡在同一个地方文档上传之后检索出来的内容驴唇不对马嘴模型答非所问。问题不在大模型本身而在于检索环节做得太粗糙——把整篇文档按固定字数切完就丢进向量库既不考虑语义边界也不做重排序更谈不上对检索结果的质量评估。这套基于RAG的智能知识库问答系统核心要解决的就是这个问题让检索真正准起来让回答真正有据可依。这篇文章面向正在做计算机毕业设计、或者想从零搭一套可用知识库问答系统的开发者。我会把整个系统的设计思路、技术选型理由、关键代码实现、以及我在实际调试中踩过的坑全部摊开讲。技术栈是SpringBoot Vue.js MySQL 向量检索前端用Element UI文档存储用MinIO大模型接入走本地Ollama或者云端API都行。读完你至少能拿到三样东西一套能跑通的完整架构方案、一份可复用的RAG核心链路代码、以及一份帮你避开常见陷阱的调试清单。1. 为什么你的RAG系统检索不准从切分策略说起很多人做RAG的第一步就错了。他们拿到一份PDF用LangChain的默认切分器按1000字符一切重叠200字符直接灌进向量库。跑起来发现检索结果总是差那么一点意思明明文档里有答案就是召不回来。这不是模型的问题是切分粒度把语义切碎了。1.1 固定长度切分的致命缺陷固定长度切分最大的问题是它完全无视文本的语义结构。一个完整的论述段落可能被从中间截断前半段进了块A后半段进了块B。用户提问时如果问题恰好匹配到块A检索回来的就是半截话模型拿到这种残缺上下文要么胡编要么说根据已知信息无法回答。我做过一个对比测试同一份技术文档分别用固定长度切分和语义切分在相同测试集上的召回率差了将近18个百分点。固定长度切分的Hit Rate只有62%而按标题层级段落语义切分能做到80%以上。这个差距在毕业设计的答辩场景里是致命的——评委随便问一个文档里的细节你的系统答不上来整个项目的可信度就崩了。正确的做法是按文档的天然结构来切。Markdown文档按标题层级切PDF先做版面分析再按段落切Word文档按样式切。切完之后每个块要保留它的上下文路径比如第三章 3.2节 数据库设计 索引优化这样检索时不仅匹配块内容还能匹配路径信息召回精度会明显提升。1.2 语义切分的具体实现方案语义切分的核心思路是先按段落粗切再计算相邻段落的语义相似度相似度低于阈值的地方就是切分点。这样能保证每个块内部语义连贯块与块之间主题分明。具体实现上我推荐用滑动窗口语义相似度判断的方式。先把文档按换行符拆成段落列表然后用嵌入模型计算每两个相邻段落的向量余弦相似度。如果相似度低于0.6就在这两个段落之间切一刀。阈值0.6是我在中文技术文档上反复调出来的经验值太高会导致块太碎太低又起不到切分效果。from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(BAAI/bge-small-zh-v1.5) def semantic_chunk(paragraphs, threshold0.6): if len(paragraphs) 1: return [.join(paragraphs)] embeddings model.encode(paragraphs) chunks [] current_chunk [paragraphs[0]] for i in range(1, len(paragraphs)): sim np.dot(embeddings[i-1], embeddings[i]) / ( np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i]) ) if sim threshold: chunks.append(.join(current_chunk)) current_chunk [paragraphs[i]] else: current_chunk.append(paragraphs[i]) chunks.append(.join(current_chunk)) return chunks这段代码可以直接用但要注意一个细节中文文档里段落长度差异很大有些段落只有一句话有些段落上千字。对于超长段落需要先做二次切分否则单个块太大嵌入向量会丢失细节信息。我的经验是单块控制在300到800字之间比较合适超过800字的段落再按句子边界切一次。1.3 元数据设计让每个块都带着身份证切分只是第一步真正让检索准的是元数据设计。每个文本块除了内容本身还必须携带以下信息来源文档ID、文档名称、章节路径、块在文档中的位置序号、创建时间。这些元数据在检索时可以参与过滤和排序。举个例子用户问第三章提到的索引优化策略是什么如果你的块元数据里有章节路径就可以在检索时加一个过滤条件章节路径包含第三章。这样即使其他章节有相似内容也不会被误召回。这个技巧在毕业设计答辩时特别加分因为评委能直观看到你的系统理解了问题的范围限定。元数据在MySQL里的存储结构我建议单独建一张表字段包括chunk_id、doc_id、content、embedding_vector如果向量库不支持独立存储、chapter_path、position、create_time。向量本身存在Milvus或者PgVector里MySQL只存元数据和原文这样查询和过滤分开处理性能更好。2. 向量检索的工程化落地从Embedding选型到混合检索检索环节是整个RAG系统的命脉。我见过太多项目在这里偷懒随便选个嵌入模型向量库用FAISS本地跑检索就做纯向量相似度。结果就是召回率上不去模型回答质量忽高忽低。这一章把检索链路的每个环节拆开讲。2.1 中文嵌入模型的选型对比嵌入模型决定了文本被映射到向量空间后的质量。中文场景下我实测过以下几个模型模型名称维度中文语义捕捉推理速度部署难度推荐场景BAAI/bge-small-zh-v1.5512良好快低毕业设计首选BAAI/bge-base-zh-v1.5768优秀中等低对精度要求高text-embedding-3-small1536优秀依赖API低有API预算m3e-base768良好中等低备选方案对于毕业设计我强烈推荐bge-small-zh-v1.5。原因有三第一512维向量在MySQLFAISS的方案里存储和检索都很快第二模型体积小本地部署对机器要求低8G内存的笔记本就能跑第三中文语义捕捉能力足够应付技术文档问答场景。如果你非要用OpenAI的嵌入模型注意一点text-embedding-3-small虽然维度高、效果好但每次调用都要走网络批量处理文档时延迟明显。毕业设计答辩现场网络不一定稳定本地模型更稳妥。2.2 向量库选型FAISS vs Milvus vs PgVector向量库的选择直接决定了系统的检索性能和部署复杂度。我把三个主流方案的实际体验列出来FAISS适合数据量在十万级以下的场景。它是Facebook开源的库不是独立服务直接嵌入到Python进程里用。优点是零部署成本检索速度极快缺点是不支持分布式数据量大了内存扛不住而且没有原生的元数据过滤功能需要自己在外层做过滤。Milvus是专业的向量数据库支持分布式、支持标量字段过滤、支持多种索引类型。缺点是部署重单机跑起来至少需要8G内存而且Docker Compose配置对新手不太友好。如果你的毕业设计数据量在百万级以上或者需要展示工程化能力选Milvus。PgVector是PostgreSQL的向量扩展最大的优势是向量和业务数据在同一个数据库里不用维护两套存储。对于毕业设计这种数据量不大、又不想折腾部署的场景PgVector是最省心的选择。MySQL虽然没有官方向量扩展但可以用MySQL 8.0的JSON字段存向量在应用层做相似度计算——数据量小的时候完全够用。我的建议是如果指导老师不强制要求用特定向量库直接用MySQL存向量应用层计算余弦相似度。数据量在1万条以下时检索延迟可以控制在200ms以内完全满足答辩演示需求。等数据量上来了再迁移到FAISS或Milvus迁移成本也不高。2.3 混合检索向量关键词的双路召回纯向量检索有个天然缺陷它对精确匹配不敏感。用户问SpringBoot的Transactional注解怎么用向量检索可能召回一堆讲事务管理的段落但就是漏掉那个明确提到Transactional的块。这时候就需要关键词检索来补位。混合检索的做法是一路走向量检索一路走BM25或全文索引两路各取TopK结果然后用RRFReciprocal Rank Fusion算法融合排序。RRF的公式很简单对每个文档将其在各路结果中的排名取倒数后求和得分高的排前面。// RRF融合排序的Java实现 public ListSearchResult rrfFusion(ListListSearchResult resultLists, int k) { MapString, Double scoreMap new HashMap(); MapString, SearchResult docMap new HashMap(); for (ListSearchResult results : resultLists) { for (int rank 0; rank results.size(); rank) { SearchResult result results.get(rank); String docId result.getChunkId(); double score 1.0 / (k rank 1); scoreMap.merge(docId, score, Double::sum); docMap.putIfAbsent(docId, result); } } return scoreMap.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .map(e - docMap.get(e.getKey())) .collect(Collectors.toList()); }k值一般取60这是原论文里的推荐值。实测下来混合检索比纯向量检索的Hit Rate能提升10到15个百分点尤其是在技术术语密集的文档上效果更明显。2.4 重排序让最相关的块排到最前面检索回来TopK个块之后不要直接丢给大模型。先过一个重排序模型把真正相关的块排到前面。重排序模型和嵌入模型不同它是对问题-文档对做交叉编码精度更高但速度慢所以只用在检索后的精排阶段。我常用的是BAAI/bge-reranker-base部署方式和嵌入模型一样简单。实测下来加一层重排序之后Top3块的准确率能从70%提升到85%以上。对于毕业设计来说这个提升在答辩演示时非常直观——同样的问题加了重排序的系统给出的答案明显更精准。重排序的代码实现from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) def rerank(query, chunks, top_k3): pairs [[query, chunk] for chunk in chunks] scores reranker.compute_score(pairs) ranked sorted(zip(chunks, scores), keylambda x: x[1], reverseTrue) return [chunk for chunk, score in ranked[:top_k]]注意一点重排序模型会显著增加检索延迟。如果答辩现场机器性能一般可以把重排序做成可开关的选项演示时根据机器状态决定是否开启。3. SpringBoot后端的RAG链路实现后端是整个系统的骨架负责文档管理、检索调度、对话生成三大模块。我用SpringBoot 3.x MyBatis-Plus MySQL 8.0的组合这套技术栈在毕业设计里足够成熟资料也多遇到问题好查。3.1 文档上传与异步处理流水线文档上传看起来简单实际上是最容易出问题的环节。用户上传一个50页的PDF如果同步做解析、切分、嵌入、入库请求会超时。必须做成异步流水线。我的做法是上传接口只负责把文件存到MinIO然后在MySQL里插入一条文档记录状态设为待处理同时往消息队列发一条消息。后台消费者拿到消息后依次执行文件下载 → 文本提取 → 语义切分 → 向量化 → 入库 → 更新状态为已完成。Service public class DocumentProcessService { Async(docProcessExecutor) public void processDocument(Long docId) { Document doc documentMapper.selectById(docId); try { // 1. 从MinIO下载文件 byte[] fileBytes minioClient.getObject(doc.getBucket(), doc.getObjectName()); // 2. 文本提取根据文件类型分发 String text textExtractor.extract(fileBytes, doc.getFileType()); // 3. 语义切分 ListChunk chunks semanticChunker.chunk(text, doc.getDocName()); // 4. 向量化并入库 for (Chunk chunk : chunks) { float[] embedding embeddingService.embed(chunk.getContent()); chunk.setEmbedding(embedding); chunkMapper.insert(chunk); } // 5. 更新文档状态 doc.setStatus(COMPLETED); doc.setChunkCount(chunks.size()); documentMapper.updateById(doc); } catch (Exception e) { doc.setStatus(FAILED); doc.setErrorMsg(e.getMessage()); documentMapper.updateById(doc); } } }这里有个坑要注意Async注解要生效必须在启动类上加EnableAsync而且异步方法不能和调用方在同一个类里否则代理不生效。我见过好几个项目在这里卡住文档状态永远停在待处理。3.2 检索接口的完整调用链检索接口是前端和RAG链路的交汇点。一个完整的检索请求要经过查询改写 → 向量化 → 多路召回 → 融合排序 → 重排序 → 上下文组装 → 大模型生成。查询改写这一步很多人会忽略但它对检索效果影响很大。用户的问题往往口语化、有指代比如它怎么配置直接拿这句话去检索向量模型根本不知道它指什么。查询改写的做法是用大模型把用户问题改写成独立、完整的检索语句。比如它怎么配置结合对话历史改写成SpringBoot中MinIO客户端的配置方法。public ChatResponse chat(ChatRequest request) { // 1. 查询改写 String rewrittenQuery queryRewriter.rewrite( request.getQuestion(), request.getHistory() ); // 2. 向量化 float[] queryVector embeddingService.embed(rewrittenQuery); // 3. 多路召回 ListSearchResult vectorResults vectorSearch(queryVector, 10); ListSearchResult keywordResults keywordSearch(rewrittenQuery, 10); // 4. RRF融合 ListSearchResult fused rrfFusion( Arrays.asList(vectorResults, keywordResults), 60 ); // 5. 重排序 ListSearchResult reranked rerankService.rerank( rewrittenQuery, fused.subList(0, Math.min(5, fused.size())), 3 ); // 6. 组装上下文 String context reranked.stream() .map(SearchResult::getContent) .collect(Collectors.joining(\n\n---\n\n)); // 7. 大模型生成 String answer llmService.generate(rewrittenQuery, context); return new ChatResponse(answer, reranked); }这个链路里每一步都有优化空间但对于毕业设计来说做到这个程度已经能明显区别于那些上传文档→向量检索→直接生成的简单实现了。3.3 大模型接入Ollama本地部署与API调用的取舍大模型接入有两种选择本地Ollama部署和云端API调用。毕业设计场景下我建议优先考虑Ollama本地部署原因很实际答辩现场网络不可控本地模型不依赖外网演示更稳。Ollama的接入很简单SpringBoot里用HTTP客户端调它的REST API就行Service public class OllamaService { private final RestTemplate restTemplate; private final String ollamaUrl http://localhost:11434/api/generate; public String generate(String prompt) { MapString, Object body new HashMap(); body.put(model, qwen2.5:7b); body.put(prompt, prompt); body.put(stream, false); ResponseEntityMap response restTemplate.postForEntity( ollamaUrl, body, Map.class ); return (String) response.getBody().get(response); } }模型选择上qwen2.5:7b在中文问答场景表现不错7B参数在16G内存的机器上能跑起来。如果机器配置更低可以用qwen2.5:3b效果会打折扣但能跑。如果机器有独立显卡可以上14B模型回答质量明显更好。云端API的话注意把API Key放在配置文件里不要硬编码在代码中。答辩演示时如果网络不稳定可以提前录好演示视频作为备份。4. Vue.js前端的交互设计与状态管理前端不只是能看就行好的交互设计能让答辩评委直观感受到系统的完成度。我用Vue 3 Element Plus Pinia的组合重点做好三件事文档管理界面、对话交互界面、检索结果可视化。4.1 文档管理上传进度与状态轮询文档上传后状态是异步变化的前端需要轮询后端接口获取最新状态。我的做法是用一个定时器每3秒查一次文档列表发现有状态变化的就更新UI。// Pinia store中的文档状态管理 export const useDocumentStore defineStore(document, { state: () ({ documents: [], pollingTimer: null }), actions: { async fetchDocuments() { const res await api.get(/documents) this.documents res.data }, startPolling() { this.pollingTimer setInterval(async () { const hasProcessing this.documents.some( d d.status PROCESSING || d.status PENDING ) if (hasProcessing) { await this.fetchDocuments() } else { this.stopPolling() } }, 3000) }, stopPolling() { if (this.pollingTimer) { clearInterval(this.pollingTimer) this.pollingTimer null } } } })轮询要注意在组件卸载时清除定时器否则会造成内存泄漏。另外轮询频率不要太高3秒一次比较合适太频繁会给后端造成不必要的压力。4.2 对话界面流式输出与引用溯源对话界面是用户感知最直接的部分。两个关键体验要做好流式输出和引用溯源。流式输出让回答像打字一样逐字出现而不是等全部生成完再一次性显示。实现上用SSEServer-Sent Events或者WebSocket都行。SSE更简单SpringBoot端用SseEmitter前端用EventSource接收。引用溯源是指回答下方显示引用了哪些文档片段点击可以展开查看原文。这个功能在答辩时特别加分因为它直观展示了RAG的可解释性——模型不是瞎编的是有据可查的。template div classchat-container div v-formsg in messages :keymsg.id classmessage div classcontent{{ msg.content }}/div div v-ifmsg.references classreferences div v-for(ref, idx) in msg.references :keyidx classreference-item clickshowReference(ref) span classdoc-name{{ ref.docName }}/span span classchapter{{ ref.chapterPath }}/span /div /div /div /div /template引用溯源的数据来自后端检索结果每个块都带着文档名和章节路径前端直接渲染就行。4.3 检索结果可视化让评委看到检索到了什么很多毕业设计的问答系统只展示最终回答评委看不到检索过程。我建议加一个检索详情面板展示每次提问召回了哪些块、相似度得分是多少、重排序前后的排名变化。这个面板不需要多好看但能让评委清楚地看到你的系统在检索环节做了哪些工作。实现上后端在返回回答的同时把检索结果列表一起返回。前端用一个可折叠面板展示每个块显示内容摘要、相似度得分、来源文档。如果做了重排序还可以显示重排序前后的排名对比。5. 部署与调试那些文档里不会写的坑这一章是我踩坑最多的地方也是最有价值的部分。很多问题在官方文档里找不到答案只能靠实际调试积累经验。5.1 MySQL向量存储的性能陷阱如果用MySQL存向量有一个坑必须注意不要把向量存成JSON字符串然后每次查询时解析。JSON解析的开销很大数据量上千条之后检索延迟会飙升。正确的做法是用MySQL 8.0的BLOB类型存向量的二进制表示查询时在应用层做反序列化和相似度计算。或者更彻底一点用MySQL的全文索引做关键词召回向量检索交给专门的向量库。还有一个坑是连接池配置。RAG系统的检索请求并发量可能不高但单个请求的数据库操作次数多查文档、查块、查元数据连接池太小会导致请求排队。我一般把HikariCP的maximumPoolSize设为20minimumIdle设为5基本够用。5.2 嵌入模型的显存与内存管理本地跑嵌入模型时最常见的问题是内存溢出。bge-small-zh-v1.5模型本身不大但批量处理文档时如果一次性把所有块加载到内存做嵌入内存会爆。解决办法是分批处理每批处理32个块处理完一批释放一批。另外模型加载后不要反复创建和销毁用单例模式保持一个全局实例。Component public class EmbeddingService { private static SentenceTransformer model; PostConstruct public void init() { // 全局只加载一次 model new SentenceTransformer(BAAI/bge-small-zh-v1.5); } public float[] embed(String text) { return model.encode(text); } public Listfloat[] embedBatch(ListString texts, int batchSize) { Listfloat[] results new ArrayList(); for (int i 0; i texts.size(); i batchSize) { int end Math.min(i batchSize, texts.size()); ListString batch texts.subList(i, end); results.addAll(model.encode(batch)); } return results; } }如果Java直接调Python模型不方便可以用ONNX Runtime把模型转成ONNX格式在Java里直接推理。这样部署更简单不依赖Python环境。转换工具用optimum或者torch.onnx.export都行。5.3 大模型幻觉的抑制策略RAG的核心价值之一就是抑制幻觉但如果检索质量不行模型照样会胡编。我总结了几个实用的抑制策略第一在Prompt里明确要求模型只根据提供的上下文回答如果上下文没有相关信息直接说不知道。这句话看起来简单但能显著降低胡编的概率。第二给检索结果设一个相似度阈值。如果所有召回块的相似度都低于阈值比如0.5说明知识库里没有相关内容直接返回未找到相关信息不要硬让模型回答。第三在回答里标注引用来源。要求模型在每句话后面标注引用了哪个块这样即使有幻觉也能通过引用溯源发现。第四对关键问题做二次验证。让模型自己检查一遍回答是否与上下文一致不一致就重新生成。这个策略会增加延迟但能提升回答可靠性。5.4 答辩演示的稳定性保障答辩现场最怕系统崩了。我建议做以下准备提前把演示用的文档处理好确保状态都是已完成不要现场上传大文件。准备3到5个典型问题覆盖不同难度简单的事实查询、需要跨段落综合的问题、知识库里没有答案的问题。每个问题提前跑一遍确认回答质量。如果用的是云端API提前测试网络稳定性准备好本地Ollama作为备用。如果本地模型跑得慢把max_tokens调小一点保证回答能在10秒内出来。最后准备一个降级方案如果检索链路出问题可以切换到纯关键词检索模式虽然效果差一些但至少能跑通流程。6. 从毕业设计到可扩展的RAG架构毕业设计做完不是终点。这套架构稍加改造就能支撑更复杂的场景我分享几个扩展方向供有余力的同学参考。6.1 多路召回的策略扩展目前做了向量关键词两路召回还可以加入更多召回路径。比如基于知识图谱的召回把文档里的实体和关系抽出来建图检索时先定位实体再沿图遍历相关节点。这就是GraphRAG的思路对需要多跳推理的问题效果很好。还有基于父文档的召回检索时用小块匹配但返回时返回小块所在的父文档段落。这样既保证了匹配精度又给模型提供了更完整的上下文。LangChain里的ParentDocumentRetriever就是这个思路。6.2 对话记忆与多轮问答目前的实现是无状态的每次提问独立检索。实际使用中用户会追问比如那它的配置参数有哪些这时候需要结合对话历史来理解它指什么。做法是在检索前做查询改写把当前问题和历史对话一起送给大模型让它生成一个独立的检索语句。同时在生成回答时也把历史对话作为上下文传进去让模型知道之前聊了什么。6.3 检索质量评估体系的搭建这是区分能跑和跑得好的关键。搭建一个评估集包含问题和对应的标准答案定期跑评估看Hit Rate、MRR、答案准确率等指标的变化。每次调整切分策略、换嵌入模型、改检索参数都跑一遍评估用数据驱动优化。评估集的构建可以人工标注也可以用大模型辅助生成。我一般准备50到100个问题覆盖文档的主要知识点每个问题标注对应的正确块ID。评估时看检索结果里是否包含正确块以及正确块的排名。这套评估体系在毕业设计里是加分项因为它体现了工程思维——不是凭感觉调参而是用数据说话。最后分享一个我在实际项目中总结的经验RAG系统的效果上限取决于检索质量而不是大模型的能力。很多人花大量时间调Prompt、换模型却忽略了检索环节的优化。把切分策略、嵌入模型、混合检索、重排序这四件事做好即使用一个7B的小模型回答质量也能超过那些用GPT-4但检索粗糙的系统。这个结论我在多个项目里验证过希望对正在做毕业设计的你有帮助。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进