让一个通用大模型老老实实只在指定资料里找答案,这件事我从一开始做就翻过车。当时还是直接把问题丢给LLM,让它基于一份内部产品手册回答客户问题,结果它发挥得很愉快,给出的答案里一半是手册里根本没有的内容,另一半年份都对不上。后来我才意识到,根本不是模型不够聪明,而是我压根没给它“只准看哪些材料”的边界。RAG这个思路就是来解决这个问题的——把资料先喂进一个可检索的索引,回答问题时先把相关资料捞出来,再让LLM只基于这些捞出来的片段作答。这篇文章是“RAG在LangChain中的实现”系列的第一篇,我会用LangChain把一个最基础的RAG问答Demo从头到尾搭起来,包含组件选型、核心代码、参数调优和踩坑记录,目标是让你能照着复现,并且知道每个环节为什么这么做。如果你正要处理内部知识库问答、产品文档答疑、私有数据问答这类“必须限定上下文”的场景,这篇内容可以直接当脚手架用。1. 为什么需要RAG:LLM的“自由发挥”问题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", encoding="utf-8") documents = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, ) 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(model="text-embedding-3-small") vectorstore = FAISS.from_documents( documents=chunks, embedding=embeddings, ) 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(model="your-model-name", temperature=0) 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", encoding="utf-8") documents = loader.load() splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80) chunks = splitter.split_documents(documents) # 2. 向量化并建库 embeddings = OpenAIEmbeddings(model="text-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(model="your-model-name", temperature=0) 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_type="similarity_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, k=4)拿到分数,分数普遍很低时,说明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_size=1000加top_k=6,再加上问题和历史对话,一个请求轻松超过一万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效果。第一篇先到这里,把地基打稳了,后面加什么花样都容易。
让一个通用大模型老老实实只在指定资料里找答案,这件事我从一开始做就翻过车。当时还是直接把问题丢给LLM,让它基于一份内部产品手册回答客户问题,结果它发挥得很愉快,给出的答案里一半是手册里根本没有的内容,另一半年份都对不上。后来我才意识到,根本不是模型不够聪明,而是我压根没给它“只准看哪些材料”的边界。RAG这个思路就是来解决这个问题的——把资料先喂进一个可检索的索引,回答问题时先把相关资料捞出来,再让LLM只基于这些捞出来的片段作答。这篇文章是“RAG在LangChain中的实现”系列的第一篇,我会用LangChain把一个最基础的RAG问答Demo从头到尾搭起来,包含组件选型、核心代码、参数调优和踩坑记录,目标是让你能照着复现,并且知道每个环节为什么这么做。如果你正要处理内部知识库问答、产品文档答疑、私有数据问答这类“必须限定上下文”的场景,这篇内容可以直接当脚手架用。1. 为什么需要RAG:LLM的“自由发挥”问题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", encoding="utf-8") documents = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, ) 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(model="text-embedding-3-small") vectorstore = FAISS.from_documents( documents=chunks, embedding=embeddings, ) 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(model="your-model-name", temperature=0) 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", encoding="utf-8") documents = loader.load() splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80) chunks = splitter.split_documents(documents) # 2. 向量化并建库 embeddings = OpenAIEmbeddings(model="text-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(model="your-model-name", temperature=0) 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_type="similarity_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, k=4)拿到分数,分数普遍很低时,说明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_size=1000加top_k=6,再加上问题和历史对话,一个请求轻松超过一万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效果。第一篇先到这里,把地基打稳了,后面加什么花样都容易。