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

LangChain学习路线:先跑通RAG增强检索链路,再谈花哨功能

LangChain学习路线:先跑通RAG增强检索链路,再谈花哨功能 ★ FEATURED ARTICLE
先直接说结论如果让我重新走一遍 LangChain 的学习路线我不会从 Agent、工具调用这些花哨功能入手先把检索增强生成RAG这一条链路跑通、调优才是性价比最高的方式。RAG 的思路并不复杂——既然大模型不知道你私有的文档内容那就把相关内容先检索出来再拼进提示词里让它基于这些素材作答。但把这条链路做扎实涉及文档清洗、文本切片、向量化、检索策略、重排序等一整套细节每一步都有坑。这篇博文就围绕“学习 LangChain项目 RAG 增强检索”这条主线把我实际搭建和调优过程中验证过的东西完整梳理一遍。1. 先搞清楚 RAG 到底解决了什么问题很多人上来就写代码但我觉得有必要先花几分钟把 RAG 的定位理顺。大语言模型有个天生短板它的知识截止于训练数据你问它某个私有知识库里的问题它要么胡编要么告诉你不知道。微调是一条路但成本高、周期长而且模型每次更新都要重新调维护负担很重。RAG 则是另一条思路不改变模型本身而是改变模型的输入——在回答之前先去外部知识源里把相关片段捞出来塞进上下文里让模型“开卷考试”。这个方案的好处非常明显时效性强。文档更新后重新走一遍索引即可马上生效不需要重训模型。可溯源。回答可以引用具体来源段落这对企业内部文档问答、客服助手这类场景几乎是刚需。成本可控。不需要 GPU 做微调主要开销是向量化和大模型 API 调用费用。我在一个模拟的知识库问答项目里验证过这套思路输入是几十份产品手册和运维文档输出是问答接口。直接问基础模型回答质量惨不忍睹很多专业名词全靠编接入 RAG 链路后准确率提升非常明显。这个对比基本就是 RAG 存在意义的最好证明。那么 LangChain 在这中间扮演什么位置它本身不提供算法而是一个编排框架。把文档加载、切片、嵌入、向量库存取、检索、提示词组装、模型调用、回答生成这一条流水线用标准化接口串起来。你不用 LangChain 也能用原生 Python 写出来但会有一堆胶水代码要维护替换组件时到处改调用。LangChain 的价值在于组件化——想换一种向量数据库、换一个嵌入模型、换一种检索策略改动局部代码就行不用推倒重来。了解这一点后你就清楚学习的重点了不是背 API而是理解每个组件解决什么问题、参数为什么这么调、整条链路的性能瓶颈在哪里。下面我按搭建顺序逐步拆解。2. 环境准备与整体链路设计2.1 基础依赖与版本选择我用的环境是 Python 3.10 LangChain 0.1.x 系列。这里提醒一句LangChain 的 API 变化很快不同版本之间函数调用方式差异很大网上的教程经常因为版本不一致而跑不通。我踩过的坑是照着 0.0.x 的写法在 0.1.x 里调用报错后查了官方迁移文档才解决。建议直接看官方文档的当前版本说明或者锁定一个版本并固定依赖。pip install langchain langchain-community langchain-openai chromadb这里拆解一下依赖的目的langchain是核心框架负责链式调用和组件编排。langchain-community包含各类集成组件比如文档加载器、向量库封装。langchain-openai是 OpenAI 模型的封装包我用的是兼容 OpenAI 接口的本地模型服务所以也走这个通道。chromadb是本地向量库开发调试阶段最方便后面可以替换成其他生产级向量库。2.2 RAG 链路的五个关键环节整个 RAG 流程可以拆成两条线一条是离线的索引构建一条是在线的问答检索。索引构建链路文档加载 - 文本切片 - 向量化 - 写入向量库问答检索链路用户问题 - 向量化 - 相似度检索 - 组装提示词 - 模型生成 - 返回回答这两条线最容易被人忽略的关联点是问题和文档片段必须使用同一个嵌入模型向量化。如果索引时用模型 A检索时换了模型 B向量空间的分布完全不同检索质量直接崩盘。这一点在换嵌入模型时特别容易犯后面第四节我会详细说。设计整体链路的时候可以参考下面的组件选型表。这是我最初一版用的配置目标是先跑通再逐项优化环节选型选择理由文档加载PyPDFLoaderTextLoader覆盖 PDF 和纯文本够用文本切片RecursiveCharacterTextSplitter按分隔符递归切分兼顾语义完整性嵌入模型text-embedding-3-small或本地模型性价比高维度适中向量库Chroma本地运行零配置适合原型验证检索策略相似度 TopK简单直观后续再加重排序生成模型GPT-4 或国产大模型按成本预算选优先选指令遵循能力强的这一版方案的核心思路是“先求通再求优”。很多初学者一上来就追求混合检索、重排序、多路召回结果链路还没跑通就被一堆概念绕晕了。我的建议是第一版全部用最简单方案等拿到一条端到端可跑的链路再逐步替换组件观察效果变化这样才能真正理解每个环节的贡献和代价。3. 文档加载与切片决定检索质量的上游环节很多人把注意力放在模型和检索参数上却轻视文档处理这一步。实际上检索质量的上限在切片阶段就决定了后面的向量化和检索只是在尽量不损失信息的前提下把内容捞回来。切片切得太粗一个片段混入多个主题向量表示语义模糊切得太细单个片段信息量不足检索回来也喂不饱大模型。这个平衡是调试 RAG 最核心的手感。3.1 文档加载时的清洗策略文档加载看似简单其实需要做不少预处理。以我处理的运维手册为例去除页眉页脚。PDF 转出来的文本里每一页都带着手册名称和页码这些噪音如果不处理会被切进片段里干扰向量表示。合并断行。PDF 里的换行经常把完整句子切断比如“系统在每日 03:00 执行\n备份任务”直接切片会让语义断裂。简单做法是把行尾的换行符替换为空格在段落级别重新分段。提取表格。如果文档里有配置参数表格纯文本提取会把行列关系打乱。我处理这类内容时会把表格转成“参数名值”的键值对形式再入库检索效果比原始表格文本好很多。以下是一个清洗 PDF 文本的简化示例核心思路是先按页拆分再做页内清理最后合回全文from langchain_community.document_loaders import PyPDFLoader loader PyPDFLoader(./ops_manual.pdf) raw_docs loader.load() def clean_text(page_text: str) - str: # 去掉页眉页脚这里按实际文档特征处理 lines page_text.split(\n) filtered [line for line in lines if 第 not in line and 机密 not in line] # 合并断行 merged .join(line.strip() for line in filtered) return merged cleaned_docs [ doc.replace(metadata{source: ops_manual.pdf, page: i 1}, page_contentclean_text(doc.page_content)) for i, doc in enumerate(raw_docs) ]3.2 切片参数怎么调chunk_size 与 chunk_overlapLangChain 里最常用的切分器是RecursiveCharacterTextSplitter它的工作方式是从大往小递归匹配分隔符优先按段落切再按句子切尽量保证每个片段在语义上是完整的。核心参数有两个chunk_size片段的最大字符数。chunk_overlap相邻片段之间的重叠字符数。重叠的目的是避免某个关键内容刚好被切在边界上导致两边都不完整。我习惯的初始参数是chunk_size500chunk_overlap50。这个组合对中文技术文档比较稳妥平均每段能覆盖 2~3 个要点同时通过重叠保留跨段上下文。如果你处理的文档是问答对形式或者每段很短可以把 chunk_size 降到 200~300如果是长章节式论述可以提到 800但不要超过 1000否则向量表示会被稀释。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ], ) chunks splitter.split_documents(cleaned_docs)separators 的顺序也很有讲究它代表切分的优先级。中文场景下我把中文标点加进分隔符序列比起默认只按英文换行和空格切分能显著减少句子被拦腰截断的情况。这里要提醒一个容易踩的坑chunk_size 是按字符数计的不是按 token 数计的。中文场景下一个 GBK 编码的汉字约占 2 个字符500 字符大概折合 250~350 个 token实际送入模型时还要叠加用户问题和提示词模板如果超长就白费了。我第一次调参时没注意这个区别把 chunk_size 提到 2000结果一个片段就有 1000 多 token检索回来的三个片段加上模板直接撑爆了上下文窗口。切完片之后建议打印几条样本看看边界是否自然。如果发现大量句子被切断、语义明显不完整那就说明 chunk_size 偏小或者 separators 顺序不对需要调大或者调整优先级。4. 向量化与向量库选型逻辑和实测对比4.1 嵌入模型怎么选本地模型还是 API 模型嵌入模型的作用是把文本映射成稠密向量语义相近的文本在向量空间里距离更近。选嵌入模型时我关注三个维度语义效果、维度大小、成本与隐私。API 类嵌入模型我用过 OpenAI 的text-embedding-3-small1536 维效果稳定而且对中文支持在近几年进步明显。它的优点是省心不需要自己维护模型缺点是数据要过 API对敏感文档不合适而且每次调用有费用和延迟。本地嵌入模型我用的比较多的是BAAI/bge-m3和moka-ai/m3e-base都是中文友好型维度控制在 1024 以内检索效果在大多数场景下够用且完全离线运行。这里有一个值得记住的结论嵌入模型不是越大越好。维度越高向量存储和相似度计算的开销越大但语义区分度并不会线性提升。一旦数据量到百万级3048 维的向量和 768 维的向量在检索延迟上的差距就很明显了。我在十万级文档片段上实测bge-m31024维和 text-embedding-3-small1536维的检索准确率差距在 1 个百分点以内但前者的向量库大小和检索耗时都更低。所以面向中文场景本地小模型是性价比之选。4.2 向量库选择从 Chroma 到生产级方案开发阶段我推荐 Chroma理由只有一个字快。pip install chromadb之后直接能用数据落在本地目录不需要额外部署服务非常适合反复试验切片参数和嵌入模型。代码层面用from_chunks直接写入即可from langchain_community.vectorstores import Chroma from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings(model_nameBAAI/bge-m3) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, ) retriever vectorstore.as_retriever(search_kwargs{k: 5})等你把 RAG 链路调顺了再考虑替换成 Milvus 或者 pgvector 也不迟。生产环境的核心考量是数据规模、并发量、权限管理以及是否需要和现有数据库打通。Chroma 在十万级片段以下表现没问题再往上Milvus 这类分布式方案的优势才体现出来。有一点务必注意切换向量库时向量数据本身是可以迁移的但嵌入模型必须保持不变。否则历史向量是用旧模型生成的新检索向量是用新模型生成的度量方式已经不一致检索质量无从谈起。这也是为什么我建议把“嵌入模型版本”纳入向量库元数据管理一旦更换模型最好重新全量入库。4.3 向量检索的原理与局限向量检索的原理说白了就是最近邻搜索把用户问题转换成向量后在向量库里找余弦相似度最高的 K 个片段。LangChain 默认的similarity检索方式就是干这个的。但向量检索不是万能的它的局限在于只捕捉语义相似度不考虑关键词匹配。比如用户问“服务器宕机怎么处理”如果知识库里明确写了“主机停机恢复流程”但措辞相差较大向量检索能捞回来反过来如果知识库里有大量文档都聊运维前 K 个结果可能全是泛泛而谈的段落真正包含操作步骤的片段反而排在后面。这个问题单靠调 TopK 解决不了需要混合检索加重新排序这是第五节的主题。5. 检索策略调优从 TopK 到混合检索和重排序5.1 TopK 的影响太少漏信息太多灌噪音检索返回的片段数量直接影响回答质量。K 值设得太小比如 2会漏掉关键细节K 值设得过大比如 10又会把不相关的内容塞进上下文模型反而被干扰。我做过一组对比实验同样的知识库、同样的问题K3 时回答遗漏了故障恢复的超时阈值K5 时回答完整K8 时模型开始参考一些相关性不高的段落回答里出现模棱两可的措辞。就我的经验中等长度文档场景下 K4~6 是比较稳的区间具体取多少要看 chunk_size。chunk_size 偏大的情况下每个片段信息量大K 可以小一点chunk_size 偏小则 K 适当加大。5.2 混合检索融合关键词与向量的互补优势混合检索的思路是同时跑多条检索通道再合并结果。最典型的是“稀疏检索BM25 稠密检索向量”的组合。BM25 擅长精确匹配专业术语、型号编号、缩写向量检索擅长理解同义改写和语义关联。比如查询“GPU 温度过高”BM25 能确保“温度”相关的原文不被漏掉向量检索能召回“显卡过热”这类同义表述。两条通道合并后去重召回覆盖面显著提升。在 LangChain 里实现混合检索有两种方式。简单一点的是用EnsembleRetriever把 BM25 检索器和向量检索器组合起来设置权重。下面这个例子仅供参考from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25_retriever BM25Retriever.from_documents(chunks) # 基于原始文本切片 bm25_retriever.k 5 vector_retriever vectorstore.as_retriever(search_kwargs{k: 5}) ensemble_retriever EnsembleRetriever( retrievers[bm25_retriever, vector_retriever], weights[0.3, 0.7], )权重设置上我实测 0.3/0.7 或 0.4/0.6 都行向量检索的贡献通常更大所以权重稍高。如果你的知识库充斥着大量型号、编号这类精确词可以适当调高 BM25 权重。5.3 重排序召回后的最后一道质检混合检索把候选集扩大了但排序质量不一定好。两个通道合并之后相关片段和不相关片段混杂在一起前 K 名里可能混入噪音。这时候需要重排序模型Reranker做一次精排。Reranker 的思路和嵌入模型完全不同它不是把文本映射成独立的向量而是把“查询-文档对”输入模型直接输出相关性分数。这个方式计算量更大但精度远高于向量相似度。LangChain 生态里可以接入CohereRerank或者各种本地 Reranker我用过的中文场景表现不错的是bge-reranker-base。from langchain_community.cross_encoders import HuggingFaceCrossEncoder from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker reranker HuggingFaceCrossEncoder(model_nameBAAI/bge-reranker-base) compressor CrossEncoderReranker(modelreranker, top_n3) compression_retriever ContextualCompressionRetriever( base_compressorcompressor, base_retrievervectorstore.as_retriever(search_kwargs{k: 10}) ) docs compression_retriever.get_relevant_documents(服务器宕机怎么处理)这个过程叫作两阶段检索第一阶段用轻量召回扩大候选集比如取 Top 10第二阶段用重排序精挑细选比如取 Top 3。代价是每次查询多一次模型推理延迟会增加几百毫秒。如果对延迟非常敏感可以把召回数控制在 7~10 个并且只在检索质量确实有问题时才引入重排序。我的经验是重排序是检索质量提升最立竿见影的一步效果远超调 TopK 或换嵌入模型。6. 生成环节提示词设计与答案格式控制检索做得再好最后一步还是要靠大模型把素材组织成回答。这一步有两个关键点提示词模板设计以及如何让模型严格基于检索内容作答。6.1 提示词模板的写法我的提示词模板经过多次迭代核心规则只有三条明确告诉模型只能根据提供的上下文回答不知道就直说。要求在回答中引用片段编号或来源方便用户追溯。规定回答格式比如先结论后细节或按操作步骤编号输出。from langchain.prompts import ChatPromptTemplate prompt ChatPromptTemplate.from_messages([ (system, 你是一个严谨的技术支持助手。请仅基于以下资料回答问题。如果资料中没有相关信息请明确说明资料中未找到相关内容不要自行编造。回答时引用来源片段编号[来源1]、[来源2]。), (human, 相关资料\n{context}\n\n问题{question}), ])你可能觉得这段话朴素但效果比默认的“请基于上下文回答”强很多。特别是那句“不要自行编造”对减少幻觉的作用非常明显。6.2 如何让模型真正“只读”检索内容很多 RAG 项目最后效果不好其实不是检索问题而是模型没被约束住。大模型预训练阶段掌握了海量通用知识看到问题后总是忍不住调用自己的记忆来补全答案。要扭转这个习惯有两个技巧在 system 提示里明确“本场景下你的所有知识均无效唯一信息源是资料片段”态度要强硬模棱两可的措辞不如直接下命令。在资料不足时期望模型输出“资料未覆盖该问题”而不是强行回答。这样虽然牺牲了一部分对话流畅度但换来了可靠性。我实际测试时发现即使用了检索增强模型偶尔还是会把通用常识混进回答比如资料里没写的内容却被当成了常识补充。加上强约束后这种“脱轨回答”大幅减少。如果你的场景对准确性要求很高建议在生成后加一层校验检查回答中的关键论断是否能在检索片段里找到依据找不到就标记为存疑走人工审核流程。这一步属于工程层面的兜底但效果非常扎实。7. 生产化改造记忆、流式输出与回调追踪跑通基础链路后要支撑真实用户使用还需要处理三个工程问题。7.1 多轮对话中的检索上下文处理真实问答不可能每轮都是独立问题用户经常说“那如果超时了怎么办呢”这句话脱离上文根本没法检索。LangChain 里有多轮对话和检索结合的方案但最简单的处理方式是把最近一两轮对话压缩并重写成一个独立问题再拿这个重写后的问题去做检索。LangChain 里的create_history_aware_retriever做的就是这个事。不过要提醒一句并不是所有场景都需要多轮检索。如果你做的是单轮知识库问答比如“查一下某设备参数”引入多轮处理反而可能把简单问题搞复杂。我的建议是明确产品定位是助手型多轮对话还是查询型单轮问答据此决定是否引入历史会话改写。7.2 流式输出先吐字再完善大模型生成完整回答往往要几秒到十几秒如果接口是等待全部生成完才返回用户会明显感到卡顿。流式输出可以先把第一个 token 返回让用户感觉系统“有反应了”。在 LangChain 里通过stream方法逐块取输出async for chunk in chain.astream({context: context, question: question}): yield chunk.content前端可以做“打字机”效果。这个体验优化在 To B 场景里同样重要尤其是有时候检索和生成加起来要十几秒没有流式输出用户会以为系统挂了。7.3 回调机制出问题时能定位到环节我遇到过一次典型问题回答内容明显不对但不知道是检索没召回正确片段还是模型没按上下文回答。如果没有日志追踪排查只能靠猜。LangChain 提供了回调机制可以在链路的关键节点挂载日志收集from langchain.callbacks import StdOutCallbackHandler handler StdOutCallbackHandler() chain.invoke({context: context, question: question}, config{callbacks: [handler]})在开发阶段我建议把每个检索到的片段及其相似度分数一并打印出来。这样你可以直观看到某次回答错误是因为检索回来的片段本身不相关还是模型没有遵循提示词。这个能力在调试阶段极大节省了时间。8. 踩坑记录这些细节容易让人卡住最后梳理几个我在 LangChain RAG 项目中真实踩过的坑都是网上文档较少提及的细节。8.1 嵌入模型不一致导致检索结果离奇有一次我在调试时把HuggingFaceEmbeddings的默认模型换成了另一个但忘了清空旧的向量库。结果新问题查询时检索返回的片段相似度都特别低而且排序毫无规律。排查了半天才发现是新旧向量混在同一个集合里嵌入空间不匹配。这些问题在日志里不容易暴露但只要检索效果突然变差第一步就应该怀疑嵌入模型是否一致。8.2 metadata 丢失与引用溯源失效文档加载时我习惯在 metadata 里附带页码和来源文件名切片后这些字段会随 Document 对象一起保留。但如果你用了某些转换器或手动重组文档对象metadata 可能会被丢掉最后回答里就无法引用来源。建议每次切片后打印一条样本确认 metadata 还在。8.3 中文路径与编码问题PyPDFLoader 对中文路径的兼容性一般我在 Windows 环境下遇到过加载失败的情况。解决办法很简单先把文件复制到纯英文路径下再加载不要浪费时间折腾权限或编码配置。文本文件读取时注意用 UTF-8 编码避免乱码入库。8.4 提示词里的 context 变量被截断LangChain 在把文档列表拼进提示词模板时默认会把所有内容直接塞进去不会自动裁剪。如果你设置了大 K 值和较大的 chunk_size很容易把模板塞爆。要解决这个问题一方面控制 K 值和 chunk_size另一方面也可以在后端做一次 token 计数超限时手动减少输入片段数。最后再分享一点我的个人体会。RAG 的调优是个典型的“先整体后局部”过程一开始先把链路跑通不要急于追求某个环节的极致链路稳定后再用控制变量的方式一次改一个环节、对比效果。我自己迭代的顺序是先调整切片参数再优化检索策略最后才引入重排序。每一步改动都保存一个版本记录当时的参数和评测结果这样回看时能清楚知道哪个改动真正起了作用。这样的习惯比收藏多少教程都管用。
阅读完成 · 觉得有帮助?
咨询建站