最近好几个搞内部知识库、文档问答系统的朋友都跑来问我,一上来就是“LangChain怎么做RAG”。确实,RAG(Retrieval-Augmented Generation,检索增强生成)算是现在大模型落地最实用的方向之一,而LangChain是绕不开的基础框架。这篇就用我实际做项目的经验,把LangChain里RAG的完整流程拆开讲清楚,从核心概念到代码实操,再到调参避坑,尽量让第一次接触的人也能照着做出一套能用的文档问答系统。
1. RAG 是什么、解决什么问题
1.1 先理解 RAG 的定位
RAG 的核心思路说白了就一句话:不直接让大模型凭空回答,而是先从你的知识库里把相关内容“捞”出来,再把“问题+上下文”一起扔给大模型,让它基于你给的材料作答。
这个“捞”的动作,就是检索(Retrieval);“基于材料作答”的动作,就是增强生成(Augmented Generation)。两者拼起来,就是 RAG。
我常用一个类比来解释:你把大模型想象成一个博学的助手,但它脑子里装的知识截止到训练数据那一天,而且它没看过你公司的私密文档。RAG 相当于在助手旁边放了一个资料库,每次提问之前,你先把资料库里相关的几页翻出来递给它,它再结合自己的语言组织能力回答。这样既不用重新训练模型,又能让模型“知道”它原本不知道的东西。
这个方案解决的核心痛点非常直接:大模型的知识过期、不了解私有数据、回答容易胡编乱造。RAG 通过外挂知识库的方式,让模型在回答时“有据可依”,大幅降低幻觉,同时不需要重新训练模型,成本低、见效快。
1.2 为什么偏偏是 RAG 而不是微调
很多人会问:那直接微调(Fine-tuning)一个垂直模型不就行了?这就涉及方案选型的问题了。微调是把新知识“写入”模型参数,听起来很美,但实际用起来有几个硬伤:
- 训练成本高,需要GPU资源和数据标注,迭代一次要等很久。
- 知识更新不灵活,文档一改,又要重新训练,时效性完全跟不上。
- 微调注入知识的容量有限,塞太多东西进去,模型容易把新旧知识搞混,反而变笨。
RAG 没有这些问题。文档更新了,直接替换索引里的内容就行;今天换一批数据,明天就能上线。成本主要花在检索和向量化上,比训练便宜不止一个量级。
当然,RAG 也不是银弹。检索质量差的时候,给模型喂了不相关的材料,答案照样会跑偏。所以现在成熟的落地项目,往往是“RAG 为主、微调为辅”:先用 RAG 解决私有知识和时效性问题,再用微调优化模型的输出风格和指令遵循能力。但作为起步,RAG 是性价比最高的切入点。
1.3 LangChain 在 RAG 里的角色
LangChain 不是必须的,原生代码写 RAG 也完全可行。但 LangChain 的价值在于把 RAG 流水线里的各个环节——文档加载、切分、向量化、存储、检索、Prompt 组装、模型调用——都抽象成了标准组件,并且提供了一套连接逻辑。
实际开发中我最看重 LangChain 的一点是:它把“文档加载器”做得特别全。PDF、Markdown、网页、数据库、云文档等几十种格式都有现成的 Loader,不用自己写解析逻辑。项目的早期原型阶段,用 LangChain 可以在半天内搭出第一版,跑通后再决定哪些环节需要手写优化。
当然,我这里分享的都是基于 LangChain 0.1/0.2 版本的经验,新版本 API 有一些调整,但核心组件的思路是通用的,你能理解原理,换版本只是改调用方式的问题。
2. 核心组件拆解:从文档到答案的整条流水线
一个标准的 RAG 应用,本质上是两条流水线:一条是“索引流水线”,负责把文档变成可检索的向量;另一条是“查询流水线”,负责把问题变成向量、检索文档、生成答案。
2.1 文档加载与切分(Load & Split)
文档加载本身不难,难点在于切分策略。切分的目的是把长文档拆成语义相对完整、大小适中的“块”(Chunk),每一块未来都可能作为检索结果返回。
为什么不能直接把整篇文档变成一个向量?因为整篇文档太长,语义被稀释了,检索时很难精确命中某一段内容。而且向量模型对输入长度有限制,超长文本会被截断,信息丢失严重。
切分里面有很多细节,先说几个我踩过坑的经验:
- 按固定字符数硬切是最简单的方案,但容易切断句子或段落,导致语义不完整。
- 更好的方式是“按结构切”:Markdown 按标题切,代码按函数切,PDF 按章节切。这样切出来的块天然语义完整。
- 块与块之间可以设置重叠(overlap),比如每块 500 字符,重叠 50 字符,避免刚好把关键句切在边界上。
LangChain 里面常用的切分器是RecursiveCharacterTextSplitter,它的思路是先用大分隔符切,切出来的块还太大,就换小分隔符继续切,直到满足大小要求。这个“由大到小递归切分”的思路非常适合中文文档,因为中文没有天然的空格分词,按标点符号切会比按字符硬切更合理。
2.2 Embedding 与向量化
切好的文档块要变成机器能比较的形式,就要用 Embedding 模型把文本转成一组浮点数向量。这里的核心诉求是:语义相近的文本,向量之间的距离也应该相近。
举个例子,“苹果发布了新手机”和“这家公司推出了新款智能手机”字面上差别很大,但在向量空间里距离应该很近。这就是 Embedding 模型训练的目标。
选 Embedding 模型时,有几个实际问题要注意:
- 中英文混合场景,要选对中文支持好的模型,否则中文检索效果会明显变差。
- 向量维度不是越大越好,维度高占存储和计算资源,维度低可能表达力不足。不过这个通常由模型决定,不用过度纠结。
- Embedding 模型的更新频率没有大语言模型那么快,但也要留意版本,老模型的效果和新模型差距可能很大。
LangChain 里通过embeddings接口统一调用,底层可以切换不同的模型厂商,比如 OpenAI 的接口、开源模型或者国产模型。前期做原型用谁的都行,重点是接口统一,后面换模型只需要改配置。
2.3 向量存储与检索
向量存储要解决的核心问题是:给定一个问题向量,快速找到知识库里最相似的 K 个向量。
这里说的“相似”,计算方式主流是余弦相似度或点积。余弦相似度只看方向、不看长度,适合文本相似度比较;点积在向量归一化之后和余弦等价。实际使用中,大多数向量数据库默认就能满足需求,你只需要理解一个关键点:检索不是“搜关键词”,而是“按语义搜”——即使问题表述和文档原文完全不一样,只要语义相近,就能被检索出来。
向量数据库的选型,我按项目阶段给个建议:
| 阶段 | 推荐方案 | 原因 |
|---|---|---|
| 原型Demo | Chroma / FAISS | 免费、本地运行、代码简单,几分钟跑通 |
| 小规模内测 | Milvus Lite / Qdrant 本地版 | 支持过滤、持久化更好,API 清晰 |
| 生产环境 | Milvus / Qdrant 集群 | 高并发、水平扩容、监控运维完善 |
有一个常见的误区:以为装了向量数据库就万事大吉。实际上,一半以上的检索质量问题出在“召回的数量和阈值设置”上。
k值(召回数量)设置太小,可能漏掉相关内容;设置太大,噪声会干扰生成。- 如果向量相似度和关键词检索的前几名结果不一致,不要急着怀疑数据库,多半是切分粒度或 Embedding 模型的问题。
2.4 Prompt 组装与生成
检索回来的文档块,最终要组装成 Prompt 喂给大模型。这一步的 Prompt 设计直接影响答案质量,但常常被新手忽略。
一个典型的 RAG Prompt 包含这几部分:
- 系统角色设定:告诉模型你是文档助手,只能基于给定的资料回答。
- 上下文信息:把检索到的文档块拼接进去,并明确标注来源。
- 用户问题:放在最后。
- 输出约束:比如“如果资料中没有相关信息,请直接说不知道,不要编造”“回答时尽量简洁”。
这套 Prompt 的逻辑容易理解,但实际测试的时候,我建议你逐步调整,效果通常差别很大。比如“只基于上下文回答”这句话,换成“严格根据资料内容回答,禁止使用内部知识补充”,约束力会更强。这个后面在调优部分展开讲。
3. 实操:用 LangChain 搭建一个最小可用的 RAG 应用
3.1 准备工作
这部分假设你已经能调用大模型的 API。我用 LangChain 0.2 版本做演示,基本的依赖安装命令:
pip install langchain langchain-openai langchain-community chromadb如果你用的是 OpenAI 接口,还需要配置环境变量。考虑到国内访问问题,很多项目其实是把base_url指向代理中转服务,这属于工程配置问题,各家用各家的,原理都一样,这里不展开。
export OPENAI_API_KEY="你的密钥"我这里的代码示例以标准 OpenAI 接口为演示对象。你完全可以用国内模型或开源模型替换,LangChain 的好处就是接口统一,替换成本很低。
3.2 完整代码演示
我直接写一个最小可用的完整流程,步骤分为:加载文档、切分、向量化、检索、生成。代码加注释,方便照抄。
from langchain_community.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma from langchain_core.runnables import RunnablePassthrough from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI # 1. 加载文档 loader = TextLoader("manual.txt", encoding="utf-8") documents = loader.load() # 2. 切分文档 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""], ) docs = text_splitter.split_documents(documents) print(f"切分后共 {len(docs)} 个文档块") # 3. 创建向量库(Chroma 会自动保存到本地目录) embedding_model = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = Chroma.from_documents( documents=docs, embedding=embedding_model, persist_directory="./chroma_db", ) # 4. 创建检索器 retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4}, ) # 5. 组装 Prompt prompt_template = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的文档问答助手。请只根据以下资料回答用户问题," "如果资料中没有相关信息,请直接说“资料中未找到相关内容”,不要编造。\n\n" "资料内容:\n{context}"), ("human", "问题:{question}"), ]) # 6. 组装完整链路 def format_docs(docs): return "\n\n---\n\n".join(doc.page_content for doc in docs) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt_template | ChatOpenAI(model="gpt-4o-mini", temperature=0) ) # 7. 提问 result = rag_chain.invoke("产品支持哪些格式的导入文件?") print(result.content)这里唯一比较绕的地方是倒数第三步那个retriever | format_docs,朗朗上口的|是 LangChain 的管道符号,意思是:检索器返回的文档块列表,传给format_docs函数,拼接成一段字符串,作为context变量放进 Prompt。
3.3 关键参数与调整思路
上面这段代码能跑通,但参数是否合理,直接决定效果好不好。我逐个说下:
chunk_size=500:这个值是我做中英文混合文档时常用的起点。如果你的是纯英文技术文档,可以调到 800~1000;纯中文可以调小到 300~400。总体原则是:内容越结构化,块可以越大;越口语化、语义越零散,块要越小。chunk_overlap=50:很典型的取值,大约是 chunk_size 的 10%。太小起不到防止切断句子的作用,太大会导致大量重复内容,浪费向量存储空间。k=4:召回 4 块。做测试时我建议先设大一点,比如 6~8,看看检索结果的质量,再逐步调小。k 太小容易漏,k 太大容易让模型被不相关内容带偏。temperature=0:问答类任务我建议直接把 temperature 设为 0,让生成结果尽量确定,减少胡说八道。search_type="similarity":多数场景用相似度检索就够了。如果后续发现结果重叠度高、多样性不够,可以改mmr(最大边际相关性),它能提升召回结果的多样性,避免召回的都是几乎重复的内容。
跑通这段代码之后,你就有了一个完整的 RAG 雏形。接下来要做的不是急着加功能,而是先“压测”:找三五十个典型问题跑一遍,观察哪些答得好、哪些答得差,然后针对性地调。
4. 把效果调好:常见问题与排查实录
4.1 召回不到相关内容的常见原因
所有 RAG 效果差的问题,第一步要判断:是“没召回到”还是“召回到了但生成错了”。区分方法很简单——把检索结果单独打印出来看。
我在代码演示里没写打印检索结果的逻辑,实际调试时一定要加一句:
retrieved_docs = retriever.invoke("产品支持哪些格式的导入文件?") for i, doc in enumerate(retrieved_docs): print(f"--- 第{i+1}个结果 ---") print(doc.page_content[:200])如果打印出来的内容和问题明显无关,那就是召回环节出了问题,常见原因有三个。
第一个原因是切分粒度不对。比如你把一个完整的产品功能说明硬切成了两半,检索时命中了后半段,但后半段没有关键名词,模型拿到的上下文不完整。
第二个原因是 Embedding 模型对领域术语或专业表述不敏感。这种情况,优先考虑换领域适配更好的 Embedding 模型,或者做简单的查询改写——把提问中的简称、口语表达改写成文档中会出现的正式术语,再去做向量检索。
第三个原因是文档本身就没有覆盖这个问题。这属于数据问题,RAG 再强也没办法无中生有。这种情况需要回到源头补充文档,而不是持续调检索参数。
4.2 答案不准确,怎么判断是检索还是生成的问题
如果检索结果相关,但答案还是不对,问题就出在生成环节。最常见的锅在 Prompt。
我遇到过的情况是:模型把上下文里的内容和它自己的内部知识混在一起回答。看起来没错,但已经偏离了“只基于资料”的约束。这时候需要强化 Prompt 的约束条件,我的经验是加上一条“回答内容必须能与资料原文对应上,不要补充资料中没有的信息”。
另外,上下文太长也会稀释模型的注意力。如果你把 8 个文档块全塞进 Prompt,模型可能抓不住重点,被第 3 块、第 7 块的不相关内容带偏。这时候可以调低k值,或者做“重排序”(Rerank)——先用向量检索召回 20 个候选块,再用重排序模型精排取前 4 个。重排序是提升 RAG 效果最明显的手段之一,强烈建议在你确认基础链路跑通之后入手。
4.3 工程落地的几个细节补充
原型跑通之后,真正落地到项目里还有几个容易被忽略的细节,顺手提一下,都是实际项目中遇到的:
- 文档更新策略:别在每次问答时重新全量建索引。正确做法是文档入库时计算哈希或记录修改时间,定时增量更新向量库,或者用 API 按文档 ID 删除旧向量、插入新向量。
- 持久化路径:上面代码里
persist_directory="./chroma_db"指定了向量库的本地存储目录。生产环境要注意这个目录的备份和迁移,向量库本质上也是数据,丢了要花很长时间重新建立。 - 上下文来源标注:生成答案时最好把命中的文档路径或标题也返回给前端展示,这样用户能验证答案来源,信任度会高很多。实现上就是把检索结果里携带的 metadata 一并透传出来,不是什么高深的技术,但体验提升很大。
- 成本控制:Embedding 和生成都按 token 计费,公共场景要加缓存,热门问题命中缓存就不需要重新走全链路。尤其 Embedding 的调用量容易被低估,很多项目账单涨上去才发现 Embedding 调用占了不小比例。
提示:判断问题的关键是“分层排查”——先看召回,再看生成,最后看数据。按照这个顺序定位,绝大部分问题都能快速找到根源,而不是靠玄学调参。
5. 进阶优化方向
基础链路和排查思路都掌握之后,RAG 还有不少可以进阶的空间。按性价比排序,我建议下一步优先做这几件事。
首先是查询改写。用户的提问经常是口语化的,比如“导入老格式文件行不行”,但文档里写的是“支持 CSV、JSON 格式导入”。直接用这个口语化问题去检索,向量匹配可能不够精准。简单做法是用大模型把问题改写成几个正式的检索词,再做多路召回,效果会立竿见影。
其次是重排序(Rerank)。向量检索的初筛结果有 10~20 个候选,用一个专门的排序模型重新计算相关性,取前几名,能显著提升最终召回质量。这一步引入的开源模型或 API 成本不高,但效果明显,是生产环境相当值得做的事。
第三是混合检索。纯向量检索适合语义匹配,但关键词的精确匹配能力偏弱。比如你搜一个产品型号“X-2000”,向量检索可能把它当成普通文本泛化掉了,而关键词检索能精确命中。商业级别的 RAG 系统基本都是“向量+关键词”混合双路召回,再用重排序融合结果。这个思路可以后面遇到具体需求再深入,但先知道有这么个方向,对你搭建系统时的架构选型会有帮助。
最后一个建议是“上下文压缩”。不是所有召回的块都和问题强相关,有些块里可能只有一句话有用,其他都是背景信息。用大模型把每个块压缩成和问题最相关的片段,再拼进 Prompt,能有效缓解上下文过长引发的注意力稀释问题。
我个人在实际项目里最大的体会是:RAG 这个技术方向,入门门槛很低,但效果上限完全取决于细节。别人跑通的代码你复制过来,效果可能天差地别,因为你们的文档结构、切分粒度、Embedding 选择、Prompt 风格全都不一样。不要被外界的“最佳实践”带着走,先把你自己的几十个测试问题跑顺,记录哪些场景表现差,再反推是哪个环节出了问题,这样迭代几轮,你的 RAG 系统会越来越好用。
如果这篇东西对你有帮助,建议你今天就拿一份真实的业务文档,按上面的代码搭一个最小原型,然后用我自己推荐的分层排查法跑上二十个问题,你会很快理解哪些环节真正影响效果。