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

RAG实战:用LangChain打造稳定知识库问答系统

RAG实战:用LangChain打造稳定知识库问答系统 ★ FEATURED ARTICLE
如果你最近在折腾大模型应用大概率绕不开 RAG 这个词。RAGRetrieval-Augmented Generation检索增强生成简单讲就是在大模型回答问题之前先从一个外部知识库里把相关材料检索出来然后让模型看着这些材料作答。我最近用 LangChain 把这一套流程完整搭过好几版从最初只能跑通演示到后来能稳定支撑一套私有知识库问答。这篇文章就记录一下我个人实践中的核心思路、代码片段和踩过的那些坑适合刚打算入坑 LangChain RAG、或者已经在写 RAG 但总觉得效果不稳定的折腾派也适合想评估 RAG 方案的技术负责人。RAG 这两个词这几年很火但火归火真正上手后你会很快发现它并不是“接一个大模型 API再连一个向量库三件套拼装完事”这么简单。数据的清洗方式、文本怎么切、向量怎么算、检索阈值怎么定、提示词怎么约束每一个环节都在决定最终效果。这篇文章按我自己做项目的顺序展开从为什么需要 RAG讲到文档处理、Embedding 选型、向量存储再到可运行的代码全流程和问题排查尽量把“能落地”的部分讲透。1. 先把 RAG 为什么会出现、能解决什么问题聊明白1.1 大模型的“知识冻结”与三个绕不开的痛点很多人第一次接触大模型时会有一个错觉模型懂得这么多那我是不是可以把内部资料都丢给它它就能像同事一样有问必答不是的。大模型的参数在训练完成后就冻结了它掌握的“知识”停留在训练数据截止的那一刻。你问它今年公司新发布的产品政策它大概率一本正经地编一个让你哭笑不得的答案。这不是模型笨而是它确实不知道。我归纳下来纯靠模型本身做知识问答会撞上三个痛点时效性问题模型知识有截止日期新资料、新规定、新版本发布之后模型根本看不见。私有数据问题公司内部制度、产品文档、实验记录、客户需求这些内容不会出现在任何公开训练集里模型天然一无所知。幻觉问题模型不知道答案时为了生成通顺的回复会捏造看似合理的内容。这在知识问答场景中是致命的用户分不清哪句是真的。RAG 的思路很直白模型不知道没关系我们先把可能包含答案的资料检索出来塞进它的上下文里再让它基于这些资料回答。相当于考试时允许它翻书而不是要求它是百科全书。这个“翻书”的动作就是 Retrieval检索后面根据翻到的内容写答案就是 Augmented Generation增强生成。1.2 LangChain 在 RAG 链路里的真实角色以及什么时候可以不选它有人会问RAG 的核心流程不是“检索 生成”吗那我直接写代码也能做为什么用 LangChain我的答案是LangChain 最大的价值不是某个独门技术而是它把 RAG 链路里那些高频组件全部抽象成了统一接口。文档加载有 Loader切分有 TextSplitter向量化有 Embeddings存储有 VectorStore检索有 Retriever生成有 Chain。这些接口一旦统一你换一个 Embedding 模型、换一个向量库只需要改一行配置而不是重写整条链路。不过我也不建议无脑上 LangChain。如果你的需求只是“把一个固定文本塞进 prompt让模型总结一下”那用 LangChain 反而显得笨重直接调模型 API 拼接字符串就够了。LangChain 真正发挥优势的场景是你需要多格式文档加载、多类型检索策略、可能要反复换模型和向量库的中大型项目。做技术选型时一定要清醒框架只是工具不是目的。我个人对 LangChain 的基本判断是RAG 项目的初期搭建用它能省很多时间尤其你想快速验证一个 idea 的时候。但它抽象层级较多出问题时 debug 链路偏长所以一定要搞清楚每一层接口背后到底做了什么否则你会被“魔法”坑得很惨。后面讲代码时我会刻意把每步拆开说就是为了帮你减少这种黑盒感。2. 决定 RAG 上限的往往是“文档处理”这一关2.1 数据盘点与清洗比写代码更早该做的事很多 RAG 项目死在第一关不是因为代码写得不好而是因为输入数据本身乱成一锅粥。我见过一个真实案例某团队想做一个内部制度问答直接把几百份 Word、PDF 丢进流程结果检索出来的内容里混着页眉页脚、目录页码、表格错位、重复段落模型的回答质量自然惨不忍睹。开始写任何代码前先做两件事。第一盘点数据源搞清楚你的资料到底有哪些格式、多少量、更新频率多高。第二做一轮粗糙清洗去掉明显的页眉页脚、把扫描件先过 OCR、把表格转成结构化的文本或 Markdown、把重复文件去重。这一步听起来不性感但很可能是整个 RAG 项目里性价比最高的一环。清洗到什么程度算合格我的经验是随机抽 20 段清洗后的文本如果能像正常文章一样读得通没有明显乱码没有大段无意义内容就可以进入切分环节。另外要注意图片里的文字、流程图里的逻辑关系模型是看不见的要么别指望它理解要么提前转成文字描述。2.2 切分策略直接决定检索结果的颗粒度数据清洗完之后下一个核心动作是切分。切分这个东西外行看着觉得简单——不就是把长文本一段段截开吗但切分质量直接决定检索时能不能把“含答案的那一块”捞出来。你想想如果一段文本被从中间硬生生切断一句话只保留了一半那 Embedding 算出来的向量表达的是半个不完整的意思检索时自然对不上问题。我常用的默认方案是递归字符切分器它会按段落、按句子、按字符逐层尝试尽量把完整语义保留在同一个 chunk 里。切分时有两个核心参数chunk_size每个块的长度。实际项目中中文资料我常用 200500 字英文资料可以适当拉长到 500800 token。块太大向量表达的主题会变得模糊检索精度下降块太小上下文信息不足模型回答时缺背景。chunk_overlap相邻块之间重叠的字符数一般取 chunk_size 的 10%20%。它的作用是防止关键信息正好卡在两个块的边界上被切开。如果你的原始文档本身就有结构比如 Markdown 标题、HTML 标签、代码块我会强烈建议优先做结构感知切分而不是纯按字符数切。这样切出来的每个块天然自带章节上下文检索和回答的质量都会明显上升。判断切分是否合理有一个土办法把切出来的块随机打印几条看一眼“语义是否完整”。如果大量块以“然后”“因此”“这个”开头说明切分过碎overlap 不够或者没有按语义边界切。2.3 Embedding 选型别只盯着“越贵越好”的向量模型文本切分好之后要把每个 chunk 转成向量这个转换用的模型叫 Embedding 模型。很多初学者会忽略 Embedding 选型觉得随便用一个模型效果都差不多——实际上是差很多的。如果 Embedding 模型无法理解领域术语、中文语义后续的检索精度上限就被锁死了再怎么调参数也是白搭。选 Embedding 模型时我一般按下面的顺序思考问题数据隐私要求如果资料不允许出内网就得用本地部署的开源模型如果能接受外部 API商用模型在通用语义上往往更好。中文还是多语言如果知识库以中文为主尽量选中文语料优化过的模型或者至少在多语言评测上表现不错的模型。向量维度与成本向量维度越高表达能力通常越强但占用的存储和计算资源也越大。如果你有几十万条数据不要忽略这个成本差异。一个很实用的验证方法挑出 10 个你真正关心的问答场景把问题和标准答案所在文档丢进去做一次“相似句子检索”测试看目标文档能不能排进前 5。如果连这一步都过不了后面再怎么调 prompt 都是白费。这个冒烟测试花不了半小时但能帮你提前避开最伤效果的错误选型。2.4 向量存储与索引先问数据量再谈技术选型向量数据库这几年非常热但热不代表必须用重型方案。我的建议很简单先问自己数据量到底多大。如果只有几千条 chunk直接用内存型向量存储就够了查询速度飞快部署还简单到了万级以上再考虑引入专门的向量数据库。用分布式方案支撑几千条数据纯粹是给自己找运维负担没有任何收益。选向量数据库时除了看它能否支持相似度检索还要关注它用什么索引算法。常见的有 HNSW、IVF 这类近似最近邻索引核心思想是通过索引结构减少需要比较的向量数量换取检索速度和内存占用之间的平衡。数据量不大时索引差别感受不明显数据量大后HNSW 这类图索引在召回质量和查询速度上的均衡性会好一些。还有一点很多人会忽略存入向量库前Embedding 向量的归一化处理很重要。如果你用的是余弦相似度不归一化也不是不能用但归一化之后计算会更稳定也方便统一做相似度阈值判断。我在项目中习惯把“写入前归一化”作为默认动作。3. 一条能跑通的 LangChain RAG 流水线实操3.1 索引阶段的完整代码与每一步在做什么先看索引阶段。所谓索引就是把文档切块、向量化、写入向量库方便后续检索。我这里会把代码的关键骨架写出来具体的大模型服务、Embedding 服务和向量库可以换成你自己环境里的实现。from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter # 1. 文档加载把原始文档装进来这一步聚焦“拿到文本” # 不管来源是PDF、Word还是Markdown最终都转成Document对象 documents [ Document(page_content...这里是清洗后的文本..., metadata{source: example.pdf}), ] # 2. 文档切分核心目标是保持语义完整 # chunk_size300, overlap50 是我在中文场景下比较常用的起点 splitter RecursiveCharacterTextSplitter( chunk_size300, chunk_overlap50, separators[\n\n, \n, 。, , , ., !, ?, , ], ) chunks splitter.split_documents(documents) # 3. 向量化调用 Embedding 服务把文本转换为向量 def create_embeddings(): # 这里接入你自己的 Embedding 模型或服务 # 返回一个符合接口的 Embeddings 实例即可 pass embeddings create_embeddings() # 4. 写入向量库chunk 越多越要关注索引类型与批量写入性能 def create_vector_store(texts, embeddings): # 这里接入你的向量库实例 pass vector_store create_vector_store(chunks, embeddings)这段代码里最容易被低估的是 metadata 字段。我给每个 chunk 打上来源、页码、文档标题这样后面模型回答时可以引用出处实现“答完题给你看翻的是哪页”也方便出问题后回溯。很多项目做到后面想追溯答案来源却追溯不了就是因为当初没存 metadata——典型的省一时之力后面花十倍时间补。3.2 检索与生成阶段把“查到的资料”变成“管得住的回答”索引建好之后就是真正的问答环节把问题转成向量去向量库检索最相关的 chunk然后把这些内容拼进 prompt让大模型基于这些内容回答。这里要特别注意一点检索出来的原始 chunk 不一定都密切相关更不能直接全塞给模型必须先按相似度过滤一轮。下面是我在实际项目里经常用的检索和生成骨架# 5. 检索拿到问题时转成向量让向量库返回最相似的块 question 报销流程是什么 retrieved_docs vector_store.similarity_search_with_score(question, k5) # 通常返回结果是 (Document, score) 的列表 # score 越小表示距离越近、越相似需要结合你使用的向量库定义来解读 # 6. 过滤把相似度太差的块剔掉可以配合调试日志观察 filtered_docs [] for doc, score in retrieved_docs: print(fscore{score:.4f} source{doc.metadata.get(source)}) if score 0.8: # 这个阈值取决于距离度量和向量库实现 filtered_docs.append(doc) # 7. 拼装提示词把上下文和问题一起交给生成模型 from langchain_core.prompts import ChatPromptTemplate prompt_template ChatPromptTemplate.from_messages([ (system, 你是一个严谨的文档问答助手必须基于给定的资料回答问题。 如果资料中没有答案就明确说不知道不要编造。), (human, 请基于以下资料回答用户问题。\n\n资料\n{context}\n\n问题{question}), ]) # 8. 调用生成模型这里接入你自己的大模型接口 def create_chat_model(): pass chat_model create_chat_model() messages prompt_template.format_messages( context\n\n.join([f[片段{i1}] {doc.page_content} for i, doc in enumerate(filtered_docs)]), questionquestion, ) answer chat_model.invoke(messages)这套 prompt 里“没有答案就说不知道”这句话很关键。它不能 100% 消除幻觉但能明显减少模型硬编答案的概率。我还会让模型在回答末尾标注使用了哪些资料片段编号比如“依据片段[1]和[3]”这样人工复核时一眼就能看出模型是不是真的在依据资料作答。3.3 参数调优记录top_k、阈值和重排的实测对比RAG 项目进入调参阶段后最常盯的就是三个参数检索返回数量top_k、相似度阈值、是否做重排。我把自己实测下来的一组对比列出来仅供参考因为不同数据分布下最优值会变。参数组合现象我的结论top_k3无阈值过滤回答快但偶尔找不到分散在多个文档里的答案适合单文档覆盖型问题答案集中时够用top_k8无阈值过滤上下文较长可能混入大量无关片段模型开始“挑着用”需要配合生成模型判断效果不稳top_k5加阈值过滤无关片段明显减少回答质量稳定这是我项目里最常用的组合top_k10 重排把候选集先放宽再精排效果最好但耗时略增适合答案比较分散、对质量要求高的场景重排Reranker是很多成熟 RAG 项目里的“隐藏王牌”。它的思路是先用向量检索捞回一批候选片段再用一个更精细的模型对“问题-文档片段”的相关性做打分排序把最相关的排到最前面。简单说第一次检索解决“别漏掉”第二次重排解决“别错乱”。如果项目对回答准确率要求高重排的收益非常明显代价是会多一次模型调用速度会慢一点。我一般把重排称为“RAG 的提分神器”因为它不像换 Embedding 那样要动全局而是可以在链路末端独立增加。4. 真实项目中的翻车现场与排查经验4.1 检出来的东西跟问题完全无关这是最打击人的一个现象代码跑通了向量库也建好了但回答问题的材料八竿子打不着。出现这种情况时我建议按下面的顺序排查先看检索分数把检索阶段打印出来的相似度分数打出来看是不是普遍偏高不相似。如果分数都很差说明检索质量本身就差。再看切分质量把命中的 chunk 原文打印出来看是不是语义残缺、上下文缺失。很大概率是切分过了头。最后看 Embedding换一个明显更强的模型做对比测试如果换了之后效果马上改善问题就出在 Embedding 选型上。额外检查一下问题本身检索任务是“用问题向量去库里找文档向量”如果用户提问口语化和知识库书面语差别巨大检索效果就会显著下降。这时候可以做一个查询改写先把问题转换成更贴近文档表达的形式再去检索。4.2 检索到了答案模型却还在自说自话这种情况比检索不到答案更让人头疼因为材料明明在上下文中模型却视而不见。我第一次遇到时百思不得其解后来慢慢总结出三个原因。第一prompt 约束不够硬。模型感觉资料有缺漏时容易按自己的“常识”补全。解决办法是把 system 提示词改成强约束你只能依据资料回答资料没有的信息必须承认不知道。第二资料和答案的形式不匹配甚至问题和资料的答案不是同一个表达方式。模型确实看到了片段但参考答案的核心点比如报销流程是表格转文本后的乱序字段模型没意识到这就是答案。这种情况需要回头处理和切分。第三模型上下文太长后注意力分散。返回片段太多答案被淹没在无关内容里。解决方法是减少 top_k或者用重排器把最相关的片段排到最前面。4.3 上下文太长、成本飙升怎么办RAG 搭完以后另一个常见的坑是 token 消耗超出预期。每次问答都要把所有检索片段都塞给大模型片段多而长时prompt 轻松就能破几千 token商用 API 按 token 计费一天调几百次成本相当夸张。我的处理思路是分级降载。第一让每个 chunk 更紧凑去掉冗余内容控制在 200 字左右。第二检索后不全部塞给模型只保留重排后的前 24 个片段。第三对 long-tail 型长文档先做一次摘要索引即文档先切成大块生成摘要再往库里存“摘要 原文定位”检索时先捞摘要再按需取原文。最后加一层缓存同一个问题命中同样片段时直接复用上一次的回答。按这些方式做下来token 消耗能降到一个可以接受的量级而回答质量几乎不受影响。4.4 一次典型的“答辩现场”从症状到根因排查有一次我帮 A 同学排查一个内部知识库问答项目用户的典型问题是“我今天下午申请报销最快要多久到账”。模型回答得很流畅但字字都是错的。我把整个链路从头打日志关键线索是这样的检索分数显示分数最好的几个片段来自一份名为“费用管理规范”的旧版本文档里面写的到账时间是 7 天而新版流程已经调整为当天内到账。chunk 本身切得没问题prompt 也在按资料作答但它“忠实地”回答了一个过时答案。这里根因不在检索、不在切分在数据新鲜度管理。知识库数据源如果更新了索引需要同步更新否则系统只是在一个旧世界里“准确”运行。处理后重新更新索引并把旧文档标记过期问题立刻解决。所以我在每个项目里都会强调RAG 不是一次性建立知识库就完了它是一条需要持续维护数据的流水线。上线前就要想好数据更新的触发方式和清理策略。5. 几次项目做下来我自己的体会做 RAG 项目这几轮下来我最大的体会是不要迷信“RAG 很厉害”这句话它的上限高度依赖数据质量和过程控制。数据清洗花的时间几乎总是超过调模型参数的时间这是正常的不是你的问题。合理的预期是把检索链条每一步都做到 80 分以上最后系统整体的可用度才会让人满意任何一步偷懒都会在用户实际提问时原形毕露。我还想分享一个我自己常用的“滴水不漏”小技巧在知识库里故意放入一些无答案的占位数据或者用空的模拟向量做一次负样本测试观察模型在检索不到资料时会不会硬答。如果模型检索不到资料时能老老实实说“资料库中没有相关信息”那这套系统的可信度才算真正立住了。后面如果再有人拿着他们奇怪的业务场景来找我聊 RAG我基本都会建议他们先把这个排查方法完整跑一遍胜过空谈一大堆技术名词。
阅读完成 · 觉得有帮助?
咨询建站