1. 为什么我最终选了RAG而不是微调来做知识库问答
毕业设计选题那会儿,我在"微调一个大模型"和"用RAG搭一套知识库问答"之间纠结了差不多一周。导师一句话点醒我:你手头有多少标注数据?我算了算,零。实验室积累的文档倒是一大堆,PDF、Word、Markdown混在一起,但没人有精力去清洗成指令微调数据集。这就是我转向RAG的直接原因。
RAG全称Retrieval-Augmented Generation,检索增强生成。说人话就是:模型本身不记住你的私有知识,而是每次回答前先去你的知识库里"翻书",把翻到的相关段落塞进提示词,再让大模型基于这些段落组织答案。这个思路对毕业设计特别友好——你不需要训练,不需要GPU集群,一台普通笔记本就能跑起来,而且知识更新只需要往库里加文档,不用重新训练。
我这套系统的定位很明确:面向中小团队或个人的私有文档问答。典型场景是,你有一堆产品手册、内部规范、课程讲义,想让人用自然语言提问就能拿到答案,而不是靠Ctrl+F一个个翻。技术栈上我选了SpringBoot做后端、Vue.js做前端、MySQL存业务数据,向量检索这块用LangChain4j串起来。选SpringBoot是因为生态成熟、资料多,出问题好查;选Vue.js是因为Element UI那套组件拿来就能用,前端不用从零造轮子。
这篇文章我会把整个系统的设计思路、关键代码、踩过的坑全部摊开讲。不管你是正在做类似毕设,还是想给团队搭一个内部知识助手,都能直接抄作业。我会重点讲清楚三件事:文档怎么切、向量怎么存和检索、检索结果怎么喂给大模型才能答得准。这三件事决定了RAG系统到底是"能用"还是"智障"。
2. 整体架构:一条从文档到答案的完整链路
2.1 系统分层与模块划分
我把整个系统拆成了四层,这样职责清晰,调试的时候也容易定位问题出在哪一层。
第一层是文档接入层,负责把各种格式的原始文档读进来。支持PDF、Word、Markdown、纯文本这几种最常见格式。这一层的核心任务不是"读",而是"清洗"——把页眉页脚、乱码、多余空行去掉,因为脏数据会直接污染后面的检索质量。
第二层是向量化与存储层。文档清洗完要切成小块(chunk),每块通过Embedding模型转成一个高维向量,存进向量库。这里我用的是LangChain4j的Embedding接口,底层可以接不同的模型实现。向量库我选了内存版的EmbeddingStore做开发调试,生产环境可以换成Milvus或PgVector,接口基本不用改。
第三层是检索与生成层,也是RAG的核心。用户提问后,问题先被向量化,然后去向量库做相似度检索,召回Top-K个最相关的文档块,拼成上下文,连同问题一起发给大模型,拿到最终答案。
第四层是业务与展示层,SpringBoot提供REST接口,Vue.js负责聊天界面、知识库管理、文档上传这些交互。MySQL存的是用户、会话、文档元数据这些结构化信息,向量本身不进MySQL。
2.2 技术选型背后的取舍
很多人做毕设喜欢堆技术,什么新用什么,结果自己都调不通。我的原则是:每个组件都要能说清楚"为什么是它"。
| 组件 | 选型 | 理由 | 备选方案 |
|---|---|---|---|
| 后端框架 | SpringBoot 3.x | 生态成熟,自动配置省事,社区问题好搜 | 早期版本Gradle构建的项目配置更繁琐 |
| 前端 | Vue.js + Element UI | 组件丰富,表格/上传/对话框开箱即用 | React + Ant Design |
| 关系库 | MySQL 8 | 毕设标配,安装配置资料多 | PostgreSQL |
| 向量检索 | LangChain4j + 内存Store | 纯Java,和SpringBoot无缝集成 | Python的LangChain需跨语言调用 |
| 大模型 | 本地Ollama或云端API | 本地零成本,云端效果好 | 二选一按需切换 |
| 对象存储 | MinIO | 文档原文件存储,S3兼容 | 直接存本地磁盘 |
这里重点说下为什么用LangChain4j而不是Python的LangChain。我的后端是Java,如果检索逻辑用Python写,就得再起一个服务,跨语言调用增加复杂度和故障点。LangChain4j是Java原生的,Embedding、向量存储、对话链这些抽象都有,直接注入Spring容器就能用,调试也方便。代价是它的生态没有Python版丰富,但对毕设这种规模完全够用。
2.3 数据流转的完整过程
把链路串起来看是这样的:用户上传一份PDF,后端用解析库把文字抽出来,清洗后按规则切成若干chunk,每个chunk调Embedding模型拿到向量,向量连同chunk原文和元数据(来源文件、页码)一起存进向量库,原文件丢进MinIO,元数据写进MySQL。
用户提问时,问题向量化,去向量库检索Top-K,拿到chunk列表,按相似度分数排序,拼成上下文提示词,发给大模型,流式返回答案,同时把命中的来源标注出来。整个过程中MySQL只参与会话和文档管理,不参与向量计算,这样职责分离,性能也好控制。
3. 文档切分:RAG效果好坏的第一道分水岭
3.1 为什么切分策略比模型选择更关键
我一开始天真地以为,只要模型够强,检索差点也能答对。实测下来完全不是这么回事。有一次我拿一份技术规范提问,模型答得驴唇不对马嘴,我以为是模型不行,换了个更大的模型,还是错。最后发现问题出在切分上——我把整份文档按固定500字硬切,结果一个完整的操作步骤被从中间截断,前半段在chunk 3,后半段在chunk 4,检索只召回了chunk 3,模型看到的是残缺信息,自然答不对。
这件事让我明白:切分决定了检索的上限。切得不好,再强的模型也救不回来。切分的核心目标是让每个chunk语义完整、自包含,同时大小适中。太小则信息碎片化,太大则噪声多、检索精度下降。
3.2 我实际采用的递归切分方案
我最终用的是递归字符切分,配合中文标点做分隔符。逻辑是优先按段落切,段落太长再按句子切,句子还长再按逗号切,层层降级,尽量保证语义边界。
// 递归切分核心配置 DocumentSplitter splitter = DocumentSplitters.recursive( 500, // 每个chunk最大字符数 50, // chunk之间的重叠字符数 new ChineseParagraphSplitter() // 自定义中文分隔逻辑 );这里有两个参数要重点解释。chunk大小500不是拍脑袋定的。中文一个汉字大约对应1到2个token,500字大概700到1000 token,这个量级既能容纳一个完整知识点,又不会让上下文过长导致模型注意力分散。重叠50字是为了防止边界信息丢失——如果一句话正好被切在边界上,重叠部分能让相邻两个chunk都包含它,检索时至少有一个能命中。
分隔符的优先级我设成:段落换行 > 句号/问号/感叹号 > 分号 > 逗号 > 空格。中文标点和英文不一样,句号是"。"不是".",这个细节不注意,切分效果会差很多。
3.3 针对不同文档类型的差异化处理
一刀切的切分策略对付不了所有文档。我根据文档类型做了差异化处理。
对于结构化的技术手册,我优先按标题层级切。Markdown的##、###就是天然的语义边界,按标题切出来的chunk自带层级信息,检索时还能把父标题拼进去增强语义。对于问答对形式的FAQ文档,我直接按"问-答"配对切,一个问答就是一个chunk,这样检索命中的就是完整的一问一答,效果特别好。对于连续叙述的长文,才用上面说的递归切分。
还有一个容易被忽略的点:给每个chunk加上下文头。我在每个chunk前面拼上它所属的文档标题和章节路径,比如"《XX系统操作手册》第三章 数据备份 > 3.2 增量备份"。这样即使chunk本身没提到"备份"这个词,检索时也能靠上下文头匹配上。实测这一招对提升召回率帮助很大。
提示:切分参数没有万能值。500字是我在技术文档上试出来的经验值,你做法律文书或小说可能得调。建议准备20到30个测试问题,用不同参数跑一遍,看召回率再定。
4. 向量检索:从"能搜到"到"搜得准"的调优过程
4.1 Embedding模型的选择与本地化部署
Embedding模型负责把文本转成向量,它的质量直接决定检索准不准。我试过三种方案:调用云端Embedding API、用Ollama跑本地模型、用LangChain4j内置的轻量模型。
云端API效果最好但有两个问题:一是要联网,二是按量计费,毕设演示时网络一抖就尴尬。所以我最终选了本地部署,用Ollama拉了一个中文效果不错的小模型。本地部署的好处是零成本、离线可用、数据不出本地,缺点是首次加载慢,且对机器内存有要求。
# 拉取并运行本地embedding模型 ollama pull nomic-embed-text ollama serve然后在SpringBoot里配置LangChain4j的Embedding模型指向本地服务:
@Bean public EmbeddingModel embeddingModel() { return OllamaEmbeddingModel.builder() .baseUrl("http://localhost:11434") .modelName("nomic-embed-text") .build(); }这里有个坑要提醒:Embedding模型和生成模型是两回事。Embedding模型只负责把文本转向量,不做问答;生成模型才负责组织答案。两者可以来自不同厂商,别搞混了。我一开始想用一个模型全包,结果发现Embedding和生成对模型能力的要求完全不同,分开选反而效果更好。
4.2 相似度计算与Top-K召回策略
向量存进去之后,检索就是算相似度。常用的是余弦相似度,值越接近1越相关。LangChain4j的EmbeddingStore封装了这套逻辑,调search方法传问题和K值就行。
Embedding queryEmbedding = embeddingModel.embed(question).content(); List<EmbeddingMatch<TextSegment>> matches = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(queryEmbedding) .maxResults(5) // Top-K .minScore(0.6) // 相似度阈值 .build() ).matches();Top-K取多少是个需要权衡的参数。K太小,可能漏掉关键信息;K太大,噪声多,还会撑爆上下文窗口。我实测下来K=5是个不错的起点。minScore阈值更重要,它是一道过滤网,低于阈值的直接扔掉。我设0.6,低于这个分数的基本是无关内容,硬塞给模型反而干扰判断。
但纯向量检索有个天然缺陷:它擅长语义匹配,不擅长精确匹配。比如你问"错误码E0434352是什么意思",向量检索可能召回一堆讲错误处理的段落,却没召回那个精确提到E0434352的段落。这时候就需要混合检索来补。
4.3 混合检索:向量加关键词的双保险
我的做法是向量检索和关键词检索并行跑,然后合并结果。关键词检索用MySQL的全文索引或者简单的LIKE匹配都行,重点是把精确命中的结果加权提上来。
具体策略是:向量检索召回Top-5,关键词检索召回Top-5,两边的结果按文档块ID去重合并,向量命中的给基础分,关键词命中的额外加分,最后统一排序取Top-5喂给模型。这样既保留了语义理解能力,又补上了精确匹配的短板。
实测这个改动让"查具体错误码、查具体函数名、查具体配置项"这类问题的准确率提升非常明显。纯向量检索时这类问题经常答非所问,混合之后基本能精准命中。
4.4 检索质量的量化评估
光靠感觉说"效果变好了"不严谨,毕设答辩时老师也会问你怎么证明。我建了一个小测试集:30个问题,每个问题人工标注了应该命中的文档块。然后算两个指标——召回率(该命中的有没有被召回)和命中率(召回的里面有多少是相关的)。
| 检索方案 | 召回率 | 命中率 |
|---|---|---|
| 纯向量 Top-5 | 73% | 68% |
| 纯关键词 | 60% | 82% |
| 混合检索 | 90% | 79% |
数据说明混合检索在召回率上优势明显,命中率略低于纯关键词但差距不大,综合来看是最优解。这套评估方法也成了我论文里"实验与分析"章节的核心内容,比空谈"效果良好"有说服力得多。
5. 提示词工程:让大模型基于检索结果老实回答
5.1 上下文拼接的格式设计
检索回来的chunk怎么拼进提示词,直接影响模型的表现。我试过几种格式,最后固定成带编号和来源的结构:
你是一个严谨的知识库助手,请仅根据下面提供的资料回答问题。 如果资料中没有相关信息,请直接说"根据现有资料无法回答",不要编造。 【资料1】来源:操作手册.pdf 第3章 (chunk内容) 【资料2】来源:FAQ.md (chunk内容) 用户问题:xxx这个格式有几个讲究。编号让模型能引用来源,回答时可以标注"根据资料1"。来源信息方便用户溯源,也方便我调试时看命中了哪些文档。**"仅根据资料回答"**这句约束是防幻觉的关键,不加这句,模型很容易自由发挥,把训练时的知识混进来。
5.2 防幻觉与"不知道"的处理
RAG最大的价值之一是能说"我不知道"。纯大模型问它没见过的知识,它会一本正经地编;RAG因为检索不到就会明确告诉你查不到。但前提是提示词里要明确授权它说"不知道"。
我踩过一个坑:早期提示词写的是"请回答用户问题",结果模型检索不到相关内容时,硬是拿检索到的无关片段拼凑了一个看似合理的答案。后来改成"如果资料中没有相关信息,请直接说无法回答",幻觉率大幅下降。
还有一个技巧是要求模型标注引用。让它回答时带上"[资料2]"这样的标记,一方面方便用户核实,另一方面也逼着模型真的去看资料,而不是凭记忆瞎答。
5.3 多轮对话中的上下文管理
知识库问答经常是多轮的,用户会追问。如果每轮都重新检索,可能丢失上一轮的语境。我的处理是:把最近几轮对话历史也拼进提示词,同时对当前问题做查询改写——结合历史把"它呢""那这个怎么配"这种指代消解成完整问题,再去检索。
// 查询改写示例:结合历史把省略问题补全 String rewritten = queryRewriter.rewrite(currentQuestion, chatHistory); // "那这个怎么配" -> "MySQL连接池怎么配置"这一步对多轮体验提升很大。不做改写的话,用户追问"那它呢",检索系统根本不知道"它"指什么,召回的全是无关内容。
6. 工程落地:SpringBoot与Vue.js的集成细节
6.1 后端接口设计与流式返回
后端我按RESTful风格设计了几个核心接口:文档上传、知识库列表、问答对话、会话历史。问答接口用SSE做流式返回,让答案像打字一样逐字出现,体验比等半天一次性返回好得多。
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestParam String question, @RequestParam String sessionId) { SseEmitter emitter = new SseEmitter(0L); // 异步执行检索+生成,逐token推送 chatService.streamAnswer(question, sessionId, emitter); return emitter; }SSE的好处是单向推送、实现简单、浏览器原生支持。相比WebSocket,问答场景不需要双向通信,SSE足够了。
6.2 前端聊天界面与文档管理
前端用Vue.js加Element UI,主要两个页面:聊天页和知识库管理页。聊天页就是个消息列表加输入框,消息气泡区分用户和助手,助手消息支持Markdown渲染和来源标注。管理页用el-upload做文档上传,el-table展示已入库文档,支持删除和重新索引。
有个细节值得说:上传大文件时要显示解析进度。一份几百页的PDF解析加向量化可能要几十秒,如果界面一直转圈没反馈,用户会以为卡死了。我用WebSocket把解析进度推给前端,体验好很多。
6.3 文档原文件存储与MinIO集成
原文件我存MinIO而不是直接扔数据库或本地磁盘。原因是MinIO是S3兼容的对象存储,部署简单,扩容方便,而且和业务数据分离,备份迁移都清晰。SpringBoot集成MinIO就是加个依赖、配个客户端Bean的事。
@Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint("http://localhost:9000") .credentials("minioadmin", "minioadmin") .build(); }上传时把文件流写进MinIO,返回一个对象key存进MySQL的文档表。下载和预览时用key去MinIO取。这样MySQL里只有轻量的元数据,不会因为存大文件而膨胀。
7. 那些让我熬夜的坑与排查过程
7.1 MySQL连接池耗尽导致问答卡死
系统上线演示前一天,我压测时发现连续问十几个问题后,接口就不响应了。日志里全是"Could not get JDBC connection"。排查下来是连接池配置太小,默认HikariCP最大连接10个,而我的问答流程里一个请求要多次查库,并发一上来就耗尽了。
解决是把最大连接数调到20,同时检查代码里有没有忘记关闭的连接。这里有个经验:流式接口里千万别长时间持有数据库连接。我一开始在SSE的整个生命周期里都开着事务,导致连接被占住不放。改成先查完数据、关掉连接,再慢慢推送流,问题就没了。
7.2 向量维度不匹配的隐蔽报错
换Embedding模型时踩了个坑:新模型输出768维向量,但向量库是按之前384维建的,存进去直接报维度不匹配。这个错还算明显,更坑的是有些向量库不报错,而是静默截断或补零,导致检索结果莫名其妙地差。
教训是:换Embedding模型必须重建整个向量库。不同模型的向量空间完全不兼容,混用等于灾难。我在代码里加了个校验,存向量前检查维度,不一致直接抛异常,避免静默出错。
7.3 中文分词的边界问题
用HanLP做关键词检索时,发现"数据备份"被切成了"数据"和"备份"两个词,检索"数据备份"反而匹配不到完整短语。这是分词粒度的问题。解决办法是关键词检索时同时用分词结果和原始短语做匹配,短语精确匹配的给更高权重。
7.4 大模型返回格式不稳定的处理
让模型标注来源时,它有时候返回"[资料1]",有时候返回"(资料1)",有时候干脆不标。格式不稳定导致前端解析来源时经常失败。我的处理是用正则宽松匹配,把各种括号形式都兼容进来,匹配不到就降级为不显示来源,不让格式问题影响主流程。
8. 系统还能怎么往下做
这套系统跑通之后,我陆续想了一些扩展方向,也分享给正在做类似项目的你。
GraphRAG是最近很火的方向。普通RAG是按块检索,块与块之间的关系是断的。GraphRAG把知识抽成实体和关系图谱,检索时能沿着关系链找到关联信息,对"某某和某某是什么关系"这类问题效果更好。实现上可以引入图数据库,把文档里的实体关系抽出来建图。
Agentic RAG是另一个思路,让模型自己决定要不要检索、检索几次、用什么查询。比如第一次检索结果不理想,模型可以自动改写查询再检一次。这比固定的一次检索灵活,但工程复杂度也高,适合作为进阶方向。
多模态检索也值得做。现在只能检索文本,如果能把图片、表格也向量化,那技术文档里的架构图、数据表就能被检索到,覆盖面会广很多。
检索结果重排序是个性价比很高的优化。初次召回用向量快速筛,然后用一个更精细的rerank模型对Top结果重新排序,把最相关的顶上来。这一步通常能再提升几个点的准确率,代价是增加一点延迟。
最后说句实在的,RAG系统的效果是"调"出来的,不是"搭"出来的。框架搭起来可能就几天,但切分参数、检索策略、提示词这些细节的打磨,才是真正拉开差距的地方。我前后调了差不多两周,才从"能答"变成"答得准"。你做的时候也别指望一次到位,准备好测试集,边测边调,数据会告诉你哪里该改。