首页 / 资讯中心 / 文章详情

基于LangChain的RAG问答应用搭建实践与避坑指南

基于LangChain的RAG问答应用搭建实践与避坑指南 ★ FEATURED ARTICLE
如果你手里有一堆内部文档想把它们变成一个能随时问答的知识助手多数人最先想到的是微调大模型。这个思路不能说错但绝大多数场景下真不如 RAG 划算。我上个月帮某团队把一个基于 LangChain 的 RAG 问答应用从零搭起来先跑通了一个最小原型再一路调试到可以日常使用整个过程遇到的问题比想象中多但也比想象中好解决。这篇文章就按我当时的推进顺序来写为什么选 RAG、文档怎么进向量库、问答链路怎么编排、上线前踩了哪些坑、最后又是怎么把回答质量提上去的。如果你刚接触 RAG或者已经把 demo 跑通了但觉得效果不稳这份笔记应该能帮上忙。1. 为什么我不用微调而是选择了 RAG 这个架构1.1 微调和 RAG 解决的根本问题不同先说一句容易得罪人的话很多人把微调当成 RAG 的替代品这其实是把两个不同维度的事情放在一起比了。微调改变的是模型本身的行为习惯和表达风格比如让模型学会某种特定的话术、推理模式、代码风格。它相当于给模型换了一套思维方式。而 RAG 改变的是模型在回答问题那一刻能看到的材料相当于给模型配了一个随时可以翻阅的资料库。你真正想让模型回答手册里写了什么、条款怎么规定的、这个故障怎么处理这类问题本质上不要求模型重新学习思维方式只需要在回答时引用正确的资料RAG 天然更匹配。从成本上算一笔账更直观。微调一次要准备训练数据、安排算力、跑若干轮训练中间还可能要反复调整超参数和数据集整体时间成本以周为单位。而且只要业务文档更新被迫再来一轮。RAG 这边呢文档更新之后只需要重新切分、重新向量化、覆盖旧的索引整个流程通常用分钟计算。对于产品手册、内部制度、运维指南这些内容更新频繁的知识类问答RAG 几乎可以说是唯一理性的选择。1.2 LangChain 在整个链路里扮演的角色选型的时候也想过完全自己写管道但后来发现没必要。LangChain 这个框架在 RAG 场景里更像一个调度台它不代替你做文档处理、不提供大模型、也不维护向量库而是把加载、切分、向量化、存储、检索、提示词组装、模型调用这些环节统一编排起来。每个环节都有标准的接口你可以在不同实现之间自由切换。这一点在实际开发里的价值非常大。我最早用的是某个轻量向量库后来数据量上来想换成支持并发的服务型方案因为接口一致改动只集中在初始化配置那几行问答主链路的代码基本没动。顺着这个思路我把加载器也换成了支持 PDF、Markdown、表格等多种格式的自定义实现项目结构依然是清晰的。也就是说LangChain 给你搭好了管道骨架你真正要干的是把管道里每一站的质量调到最好。1.3 什么时候不该用 RAG反过来也得说清楚边界。如果你要解决的是模型对某类格式的输出不够稳定或者想让它养成某种固定的话术习惯RAG 帮不上忙。RAG 适合知识覆盖类问题不适合能力提升类问题。还有一个场景也不适合一上来就 RAG高频短问答、强逻辑推理、需要多步规划的请求。这些即使塞了一堆资料进去模型也不一定有效利用。我当时的判断依据很简单团队真正的问题不在于模型不会说人话也不在于它不会推理而在于它对内部资料一无所知、回答没有依据。这种问题用 RAG 修正信息来源是最对症的。想清楚这一点之后架构选型就没什么纠结了。对比维度微调RAG知识更新成本重新训练以周计重建索引以分钟计可追溯性黑盒难以解释可引用具体来源幻觉控制依赖训练数据质量可通过约束和引用有效兜底典型适用场景行为风格、输出格式私有知识问答、文档检索2. 先把知识装进去文档加载、切分和向量化的完整流程2.1 从文档到 chunk切分不是一刀切数据准备这一步看似简单实际决定了整个问答系统的上限。我见过不少项目问答链路写得挺漂亮最后效果差回头一查根子都出在切分上。先把文档读进来这一步没什么好说的。真正的关键是从原文档到可检索片段的切分策略。LangChain 提供递归字符切分器它按优先级从高到低尝试分隔符优先在段落边界切找不到再到句子边界、标点边界。这比按固定长度硬切要友好得多至少不会把一个完整句子拦腰截断。from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader TextLoader(./data/maintenance_manual.txt, encodingutf-8) doc loader.load() splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , , ], ) chunks splitter.split_documents(doc) print(f原文被切成 {len(chunks)} 个片段)切分参数里chunk_size 决定每个片段的长度chunk_overlap 决定相邻片段之间保留多少重叠。很多人不理解 overlap 的作用它其实是在补偿知识被切断的问题。一个知识点往往在段落末尾才给出结论如果前一段和后一段完全不重叠后一段检索到的内容就缺失前因模型刚看到答案却没看到前提自然容易答偏。实际跑下来chunk_size 设 500 到 800 字符、overlap 设 80 到 120 字符是我在中文文本上觉得比较稳的区间。片段太小检索可控但上下文不完整片段太大单次召回的噪声多进了模型上下文也浪费 token。另外要注意中文字符本来信息密度就高于英文所谓 500 指字符而不是 token换算关系需要你按实际文本自测。2.2 向量化选对嵌入模型是地基中的地基切分得到片段以后下一步要把文本转换成向量。嵌入模型的任务是把一段话映射成一组浮点数语义相近的文本在向量空间里距离更近。这个距离是否贴合直觉直接决定检索质量。选型经验里最重要的一条是必须选用在中文语料上训练过的嵌入模型。用英文为主的通用嵌入模型处理中文很多语义关系会非常怪比如苹果到底该跟水果还是手机靠近模型都是靠训练语料猜出来的语料里中文少猜错的概率就高。嵌入向量的维度也值得留意。维度高的模型通常表达能力强但存储占用和计算耗时也会上涨。对于几万片段的项目维度差异影响还不明显但如果你要往百万级走向量维度和检索耗时的关系需要提前做压力测试。还有一条很多人踩过的坑索引和查询时务必用同一个嵌入模型。我犯过一次索引用 A 模型跑起来觉得效果不好换成 B 模型却忘了重新构建向量库。结果 A 的向量和 B 的查询向量根本不在同一个语义空间里程序不报错但检索结果完全是乱的。换了嵌入模型就得强制要求自己重建索引不要心存侥幸。2.3 向量库的选型思路向量库解决的是海量向量怎么存、怎么快速找邻居的问题。选型时先问数据量有多大、并发有多高、需不需要持久化再决定方案。入门阶段我推荐先用本地文件型向量库比如 FAISS 这类不需要额外起服务的方案。它存成一个本地索引文件加载快、调用简单非常适合把链路先跑通。LangChain 把它封装得足够简单基本三行代码就能建库from langchain_community.vectorstores import FAISS # 假设 embeddings 是已经初始化好的嵌入模型实例 vectorstore FAISS.from_documents(chunks, embeddings) vectorstore.save_local(./storage/faiss_index)当数据量上来或者需要多个服务共享同一个检索能力时再换成独立运行的服务型向量库也不迟。因为你走的是标准接口迁移时问答主链路不用改动最坏情况也就是把初始化代码替换掉。还有一条常见路线是直接在已有的关系数据库上加向量索引适合那种文档数据本来就在库里的团队减少一套外部依赖。3. 把检索和生成编排起来RAG 问答主链路的代码实现与设计3.1 最小可用版本retriever prompt LLM整个问答链路本质上就三步根据用户问题去向量库检索相关片段把检索结果和问题拼进提示词把提示词交给大模型生成回答。用 LangChain 表达出来非常直接retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 5} )这里的 k 代表每次返回多少个候选片段。一般先设 5后续根据效果再调。真正决定问答质量的其实是提示词。我见过不少 Demo 没做提示词约束把问题原样丢给模型甚至压根没把检索到的片段放进去那就成了伪 RAG模型照样凭记忆乱编。一个好的提示词至少要包含三件事角色定义、资料上下文、拒绝编造的兜底指令。下面这个模板我一直在用效果很稳定prompt_template 你是知识库问答助手。请只依据下面的参考资料回答用户问题。 如果参考资料中没有相关内容请直接回答“资料中没有找到相关信息”不要编造。 参考资料 {context} 用户问题{question} 回答把 context 替换成检索到的片段question 替换成用户问题。这里的细节是给模型留了一个不知道就说不知道的出口。在企业应用里可预测性远比盲目自信重要一个能诚实说资料里没有的助手比一个一本正经胡说八道的助手可靠得多。3.2 检索参数怎么调top_k 和相似度阈值有讲究检索参数是很多人不重视、但影响体验的关键。k 值太小正确答案可能没进候选k 值太大无关片段会稀释模型注意力甚至把回答问题所需的 token 占掉。我的经验是先从 k5 起步看一类典型问题的检索结果如果正确答案经常出现在第 3、4 位可以适当调大到 8如果第 1 位基本就准了那 k3 足够还能省 token。相似度阈值也一样不同嵌入模型产出的得分分布天差地别千万不要直接抄别人的阈值。最快的方法是打印一批真实问题的检索得分for i, (doc, score) in enumerate(vectorstore.similarity_search_with_score(q, k5)): print(f#{i} 得分{score:.4f} 来源{doc.metadata.get(source)}) print(doc.page_content[:80].replace(\n, )) print(---)跑两三轮典型问题之后你对当前数据的相似度分布就有感觉了再来决定阈值是 0.7 还是 0.8。不要一开始就拍脑袋定阈值。3.3 多轮对话记忆容易拖垮检索准确率的地方RAG 应用难逃多轮对话。但检索器处理的是单个问题字符串它并不知道上下文。最直接的做法是让用户的最新一轮问题带上历史信息这需要做问题改写。我当时的使用场景里用户会问它的最大负载是多少然后紧接着问那维护周期呢。如果不结合历史检索器根本不知道它指的是哪台设备。改动也不复杂先用模型把最新问题和最近两轮问答历史合并重写成一个完整的独立问题再交给检索器。rewrite_prompt 根据对话历史把用户最新问题重写为一个不依赖上下文、语义完整的独立问题。 对话历史 {history} 用户最新问题{question} 重写后的问题改写要花一次模型调用但检索准确率的提升通常非常明显。如果不想用大模型改写退一步的做法是把最近一轮问答文本直接拼在问题后面也能缓解部分指代问题但效果不如专门改写。这里我不建议把太多历史塞给检索器答案通常藏在随后的几轮里历史越多噪声越大。4. 真正跑起来之后我遇到的三个典型问题与完整排查过程4.1 问题一手册里明明有答案检索器却说找不到现象是用户问如何重置管理员密码系统回答资料中没有找到相关信息但那份运维手册里确实写了这一节。开始我以为是大模型没用好提示词后来把检索结果打印出来才发现top 5 里根本没有这个问题的相关内容。排查过程分三步走。先确认向量库真的包含这份手册直接去数据源里查文本量再把手册里跟该问题最相关的一句话单独拎出来与用户问题算相似度发现得分依然很低最后判断这是嵌入模型的表达能力问题还是查询与文档的用词差异太大。结论是后者手册里写的是恢复默认账号凭据用户问的是重置管理员密码表述完全不一样但语义相同。解决思路是在两端同时优化。文档侧把容易出现的同义说法写进片段附近的上下文查询侧给问题做一次同义扩展把重置密码补充成重置/找回/恢复密码。这两个手段组合能覆盖掉相当一部分检索不到的 case。实际数据里很多文档不是不包含信息而是表述和用户习惯不一致这是 RAG 项目最典型的隐形问题。4.2 问题二检索结果里明明有正确答案模型却没用上另一个更让人挠头的情况是检索结果已经包含正确答案生成出来的回答仍然偏离。我用上面那段调试代码打印 top 5 之后发现正确答案排在第三排第一的片段和问题只是长得像方向相近但不是用户真正要的东西。这个问题的根因在于纯向量检索只按语义相似度排序它会把泛泛相关的片段排到前面而把真正精确匹配的片段压在后面。模型在生成时如果只盯着 top 1 或者前两个片段就会答偏。治标的办法是把 k 调大一点让正确答案进入模型视野治本的办法是引入重排序先把候选放宽到 20 条左右再用更强的排序模型把真正相关的片段提到前面来。这个优化我放在下一章细讲但排查链路本身很重要以后遇到检索到了却答错先看检索排序再看提示词这两个环节的问题概率远高于模型本身。4.3 问题三上下文长度超限回答直接被截断随着候选片段增多和对话历史变长一次调用喂给模型的 token 很容易超过限制。我遇到过报错提示超出最大上下文长度也遇到过看似没报错但回答到一半就断了。排查时我把送进模型的内容拆开数了数系统提示词、检索片段、最新问题、历史对话每一部分的 token 单独统计很快定位到是检索片段太多太长。解决思路有三个可以组合使用。第一降低 k 值从 8 砍到 5把真正有用的片段留给模型第二控制单个片段长度切分时不要让 chunk_size 无谓地大第三对历史对话做裁剪只保留最近两轮。还有一个更主动的方案是结果压缩——先将检索到的候选片段做一个相关性筛选或者让模型把每个片段压缩成与当前问题相关的摘要再拼进提示词。多花一次模型调用但能有效避免上下文装不下的问题。4.4 让调试过程变得更简单把检索日志当作排查起点这三点排查下来我最大的体会是回答质量问题要先看检索再看提示词最后才怀疑模型。为了让这个过程高效我在项目里加了一个日志开关每次问答都把检索到的片段原样记录下来包含来源、得分、片段开头。这样无论线上还是本地复现第一步永远不是猜而是翻日志。import logging def log_search(query, hits): logging.info(检索问题%s, query) for i, (doc, score) in enumerate(hits): logging.info(#%d 得分%.4f 来源%s, i, score, doc.metadata.get(source)) logging.info(doc.page_content[:60].replace(\n, ))这个日志在我后来排查大量效果问题时省了至少一半时间。你现在搭 RAG 应用我建议一开始就把这个能力做进去不要等出了问题再去翻代码。5. 让回答质量再上台阶的重排序、多路召回与引用溯源5.1 为什么单纯靠向量相似度不够纯向量检索的短板很明显它擅长语义相关性的模糊匹配但不擅长精确匹配。产品型号、条款编号、特殊名词这些场景用户输入的字符串和文档里可能只差一个字符语义上确实相关但排序不一定靠前。要想回答准确率跟手感上一个台阶绕不开两个方向多路召回和重排序。多路召回的意思是不把赌注全押在向量这一个篮子里。关键词检索这一路保证精确匹配不丢向量检索这一路负责语义泛化。我当时用的方案是把 BM25 检索结果和向量检索结果按一定比例合并将并集去重后一起进入重排序。代价是多维护一条检索链路但召回覆盖面立刻宽了很多。5.2 重排序的正确姿势宽召回之后再精排重排序的思路一句话就能讲清楚先用低成本方式多召回一些候选再用高成本但更准确的方式精排一遍。检索阶段用 k20 甚至更高召回然后把用户问题 候选片段逐条拼成对送入重排序模型打分最后只保留 top 3 进入生成阶段。这个策略几乎是短文本搜索提升效果最快的手段。我实测的效果对比很直观。纯向量检索下 top 1 命中正确答案的概率大概七成左右加上这一轮重排之后top 3 内包含正确答案的概率能超过九成。代价是多算了十来次排序分数以及多调用一层模型服务但对回答质量的提升是值得的。选重排序模型时优先考虑那些专门训练过的交叉编码器它把问题和文档拼成一对来判断相关性比普通向量模型更接近是否真的回答了这个问题。5.3 结果压缩别把不该进上下文的垃圾都喂给模型宽召回以后自然会带进来一些不相关的片段。如果一股脑全塞进上下文模型容易被噪声干扰还会挤占上下文空间。结果压缩就是为了解决这个矛盾对召回片段进行相关性过滤或把片段压缩成只跟当前问题有关的摘要。这个操作有一个直接收益模型注意力更集中。我在做日志对比时发现同样的 k5未压缩前模型有时会被来源不同的片段带偏压缩后回答的准确率和稳定性都有提升。压缩会增加一次模型调用对延迟敏感的场景需要权衡。我建议把它做成可配置的能力默认关闭遇到上下文拥挤或噪声严重的 case 再打开。5.4 引用溯源与资料查不到的兜底策略最后一步是让答案附带依据。这不仅是给用户看的也是在回答可信度和可维护性上做兜底。让模型在生成回答时用 [1][2][3] 这类角标对应相关资料片段并在回答末尾列出来源。用户如果对回答有疑问可以直接对照原始文档这个体验上的提升非常明显。# 提示词尾部追加引用说明 回答中的信息需要标注来源编号例如 [1]、[2]。 回答结束后另起一行列出 资料[1]《xxx手册》第3章 2.1节 资料[2]《故障处理指南》第7章 对于确实查不到的情况提示词里那句资料中没有找到相关信息就是最后的底线。宁愿让用户觉得系统笨一点也不要让模型编一个看起来很专业、实际上不存在的答案。这条原则在任何一个正经的知识问答产品里都应该放进提示词的默认配置。回顾这个项目我最想分享的一条经验是RAG 问答应用的效果天花板往往是检索质量而不是模型聪明程度。大量回答糟糕的案例只要把检索到的片段原样打出来看问题通常早就存在了——要么是资料压根没召回来要么是相关片段排序太靠后。后来团队里任何人来问为什么回答不对我的第一句话都是先打印检索结果而不是讨论换什么模型。这个习惯看着简单实际能帮你排掉百分之八十的效果问题。如果你正在搭类似的问答应用也建议把这条规则写进团队的工作流里。
阅读完成 · 觉得有帮助?
咨询建站