资讯详情

LangChain实战:构建RAG知识库问答系统全流程

📅 2026/10/20 20:15:43 | 华诺云谱 👁 阅读
LangChain实战:构建RAG知识库问答系统全流程
让一个通用大模型老老实实只在指定资料里找答案这件事我从一开始做就翻过车。当时还是直接把问题丢给LLM让它基于一份内部产品手册回答客户问题结果它发挥得很愉快给出的答案里一半是手册里根本没有的内容另一半年份都对不上。后来我才意识到根本不是模型不够聪明而是我压根没给它“只准看哪些材料”的边界。RAG这个思路就是来解决这个问题的——把资料先喂进一个可检索的索引回答问题时先把相关资料捞出来再让LLM只基于这些捞出来的片段作答。这篇文章是“RAG在LangChain中的实现”系列的第一篇我会用LangChain把一个最基础的RAG问答Demo从头到尾搭起来包含组件选型、核心代码、参数调优和踩坑记录目标是让你能照着复现并且知道每个环节为什么这么做。如果你正要处理内部知识库问答、产品文档答疑、私有数据问答这类“必须限定上下文”的场景这篇内容可以直接当脚手架用。1. 为什么需要RAGLLM的“自由发挥”问题1.1 直接问LLM的三个大坑先看一个反例。我早期的做法很天真把PDF手册喂给模型的上下文窗口然后直接问“这款设备支持的最大电压是多少”。结果模型回答了一个看起来非常专业的数值但这个数值来自它训练数据里的另一款产品而且官方手册里明确写了不同的参数。这种问题根本不是个例而是直接裸调LLM必然遇到的三个坑。第一是幻觉。LLM的本质是预测下一个token它没有“查证”能力训练时见过的知识会被自信地拼接到答案里。第二是知识截止。模型训练完成那一刻之后的资料它完全不知道你给它一份昨天刚更新的制度文件它只会按老版本回答。第三是审计困难。就算模型答对了你也无法追溯它依据的是哪一段原文说不清楚答案来源在企业场景里就难以验收。RAG的核心思想其实不复杂先建一个资料库回答问题时把问题丢进去做检索把最相关的那几段原文捞出来再连问题一起塞给LLM明确告诉它“只能根据这些原文回答”。这样模型的知识范围被硬生生限定住了幻觉被大幅压缩而且每句话都能对应到原文出处可审计性也解决了。1.2 RAG的核心流程先检索后生成把RAG拆开看就是一个清晰到可以背下来的链路加载文档、切分文档、向量化、存向量库、检索、组装提示词、LLM生成。资料进来之后不是整个扔给模型而是切成块每块变成向量用户提问时把问题也变成向量在向量库里做相似度搜索取出最相近的几块最后把这几块原文放进prompt让模型在限定材料里作答。打个比方这就像考试时老师先发给你一张指定参考页的索引你按图索骥把相关篇章翻出来放在桌上再开始答卷。你不需要把所有参考书背下来但你的答案必须引用桌上这几页的内容。RAG做的就是把“指定参考范围”这件事程序化。1.3 什么场景适合RAG什么场景不适合不是所有问答都要套RAG。我做过的项目里适合RAG的场景很典型企业内部知识库问答、产品使用文档问答、政策法规查询、客服FAQ、私有的非公开资料问答。它们的共同点是资料是固定的、相对静态的、必须严格以原文为准答案不能自由发挥。不适合RAG的场景也有。比如开放式创作你希望模型脑洞大开那就不要用检索来限制它再比如多轮闲聊需要长期记忆上下文这时该做的是维护对话记忆而不是每次都重新检索一堆资料。搞清楚边界很重要不然你会在错误的地方硬套RAG。2. LangChain中RAG的整体设计与组件选型2.1 RAG的五个核心组件LangChain把RAG流程封装成了一组可替换的组件每个环节都有对应的抽象接口。我最常用的是下面这几个组件角色常用选择说明文档加载器读取原始文件PyPDFLoader、TextLoader、CSVLoader把PDF、txt、csv等不同格式统一转成文档对象切分器把长文切成块RecursiveCharacterTextSplitter控制送入检索单元的粒度向量化模型文本转向量OpenAIEmbeddings、HuggingFace BGE决定相似度计算的语义基础向量库存储和检索向量FAISS、Chroma管理向量索引链/检索器串联流程RetrievalQA、LCEL把检索和生成组合起来组件化的好处是你可以分别换掉任意一个部件。比如今天觉得这个embedding模型效果不好换一个表达式就行不用改其他代码明天想从FAISS换到Pinecone也只替换向量库实例化的那一段。2.2 为什么选LangChain而不是从零手写其实RAG本身并不难手写也能写一个加载文档函数、一个切分函数、一个调embedding API的封装、一个向量库、再把检索结果拼进prompt。那为什么还要用LangChain我的理由很实际它把这些环节的边界统一了让代码在不同组件之间切换的成本极低而且它预置了prompt模板、文档格式处理、输出解析这些细节能少写很多兼容代码。但我也强烈建议不要只停留在“会用LangChain”层面。第一次搭的时候最好手写一遍核心链路搞清楚向量库返回的是什么、prompt里检索内容是怎么插入的。否则出了问题就跟玄学一样只能瞎猜。2.3 Embedding模型选型同一个文本不同向量差别巨大刚开始做的时候我犯过一个错误把英文专用的embedding模型拿来处理中文文档结果检索质量惨不忍睹。后来才真正理解embedding决定了“语义相似”这件事到底怎么衡量。中英文混排时需要选择对中文支持稳定的模型目前实际项目中我遇到过两类方案一类是收费但效果稳定、无需本地部署的API型Embedding模型另一类是本地可部署的开源模型比如针对中文优化的BGE系列。选择时主要看三点模型词表是否覆盖目标语言、向量维度是否满足后续存储要求、以及是否有成本或部署条件的限制。我现在的经验是中文场景优先用中文语料训练的模型或多语言模型不要拿纯英文模型硬跑。这个选型不值得省。2.4 向量库选型FAISS、Chroma、Milvus向量库这块我也横向比较过几个。FAISS适合本地验证和小规模应用内存索引加载慢但查询快简单直接。Chroma更偏轻量级应用自带持久化。Milvus是服务化方案功能全、支持分布式但部署成本高。参考表格如下向量库部署方式适合场景缺点FAISS本地文件Demo、小规模私密项目持久化管理弱Chroma本地文件/服务中小应用、快速原型高并发能力一般Milvus独立服务大规模生产环境运维成本高本篇的Demo用FAISS因为它的接口在LangChain里集成度高代码量最少能让你把注意力放在RAG流程本身。3. 实操从零搭建一个“受控问答”Demo3.1 环境准备安装依赖先把依赖装上。下面的库分别对应加载、切分、向量化、向量库和语言模型调用。pip install langchain langchain-community langchain-openai langchain-text-splitters faiss-cpu python-dotenv pypdf需要注意的是不同版本LangChain的包结构有调整早期很多类都在langchain主包里现在分类到了langchain-community、langchain-openai等子包。如果你安装的是最新版本导入路径一定要按我下面代码里的来否则会报模块不存在。3.2 第一步文档加载与切分假设你手头有一份产品手册product_manual.md先加载并切分。Loaders支持的格式很多这里用TextLoader读纯文本如果是PDF就用PyPDFLoader。from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader TextLoader(product_manual.md, encodingutf-8) documents loader.load() splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, ) chunks splitter.split_documents(documents) print(len(chunks)) # 看切出了多少块RecursiveCharacterTextSplitter是我默认首选的切分器因为它会按优先级递归找分隔符先尝试段落符、再找句号、逗号这样切出来的块尽量保持语义完整。相比之下按固定字符数硬切最容易把一句话从中间断开造成检索倒没问题但生成时上下文读着特别别扭。3.3 第二步向量化并存入向量库将切好的块交给embedding模型转成向量然后用FAISS建索引。from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS # 如果你用本地模型可以换成 HuggingFaceBgeEmbeddings只是初始化参数不同 embeddings OpenAIEmbeddings(modeltext-embedding-3-small) vectorstore FAISS.from_documents( documentschunks, embeddingembeddings, ) vectorstore.save_local(faiss_index)这里的核心动作是from_documents它遍历每一个chunk调用embedding模型生成向量再建索引。保存本地之后下次启动不需要重新向量化整个文档库。3.4 第三步组装检索器与受限回答链这是整篇文章的关键。先创建检索器再写一个“只许用给定上下文作答”的prompt最后把二者串成链。from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough retriever vectorstore.as_retriever(search_kwargs{k: 4}) prompt ChatPromptTemplate.from_template( 你是文档问答助手。请严格根据下面提供的资料片段回答问题。 如果资料片段中没有相关信息请直接回答“资料中未找到相关内容”不要自行推测。 资料片段 {context} 问题{question} 回答 ) llm ChatOpenAI(modelyour-model-name, temperature0) rag_chain ( {context: retriever, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() )这段代码里的链式写法是用LangChain的LCEL语法retriever会自动接收输入问题去向量库检索并把命中的chunk列表放进context字段。RunnablePassthrough原样把问题传给question字段。对初学者来说也可以改用RetrievalQA封装类但LCEL更透明出了问题你能看清数据怎么流动。3.5 第四步完整代码串联把上面几段拼成一个可运行的脚本from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_community.vectorstores import FAISS from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough # 1. 加载并切分 loader TextLoader(product_manual.md, encodingutf-8) documents loader.load() splitter RecursiveCharacterTextSplitter(chunk_size500, chunk_overlap80) chunks splitter.split_documents(documents) # 2. 向量化并建库 embeddings OpenAIEmbeddings(modeltext-embedding-3-small) vectorstore FAISS.from_documents(chunks, embeddings) # 3. 检索器与链 retriever vectorstore.as_retriever(search_kwargs{k: 4}) prompt ChatPromptTemplate.from_template( 你是文档问答助手。请严格根据下面提供的资料片段回答问题。 如果资料片段中没有相关信息请直接回答“资料中未找到相关内容”不要自行推测。 资料片段 {context} 问题{question} 回答 ) llm ChatOpenAI(modelyour-model-name, temperature0) rag_chain ( {context: retriever, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 4. 测试 result rag_chain.invoke(这款设备的最大输出电压是多少) print(result)运行之后你会看到模型基于检索到的片段给出答案。如果文档里没有这个参数模型会按prompt要求回答“资料中未找到相关内容”而不是自己编一个值。3.6 验证效果对比有RAG和没RAG的差别我做这个Demo时习惯同时跑两个版本一个走上面的RAG链另一个直接用裸LLM回答同一个问题。效果差异非常明显同一个关于设备参数的问题裸模型自信地给出一组数据RAG模型则老老实实引用手册里那句原文。这个对比建议你也做一下能直观感受到RAG“限定范围”到底限定了什么。4. 参数调优让回答既准确又不越界4.1 chunk_size与chunk_overlap怎么定这组参数直接决定召回颗粒度。切太小语义可能不完整切太大经常把无关内容混进同一段还浪费token。我的经验值参考如下chunk_size优点缺点适用场景200以下定位精准上下文可能缺上下文词汇查找、关键词型问答300-500兼顾语义完整性偶有信息割裂多数通用文档问答800以上保留完整段落召回粗、容易混入噪音长段落法律条款、制度文件chunk_overlap的作用是让相邻chunk之间保留交接处的信息防止一个完整句子正好被切分线一分为二。我一般设为chunk_size的10%-20%比如500字配80字。太小没效果太大则会让相邻块几乎重复同一内容被重复检索白白占上下文窗口。4.2 top_k怎么选不是越大越好top_k控制检索返回多少块。k太小可能漏掉真正相关的内容k太大不相关的段落混入模型反而被干扰。我在不同需求下的选择参考这几点答案是名词型、出处明确k选3足够需要综合多段信息才能总结k选4-6文档质量参差、噪音较多时反而要减小k防止污染。除了调k还可以用search_typesimilarity_score_threshold给检索设一个相似度阈值低于阈值的直接不上屏。这个机制在“宁可答不上来也不要答错”的场景里特别实用。4.3 temperature与prompt约束双重保险LLM参数里temperature直接决定生成随机性。RAG问答中我几乎一律设置为0最多不超过0.2否则同样的上下文会生成不同措辞甚至偶尔发散。温度是软约束更硬的约束在prompt里。我在生产项目中使用的指令模板是三层结构第一步声明角色第二步定义回答边界“只能使用context中的内容”第三步定义未知时的行为“资料中没有就明说没有”。这三层缺一不可尤其是第三层没有它模型大概率会强行编造一个“看起来合理”的答案。4.4 评价检索质量的土办法很多教程让你用精确率召回率评估但个人项目里面并没有标注好的评估集。我提供一个土办法人工准备10个问题逐个打印retriever.invoke(question)的输出看返回的chunk到底相关不相关。如果5个问题里超过2个检索结果明显不相关那就不要急着调生成先解决检索。另外一个技巧是打印相似度分数。FAISS上你可以通过vectorstore.similarity_search_with_score(question, k4)拿到分数分数普遍很低时说明embedding对当前语料不敏感这时应该考虑换embedding模型或重写检索query而不是继续加剧情。5. 常见问题与排查技巧实录5.1 模型明明有资料还是胡编这个是最常见的翻车现场。排查顺序我建议这样先把链里最终发给LLM的prompt完整打印出来看看context字段里到底塞进去了什么。很多时候问题出在检索结果为空或prompt模板的变量名写错了context根本就是个空列表模型没有资料可用自然只好自由发挥。如果prompt里有内容还是胡编再看prompt里是否缺少“没有就承认没有”的约束。不少人把“严格根据资料”写在角色描述里实际模板里却只有一个context变量模型读不出强制边界。养成习惯未知行为必须在prompt里显式声明。5.2 检索出来的片段不相关检索不相关的原因通常有四种embedding模型语言不匹配chunk块太大导致语义混杂query太短或者包含太多口语化表达文档里是扫描件或表格加载器根本没读到正文。我早期踩过最深的坑是PDF表格。PyPDFLoader读表格会把列和行都打碎成不连续字符串检索时完全命中不了生成自然就瞎编。这种情况要么先做OCR要么把表格转成Markdown格式再喂给切分器。5.3 上下文太长token爆了chunk_size1000加top_k6再加上问题和历史对话一个请求轻松超过一万token。爆token的典型现象是接口直接报错或者是模型只回复前半段就截断。解决方式很直接缩小chunk_size到500以内k调到3-4同时给context做截断或只保留得分最高的几段。如果资料本身非常长建议先做一层粗过滤再进RAG。比如先用关键词或元数据筛选掉明显无关的章节再对剩余部分做向量检索。这比单纯扩大向量库管用得多。5.4 回答每次都不一样同一份资料同一个问题跑两次结果不同。先查模型参数temperature没设0导致生成随机。第二步查向量库是否有更新如果保存的索引文件没有重建新增文档不会自动进入检索结果。第三步看prompt里是否有排序相关表述如果让prompt“按资料顺序回答”而检索结果每次排序不同输出也会不同。实测下来RAG链本身对检索结果顺序很敏感。如果你发现模型只在某些时候引用命中的第二段另一些时候引用第三段那多半不是模型问题而是检索排名的微小变化。固定temperature、固定top_k、必要时把search_kwargs里的fetch_k调大再压缩能减少很多不确定性。5.5 文档更新后结果不更新这是最容易忽视的问题。很多人改完源文档重新跑了一次加载和切分却没有重建向量库索引。结果FAISS里还是旧数据的向量模型每次都在旧版本资料中找答案。我的习惯是每次文档更新后把索引文件删掉重建或者按文档ID做增量管理绝不在旧索引上直接追加。可以用一个简单自查更新文档后问一个只有新版才有的问题如果答不上来先查索引更新时间基本能定位。5.6 常见问题速查表现象最可能的原因第一排查动作模型编造内容检索结果为空或prompt无边界声明打印最终prompt检查context检索到无关内容Embedding语言不匹配、chunk过大换embedding模型、缩小chunk_size上下文超长chunk_size/top_k设置过大降低两者并做得分截断同问不同答temperature过高或索引未更新temperature设为0重建索引表格类文档检索差PDF表格被加载器打碎先转Markdown/OCR再入库新文档答不上来旧索引未重建删除索引并重新向量化说实话上面这些坑我基本都踩过一遍最痛苦的不是报错而是“代码完全正常但效果就是不对”。后来我才明白RAG的效果大头不在LLM模型本身而在检索质量。如果你遇到类似问题先别急着换更贵的模型老老实实检查检索、切分、embedding这三层多半有收获。这个系列后面会继续更新比如查询改写、多路召回、重排序以及如何用评估集量化RAG效果。第一篇先到这里把地基打稳了后面加什么花样都容易。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑