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

LangChain搭建RAG知识库实战:从文档加载到检索生成全流程

LangChain搭建RAG知识库实战:从文档加载到检索生成全流程 ★ FEATURED ARTICLE
用LangChain搭RAG知识库我最近一个月至少被问了五遍。大多数人的第一反应是把公司文档一股脑塞进大模型的上下文窗口让模型“记住”这些内容——这个思路听着合理真跑起来要么上下文爆炸要么回答串味。直到你把RAG检索增强生成这套流程落地问题才算真正解决。这篇文章是LangChain快速入门的第三篇我把一次完整的知识库搭建过程拆开来讲文档加载、文本切分、Embedding向量化、向量库存储、检索召回、Prompt组装、生成回答全链路跑通最后附上我实测中踩过的一些坑和排查思路。适合已经会跑LangChain基础LLM调用、正准备做知识库问答项目的同学参考。1. 先弄清RAG的机制它不是在“训练模型”而是在“检索拼接”很多人一听“知识库”就以为要微调模型其实RAG走的是另一条路模型参数一动不动靠的是每次提问时从外部索引里把相关资料捞出来拼进Prompt再让模型基于这些材料作答。你可以把它理解成一个“先查资料、再写报告”的助手而不是一台被灌输了知识的考试机器。1.1 一次问答背后完整跑过的四个阶段RAG的标准流程可以拆成四个阶段我按先后顺序说清楚索引Indexing把知识文档切分成小块逐块算出向量表示写入向量库。这一步是离线的可以在服务上线前批量完成文档更新时只需要重跑对应部分。检索Retrieval用户提问后先把问题也转成向量然后在向量库里做相似度搜索召回最相关的几个文本块。注意这里检索的单位是“块”不是整篇文档。增强Augmentation把召回到的文本块和用户问题一起放进Prompt并在Prompt里明确告诉模型“以下内容来自知识库资料”。生成Generation大模型只负责基于给定的上下文组织语言、回答问题不再依赖它自己的“记忆”。用个生活化的类比向量库相当于档案室的索引柜。以前的做法是把档案室所有文件都搬到你的办公桌上让你自己翻RAG的做法是你先去索引柜按关键词找出最相关的三五页再让助手只根据这几页写答复。省时省力关键是过滤掉了无关信息。1.2 上下文窗口再大也不该把文档全塞给模型现在模型上下文窗口普遍不小但“塞得下”不等于“效果好”。我实际跑下来有三个直接问题成本是线性涨的。一份几十万字的资料每次提问都全量带进PromptToken费用和接口延迟都让人肉疼。噪音会干扰判断。文档里真正和当前问题相关的内容常常只有几百字其余大量无关段落混进来后模型回答的准确率反而下降有时候还会把不同段落的信息错误地拼在一起。更新很不灵活。文档一改Prompt里的静态内容就得跟着改而RAG只需要把对应文档重新切分、重新索引其他部分完全不受影响。所以长上下文解决的是“能不能读完”RAG解决的是“该读哪些”两者不是替代关系。1.3 什么场景该上RAG什么场景别硬上我踩过一次教训把某项目的统计需求也塞进RAG结果模型一本正经地给了一个完全错误的汇总数字。后来拆开来想RAG适合的是“查内容、找依据”类问题不是“做计算”。具体来说适合的场景包括内部产品手册问答、故障排查FAQ、历史工单沉淀、制度流程查询。这类问题的答案都能在文档里找到明确依据检索召回后由模型整理表达即可。不太适合的场景包括需要跨多条记录做聚合计算的应该接结构化查询或写代码计算需要多跳推理的问“A文档里提到的那个项目在B文档里状态如何”单轮检索很难串起来以及数据量很小且高度固定的场景直接写进System Prompt反而更简单。2. 动手前的选型Embedding、向量库与切分参数怎么定在写第一行代码之前有三个选择会直接决定知识库效果的天花板Embedding模型、向量库、文本切分策略。这一步选错了后面调Prompt、调检索都是白费力气。2.1 Embedding整个知识库的“度量衡”Embedding把一段文字映射成一个高维向量语义相近的文字在向量空间里距离也近。RAG的检索质量几乎完全取决于这个映射是否贴合你的数据。我选型时主要看三点语言支持。中文场景就要选对中文理解好的模型用面向英文优化的模型做中文知识库召回来的内容经常对不上问题。维度与成本。云端接口向量维度普遍在千维左右本地开源模型也可以选不同维度。维度高不一定更好还要考虑向量库的存储和检索开销。一致性最重要。入库和查询必须用同一个Embedding模型否则两边向量不在同一个语义空间相似度数值毫无意义。我见过同事入库用A模型、查询换成了B模型检索结果完全不可用排查了半天才发现是这里的问题。实际操作上如果你有稳定的云端Embedding接口直接用官方封装最省事如果要本地化部署社区里也有不少中文效果不错的开源模型比如BGE系列。我的建议是先用一个现成的别纠结等召回效果明确有问题再换。2.2 向量库从本地小规模到生产级向量库负责存储向量并按相似度做召回。选型可以按规模阶段来不必一步到位。我对三类方案的感受本地轻量方案如Chroma安装简单、启动快数据持久化到本地目录和LangChain的配合最顺适合个人项目和中小规模知识库。我的入门项目一直用它。内存索引方案如FAISS检索速度非常快适合批量构建索引、服务重启后重建的场景更偏“索引库”而不是“数据库”没有完整的数据管理能力。生产级远端向量库适合团队协作、数据量大、需要权限管控和水平扩展的场景但部署和维护成本高初期完全没必要上。方案存储方式适用规模部署成本LangChain接入难度Chroma本地持久化中小规模低低FAISS内存/本地文件中大规模批量索引低低远端向量库独立服务大规模、多端共享高中我的建议第一版用Chroma就够了跑通之后再考虑迁移。数据量到百万级向量、或者多人同时访问时再评估远端方案。2.3 切分参数chunk_size和chunk_overlap不是随便填的文本切分是RAG里最容易被忽视、却最能影响召回效果的一步。切得太小一个完整场景被截成半截话模型拿到的上下文不完整切得太大一块里混了好几个主题相似度被稀释召回的精准度反而下降。中文场景我常用的范围是300到800字符说明文可以取大一些操作步骤、故障排查这类强流程内容取小一些。chunk_overlap一般取chunk_size的10%到20%作用是给被切断的句子留一条“尾巴”避免关键信息正好落在切割点上。另外如果文档本身有结构Markdown标题、HTML层级优先用按标题层级切分的专用切分器比纯按字符数切更符合阅读逻辑。我一开始用固定字符切问答效果一般改成按章节切之后召回质量明显提升。3. 入库管线把PDF与Markdown变成向量库里的条目选型定下来之后就可以写入库代码了。入库是整条链路里最机械但最不能出错的一步文档能不能被正确加载、切分、写入直接决定后面检索有没有料可查。3.1 文档加载Loader是整个流程的入口LangChain把不同格式的文档封装成了统一的Loader加载后返回的是Document对象列表每个对象包含page_content正文和metadata来源、页码等元信息。metadata我会特意多留一些后面做过滤和溯源都要靠它。先装齐基础依赖pip install langchain langchain-community langchain-openai langchain-chroma langchain-text-splitters现在LangChain的生态拆成了多个包导入路径和早期大全包不一样照着老教程抄经常会报ModuleNotFoundError装包时最好按上面的列表一次装齐。from langchain_community.document_loaders import PyPDFLoader, TextLoader, DirectoryLoader # 单个PDF文件 loader PyPDFLoader(docs/产品手册.pdf) docs loader.load() # 批量加载目录下所有Markdown文件 loader DirectoryLoader( pathdocs, glob**/*.md, loader_clsTextLoader, ) docs loader.load()注意PDF加载出来是“按页”的一页一个Document页面里如果跨页段落会被拆开所以要留给后面的切分器处理。如果用DirectoryLoader做批量加载尽量把不同类型文档分目录放每种格式用对应的Loader混在一起容易出编码问题。3.2 文本切分先按结构切再按长度兜底我推荐用RecursiveCharacterTextSplitter它按分隔符优先级递归切分先尝试按段落切段落太长再按句子切句子还长再按标点切。这样能最大限度保留语义完整的段落和句子而不是生硬地按字符数砍。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ], ) chunks splitter.split_documents(docs)separators这个列表的顺序就是切分的优先级LangChain会先尝试用最前面的分隔符切切不动再依次往后用更小的分隔符。中文场景一定记得把句号和逗号加进去否则长度超过chunk_size的句子会被硬切切出来的块语义是残的。这样切出来的块多数情况是一段完整的论述或一个完整的步骤后面Embedding和召回都会舒服很多。3.3 向量化并写入向量库一条代码搞定用Chroma.from_documents可以直接把“切块 Embedding 写入向量库”三步合成一步from langchain_community.vectorstores import Chroma from langchain_openai import OpenAIEmbeddings embeddings OpenAIEmbeddings( modeltext-embedding-3-small, # 换成你的Embedding接口实际支持的模型名 ) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, )persist_directory指定的是向量库的持久化目录跑完之后这个目录里会存下向量数据和元信息下次直接加载复用不用重新算一遍向量。这一步很关键尤其Embedding接口按调用量计费时重复入库等于重复花钱。3.4 增量更新与去重知识库早晚要面对的问题知识库不是一次建完就不动了。文档更新、新增内容都需要往向量库里追加。追加本身很简单vectorstore Chroma( persist_directory./chroma_db, embedding_functionembeddings, ) vectorstore.add_documents(new_chunks)但有个坑直接add_documents会把同一份文档重复写入检索时相同内容被召回多份既浪费Token又干扰判断。我现在的做法是给每个文本块生成一个稳定ID内容哈希加上来源路径写入前先检查ID是否已存在存在就跳过。Chroma的from_documents和add_documents都支持传入ids参数配合一个本地set做去重就能解决。一个完整的入库脚本大概是加载文档 → 切分 → 生成稳定ID → 过滤已存在的块 → 写入向量库。我建议把入库做成一个独立脚本用定时任务或文档变更钩子触发不要让问答服务每次启动都重建索引。4. 检索链路召回质量才是知识库效果的分水岭入库做完知识库已经“有货”了。但用户提问时到底能不能把最相关的内容捞出来取决于检索这层。我见过不少项目向量库建得很好Prompt写得也漂亮最后效果稀烂问题几乎都出在检索配置上。4.1 先从向量库里把“相关段落”捞出来最基础的用法是把向量库转成retriever然后调用retriever vectorstore.as_retriever( search_kwargs{k: 5}, ) docs retriever.invoke(服务器启动失败时怎么排查)返回的docs就是召回的文本块列表。调试阶段我更推荐用similarity_search_with_score它会同时返回相似度分数方便你感知检索结果靠不靠谱results vectorstore.similarity_search_with_score( 服务器启动失败时怎么排查, k5, ) for doc, score in results: print(fscore{score:.4f}, doc.metadata.get(source), doc.page_content[:40])这里有个新手容易搞混的点不同向量库返回的score含义不一样有的是余弦相似度越大越相关有的是距离越小越相关。Chroma返回的是距离所以score越小越相关。别拿一套经验套所有库先打印出来看看方向再说。4.2 k值、相似度阈值与metadata过滤检索配置里三个高频参数我按调参顺序说k值召回几个块。我一般从4到6起步。k太小会漏掉相关内容k太大则把无关段落也带进来既稀释注意力又烧Token。相似度阈值可以设一个下限低于它时认为知识库里没有相关内容提示用户换种问法。但要注意语义相似度的绝对值在不同文档集合间并不稳定阈值不宜设太死我更倾向于用它做“无结果提示”而不是硬过滤。Metadata过滤如果文档分了类可以按来源过滤减少干扰。比如只查产品手册retriever vectorstore.as_retriever( search_kwargs{ k: 5, filter: {source: docs/产品手册.pdf}, } )这个过滤在文档多、主题杂的时候特别有用相当于先圈定一个科室再在里面找答案。4.3 简单但有效的召回增强MMR和查询改写召回质量不够时先别急着换Embedding两个便宜方案值得先试。第一个是MMR最大边际相关检索。普通相似度检索召回的5个块可能内容高度重复MMR在保证相关性的同时让结果尽可能多样覆盖不同角度retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 5, fetch_k: 20}, )fetch_k是先把候选扩大到20个再从中挑5个兼顾相关性和多样性。第二个是查询改写。用户提问往往口语化、省略化和文档里的书面语差距很大。比如用户问“登录不上怎么办”文档里写的可能是“用户认证失败”。这时候可以先让一个小模型把问题改写成更贴近文档表述的形式再去做向量检索。LangChain里的MultiQueryRetriever做的就是类似的事情一次生成多个问题变体分别检索再合并。实测对口语化查询的提升很明显代价只是多几次Embedding调用。5. 回答链路把召回结果组装成大模型的“可读材料”检索只是把原材料找出来真正的回答要让大模型基于这些材料生成。这一层的核心是把Prompt组织好让模型明确“你的依据是这几段资料”而不是自由发挥。5.1 用RetrievalQA把一条链路串起来LangChain最经典的组装方式是用RetrievalQA它把检索结果注入Prompt、再调大模型生成这一步封装成了统一接口from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI llm ChatOpenAI( modelgpt-4o-mini, # 换成你实际能访问的模型即可 temperature0, ) qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrieverretriever, return_source_documentsTrue, ) answer qa_chain.invoke(服务器启动失败时怎么排查) print(answer[result])我建议上线调试阶段一定把return_source_documents设为True这样每次回答都能看到模型用到了哪些文档、哪些片段。既方便你判断召回是否正常也方便给用户展示依据来源是降低“AI胡说”信任成本的关键设计。5.2 chain_type怎么选stuff、map_reduce、refineRetrievalQA支持三种chain_type区别在于怎么处理召回的多个文本块stuff把所有召回块一次性拼进Prompt让模型一次性阅读。实现最简单信息最完整是默认首选。缺点是召回的块多、块长时可能超上下文限制。map_reduce先让模型对每个块分别整理答案再把多个答案汇总成最终回答。适合召回块多、原文长的场景但分散处理容易丢细节而且多了一整轮LLM调用。refine从第一块开始生成答案然后用后面的块逐块修正、补充之前的结果。效果通常比map_reduce好但调用次数也最多速度最慢。我的选择逻辑很直接默认stuff上下文塞得下就永远别折腾真要处理超长召回先考虑减小chunk_size和k值比换chain_type更划算。5.3 定制Prompt压制幻觉的关键一步RetrievalQA自带默认Prompt但我会自己写一个因为默认Prompt对“资料里没有怎么办”约束得不够。实测在我的知识库场景里下面这个模板把胡说八道的比例压下去一大截from langchain_core.prompts import PromptTemplate template 请基于下面的资料回答问题。 资料 {context} 问题 {question} 要求 1. 只能依据资料中的信息回答资料中没有的内容直接回答“资料中未找到相关内容”不要自行推测 2. 引用具体数据或步骤时标注对应的来源文档 3. 用中文回答语言简洁清晰。 prompt PromptTemplate( templatetemplate, input_variables[context, question], ) qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrieverretriever, chain_type_kwargs{prompt: prompt}, return_source_documentsTrue, )这里{context}就是检索结果注入的位置。和很多人的直觉相反最有效的约束不是“你要回答正确”而是“没找到就直说”。模型一旦被允许承认不知道编造答案的概率会明显降低。5.4 多轮会话怎么办先把问题“去历史化”RetrievalQA本身是无状态的每次提问都是独立检索。多轮对话里用户很可能问“那它有没有别的办法”这里的“它”指代上文的实体直接拿去检索基本召不回来。两个常用方案一是用记忆组件把对话历史传给LLM先改写出一条独立于历史的完整问题再拿改写后的问题去检索二是简单粗暴地让前端把最近几轮对话拼进Prompt让模型自己理解意图。这属于进阶改造第一版可以先不做但要心里有数否则知识库Demo一接入聊天窗口就露馅。6. 实测中的三个主要坑召回、幻觉与性能最后把我实际跑知识库项目时踩过的坑集中列一下按出现频率排序。这些问题不踩一遍很难从文档里学明白看到了就能少走好几周弯路。6.1 召回质量差的排查顺序先别怪Embedding召回不对时第一反应往往是“换个Embedding模型”但多数情况下问题不在Embedding。我现在的排查顺序是先看切分。把召回的文本块原样打印出来如果发现是一截半句话、或者一个场景被拦腰截断优先调chunk_size和chunk_overlap或换按标题切分的切分器。再看查询词。用户口语和文档书面语差距大先做查询改写再检索很多时候比换模型管用。再看一致性。确认入库和查询用的是同一个Embedding模型这个低级错误真的会犯。最后才换Embedding。换模型后建议保留一个小的对比集量化前后召回变化别凭感觉判断。我遇到过最典型的一次用户问“登录失败”召回来的却是讲“权限配置”的文档因为两段里都提到了“账号”。后来把查询改写成“登录失败 错误码 提示信息”召回结果立刻正常。这类问题调切分和改写就能解完全不用动模型。6.2 幻觉和错误引用的处理先查材料再改Prompt知识库回答不准很多人第一时间归咎于“模型幻觉”但我实测下来相当一部分“幻觉”其实是检错了材料——召回回来的块本身就不相关模型只能基于错误材料一本正经地输出。所以遇到答非所问先看return_source_documents返回的源文档判断是召回问题还是生成问题再对症下药。生成层面的幻觉我主要靠两招压制一是Prompt里明确“资料中没有就直说”给模型一个合法且安全的退路二是在回复里带上来源让用户能看到依据。别指望一次Prompt就完全消除幻觉RAG系统是一个持续迭代的系统把评估和溯源做成标配比追求“零幻觉”更实际。6.3 性能与成本不要每次服务启动都重建索引性能优化里最容易被忽视的一条是向量库要复用不要每次启动问答服务都重新Embedding一遍。我见过有人把Chroma.from_documents写进Web服务启动代码每次重启都要等好几分钟、烧掉一大笔接口调用费。正确做法是入库单独跑问答服务只加载持久化目录vectorstore Chroma( persist_directory./chroma_db, embedding_functionembeddings, ) retriever vectorstore.as_retriever(search_kwargs{k: 5})另外几个实用的优化点入库时用批量Embedding一次传多段文本比逐条调用快得多。对高频的查询片段做缓存相同问题短时间内的检索结果直接复用。如果并发访问量上来了本地Chroma要注意锁和并发写问题读多写少基本可以接受写多就要考虑换远端向量库。LLM层先选小模型跑通效果再按需升级第一版别一上来就用最贵的大模型。性能问题的本质是流程设计问题把入库、检索、生成三个环节解耦每层都能单独扩展和优化。最后说点个人体会。我给知识库项目做验收时第一件事不是跑Demo而是手工整理二三十个典型问答对做成评估集每次调整后都拿它过一遍比对模型换参数可靠得多。另一个小技巧是入库时把来源文件名和章节写进metadata调试时用similarity_search_with_score打印出来一眼就能看出召回是不是跑偏这个习惯帮我省了不少排查时间。RAG的入门门槛其实不高但把每个环节的细节都摆弄明白才是它真正值钱的地方。
阅读完成 · 觉得有帮助?
咨询建站