ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain+RAG实战:搭建大模型知识库问答应用

LangChain+RAG实战:搭建大模型知识库问答应用

大模型问答应用如果只靠模型本身的“记忆”,很快你就会遇到一个尴尬场面:问一个训练数据之外的问题,模型会一本正经地给你编一个答案。RAG(检索增强生成)就是来解决这个问题的——它相当于给大模型外挂一个知识库,让模型在回答之前先到资料里“查一遍”,再基于查到的内容组织语言。这篇文章我会基于LangChain,从原理到代码,完整走一遍RAG问答应用的搭建过程,全程能跑、能改、能直接搬到项目里用。内容覆盖链路设计、参数调优、避坑经验,适合正在做知识库问答、私有文档查询、客服辅助这类场景的开发者参考。

1. RAG到底在解决什么问题:大模型的幻觉和“开卷考试”

1.1 大模型为什么会一本正经地胡说八道

先从一个很常见的场景说起。假设你在做一个企业内部制度问答机器人,员工问“年假折算到小时数怎么算”,模型如果没在训练语料里见过你们公司的制度,它会怎么做?它会基于通用常识去“猜”一个答案——比如告诉你“年假按日历日折算”,而这个回答听起来非常像回事,却跟你公司真正执行的规则完全对不上。

这不是模型“笨”,而是它的本质决定的。现在的LLM本质上是一个超大的条件概率模型,它学习的是语言规律和语料中反复出现的知识模式,而不是一个可以随时精确查询的外部数据库。模型的参数在训练完成那一刻就“冻结”了,它没法凭空知道训练截止之后发生的事情,也没法知道只存在于你公司内网里的文档。遇到不知道的问题时,它只能依据概率分布生成“最像正确答案”的文本——这就是幻觉的来源。

我一度用“一个读了很多书但记性不太好的远房亲戚”来跟朋友打比方:你问他你家小区物业的电话,他大概能告诉你“电话通常是八位数”,甚至还能编一个头头是道的号码,但那个号码大概率是错的。要让这个亲戚给出准确答案,你最好把通讯录递到他手上。RAG干的就是递通讯录这件事。

1.2 RAG的核心思路:让模型先查资料再动笔

所谓RAG,全称是Retrieval-Augmented Generation,检索增强生成。它把“问答”这件事拆成两个阶段:先到你的知识库里检索出与问题最相关的若干文本片段,再把这些片段连同用户的问题一起交给大模型,让模型基于这些片段进行回答。

你可以把它理解为“开卷考试”。模型不再依靠闭卷记忆硬答,而是先翻资料,再动笔。这种做法带来几个立竿见影的好处:

  • 知识可控:回答基于你提供的文档,来源明确,可以追溯到具体片段。
  • 更新成本低:文档变化时,只需要更新知识库,不需要重新训练模型。
  • 降低幻觉:模型的回答被限制在给定资料的范围内,编造空间大幅缩小。
  • 适配私域数据:企业内部文档、个人笔记、产品说明书,只要转成文本并入库,模型就可以“看到”。

很多人会把RAG和Fine-tuning(微调)放在一起比较。我个人的理解是:微调是用来改变模型的“能力与风格”的,比如让模型学会某种缩写习惯、特定的判别规则;而RAG是用来给模型“补充具体事实”的。如果你要回答的内容是产品参数、内部流程、最新政策这类强事实、高频变动的信息,走RAG性价比高得多——不用显卡训练,改文档即改答案,天然适合落地。

2. 整体链路设计:RAG问答应用从文档到答案的五个关键环节

2.1 一条完整链路:加载、切分、向量化、检索、生成

RAG不是一个单点技术,而是一条流水线。数据从原始文档到最终答案,至少要经过五个环节:

  1. 加载(Loading):从PDF、Word、txt、Markdown、网页中抽取文本内容。
  2. 切分(Splitting):把长文本切成一个个有语义边界的片段。这个片段的长度直接影响后续检索精度。
  3. 向量化(Embedding):把每个文本片段用嵌入模型转换为向量,也就是一组浮点数。只有变成向量,才能计算不同文本之间的语义相似度。
  4. 存储与检索(Storage & Retrieval):把向量存进向量数据库。用户提问时,先把问题也转换为向量,然后在库里找出与问题向量最接近的若干片段。
  5. 生成(Generation):把检索到的片段作为“参考资料”拼进提示词,连同问题一起交给大模型,生成最终回答。

这里有一个关键设计:用户的问题和知识库中的文本片段,必须经过同一个Embedding模型转换,才能放到同一向量空间里计算相似度。换一个模型,向量分布就不同,检索结果也就不可比了。

我在实际项目里最深的感受是:很多人把精力全放在最后的“生成”环节,但 RAG 的效果上限,很大程度上由前面的“切分”和“检索”决定。如果检索出来的片段根本不相关,后面的模型再强也是白搭——它没有可以依凭的内容,只能开始“创作”了。

2.2 为什么选LangChain来做这个事

LangChain是这个领域里目前最常用的开发框架。它做的事情其实很朴素:把大模型应用里那些高频重复的环节——加载文档、切分文本、调Embedding接口、接向量库、拼Prompt、调LLM——都封装成了可组合的组件。你不需要自己写一套“文档转文本再接向量数据库”的胶水代码,LangChain把这些接口统一了。

举个例子,如果不用LangChain,你要写RAG大概得这么干:自己写文档解析、自己调Embedding接口并管理大批量请求、自己封装向量库SDK、自己写Prompt拼接逻辑、自己处理重试和错误……当然这些都能写,但每换一个向量库或换一个模型,都要动一层代码。用LangChain,核心链路可以做到与具体工具解耦:今天用Chroma,明天换FAISS;今天调OpenAI接口,明天换本地模型,改动都被限制在很小的配置范围内。

当然,LangChain也不是银弹。它的抽象层设计比较重,新手有时候会陷入“找类名”而不是“想逻辑”的境地里。如果你的业务非常简单,比如就一个固定文档、调用一次模型,那直接手写反而更轻。但如果你要做的是一个需要持续迭代、需要支持多种格式和模型的问答应用,LangChain确实能帮你省掉大量重复工作。这也是我推荐大多数项目选它的原因。

2.3 工具选型思路:Embedding、向量库和LLM怎么配

RAG链路里的每一个环节都有多种选择,我当年第一次搭的时候也犹豫了很久。这里直接给出我总结的选型思路:

Embedding模型,分为在线API和本地开源两大类。在线API使用简单、效果稳定,适合快速验证;本地开源模型需要自己下载部署,但数据不出内网、长期用不花token钱,适合有隐私要求或高频调用的场景。

向量库,常用的有Chroma、FAISS、Milvus、pgvector等,它们的定位差异很大。

向量库适合场景特点
Chroma学习、原型验证轻量,数据持久化简单,适合单机小规模检索
FAISS本地小规模到中等规模计算高效,支持大规模索引,但本身不自带服务端
Milvus生产环境、海量数据分布式部署,功能全,运维成本也更高
pgvector已有PostgreSQL业务复用现有数据库,减少额外组件,适合中低并发

LLM方面,同样可以选API或本地开源模型,主要取决于你的部署环境、数据敏感程度和预算。做原型验证时先用API把链路跑通,再按需替换成私有化部署模型,这也是比较稳妥的推进路径。

我个人的建议是:第一版别追求“最全最优”,先选一条最简单、最容易跑通的组合——比如FAISS加一个在线Embedding接口加一个在线LLM,把整条链路跑顺,再逐步替换组件。这个思路可以帮助你把精力集中在“RAG效果怎么调”这个真正重要的问题上,而不是被工具配置拖住。

3. 实操:用LangChain从零搭建RAG问答应用

3.1 环境准备与依赖安装

先准备好运行环境。我用的是Python 3.10,项目里建议你用虚拟环境,避免把依赖装乱。然后安装下面的包:

pip install langchain langchain-openai langchain-community faiss-cpu openai pypdf

这些包分别负责:LangChain核心框架、OpenAI的聊天和Embedding接口封装、社区加载器、FAISS向量库、OpenAI SDK,以及PDF解析。如果你只需要加载txt文件,pypdf也可以不装,但留着之后扩充知识库会方便很多。

接着设置你的OpenAI API Key。我一般建议直接写成环境变量:

export OPENAI_API_KEY="你的key"

Windows用户就对应设置系统环境变量,或者在代码里临时配置也行,只是注意别把key硬编码到会提交到仓库的文件里。

3.2 文档加载与文本切分:决定检索效果的第一道关卡

先准备一份测试文档,我拿一个模拟项目X的产品说明书来演示。文档是txt格式,放在llm_rag_hub下面:

from langchain_community.document_loaders import TextLoader loader = TextLoader("llm_rag_hub/模拟项目X产品说明.txt", encoding="utf-8") documents = loader.load()

如果你有多个文件,可以用DirectoryLoader加载整个目录,格式上也可以支持PDF、Markdown、Word等。加载得到的documents是一个包含Document对象的列表,每个Document有page_content和metadata两个主要属性。

接下来是关键的一步:文本切分。这一步的目的不是简单地把文档变短,而是让每个片段保持相对完整的语义。我用的是RecursiveCharacterTextSplitter,它支持按一系列分隔符(从段落符到标点符号)递归切分,尽量让片段停在不切断语义的位置:

from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""], length_function=len, ) chunks = text_splitter.split_documents(documents)

你会问,chunk_size=500的“500”是什么概念?它指每个片段的最大字符数。对于中文内容,500个字符大概是一小段介绍或者两三个短段落,语义还比较完整。chunk_overlap=50的意思是相邻两个片段之间有50个字符的重叠,这是为了处理“切分点恰好切断一句话”的情况——有了重叠,即使一句话被分到两个片段里,检索时也能在相邻片段中找到它的上下文。

3.3 向量化与向量库存储:把文本变成可检索的“坐标”

现在把切好的片段向量化并存进向量库。这里用OpenAI的Embedding模型做向量化:

from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index")

这行代码干了好几件事:把每个片段交给Embedding接口,转换成向量;把向量和原始文本一起存进FAISS索引;最后把索引保存到本地目录faiss_index,方便之后直接加载。

Embedding模型本身其实相当“廉价”——它是用来生成文本向量的,相比于对话模型,它的调用成本通常可以忽略不计。但在选型时有几个细节值得注意:中文效果、向量维度、上下文长度。OpenAI的text-embedding-3-small在通用语义相似度上表现不错,维度适中,适合做中文知识库的原型验证;如果你完全不想用外部API,社区里也有几款优秀的中文Embedding模型,但需要本地部署,后面章节再展开。

3.4 构建检索器:怎么把最相关的片段捞出来

向量库建好后,下一步是把它封装成检索器:

retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} )

这里的含义是:用户问一个问题,向量库会把所有片段按“与问题的向量相似度”从高到低排序,然后返回前4个最相关的片段。k=4是我在多数项目里比较常用的起始值——少了信息不够,多了会带进噪声。

到这里,检索链路已经通了。你可以先做一次小测试,看看检索器能不能抓到相关内容:

retrieved_docs = retriever.invoke("报销流程怎么走?") for doc in retrieved_docs: print(doc.page_content)

打印出来的内容就是之后要喂给大模型的“参考资料”。这一步一定不要跳过——它能帮你直观判断前面的切分和向量化有没有做好,是整个项目排查问题时最核心的调试手段。

3.5 组装问答链:让大模型基于资料说话

最后一步是把检索到大模型串起来。这里我用LangChain的LCEL表达式来定义链路,这也是目前推荐的做法,结构清楚,逻辑直观:

from langchain_openai import ChatOpenAI from langchain_core.prompts import PromptTemplate from langchain_core.runnables import RunnablePassthrough prompt_template = """你是一个严谨的客服助手。请只根据下面提供的资料回答问题。 如果资料里没有提到相关信息,请直接说“资料中未找到相关内容”,不要自己编造。 资料: {context} 问题: {question} 回答:""" prompt = PromptTemplate(template=prompt_template, input_variables=["context", "question"]) def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) rag_chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough() } | prompt | llm ) answer = rag_chain.invoke("年休假怎么计算?") print(answer.content)

看到这里,你会发现整条链路其实只有三次转换:用户问题 → 检索出相关片段 → 拼成Prompt → 调LLM → 得到回答。LCEL表达式用|把这些步骤串起来,每一步的输出作为下一步的输入,读起来非常直观。

关于temperature=0,这是做知识库问答时我很坚持的一个设定。问答类任务要的是“稳定和忠实于资料”,不是“发散和创造”,将temperature设为0可以显著减少模型自由发挥的倾向。

注意:不同版本的LangChain API略有差异,如果你安装的是较新版本,个别导入路径可能变了。碰到报错时,先看报错信息里提示的模块路径,再按实际版本调整import语句——这是每个LangChain使用者都会经历的过程,不用慌。

4. 进阶调优:chunk、检索、Embedding和Prompt的关键参数

4.1 chunk_size和chunk_overlap到底怎么选

很多新手搭完第一版RAG,效果经常是“感觉有点相关,但又不够准”——十有八九是chunk切分没调好。chunk_size的影响比你想的大得多,它的取舍可以这样理解:

chunk_size优势劣势适合场景
200左右定位精准,噪声小,命中粒度细语义容易被切碎,有时信息不完整问答明确、文档条理清晰的FAQ类
500左右语义完整度与定位精度较均衡偶尔会带进冗余内容大部分通用知识库,我的首选起始值
1000以上上下文完整,信息量大噪声多、token开销高、检索容易模糊长章节、整体性说明类文档

chunk_overlap我个人建议设置在chunk的10%到20%之间,也就是500的chunk配50到100的overlap。它的作用是兜住切分点两侧的上下文,避免“话说到一半被切开”的情况。overlap太小,切分点附近的语义容易被截断;overlap太大,又会导致大量重复内容入库,白白增加存储和token消耗。

调chunk时不要凭感觉反复试。一个高效的做法是:先定几个候选值(比如300、500、800),每个都跑一批测试问题,把检索到的片段打出来对比,看哪个切法更稳定地命中关键内容。这比漫无目的地调参快得多。

4.2 检索不准怎么办:混合检索、重排序和Query改写

当纯向量检索的效果不够时,有几种成熟的优化手段,按实施难度从低到高排列:

第一是混合检索。向量检索擅长语义相关性,但对精确关键词不敏感;而传统的BM25关键词检索可以精准命中专有名词、编号、型号等。两者结合,能同时兼顾“意思相近”和“字面相同”。LangChain里可以通过EnsembleRetriever把向量检索器和BM25检索器组合起来,再用权重控制两者的贡献比例。

第二是重排序(Rerank)。第一步用向量检索先召回100个候选片段,然后用一个专门的Rerank模型对候选片段进行精排,取前5个。这样做的好处是:粗召回阶段可以大胆扩大候选范围,精排阶段再做精细取舍,最终效果往往比单纯调整k值提升明显。

第三是Query改写。有时候用户的问题本身是模糊的,比如只问“怎么申请?”,很难直接命中知识库里“物资申请流程”这一章节。可以先让LLM把用户的问题改写成更完整、更适合检索的形式,比如“如何申请办公物资?需要哪些步骤?”,然后再用改写后的问题去检索。

这三种手段不是互斥的,线上系统经常一起用。不过我的建议是循序渐进——先确认检索结果本身有没有问题,再考虑上重排和改写,不要一开始就全堆上,以免排查问题时变量太多。

4.3 中文场景的Embedding选型建议

如果你处理的是中文知识库,Embedding选型的优先级甚至高于LLM选型。为什么这么说?因为检索结果的质量直接决定回答的上限,而Embedding模型对中文语义的理解能力差异非常大——一些面向英文优化的模型在中文上会表现出明显的“语义盲区”。

目前中文场景下比较常用的方案有两类:

一类是继续用在线API模型,比如OpenAI的Embedding接口,它对中文的支持已经相当不错,胜在省事、稳定,适合快速上线和中小规模知识库。

另一类是本地开源Embedding模型,目前社区里比较成熟的有成体系的几个系列,它们针对中文语料进行了专门训练,在中文句子相似度、检索召回等任务上表现不输在线接口。本地部署之后,每次向量化的成本近乎为零,适合大规模文档入库和隐私要求较高的场景。

我给一个比较实在的建议:先拿你的真实文档,分别用在线API和本地模型各跑一版,挑几十个有代表性的问题,对比检索命中效果。不要只看榜单分数,实际业务里的问题往往更口语化、更碎片化,只有拿自己的数据跑过才有发言权。

4.4 Prompt设计:让模型学会“不知道就说不知道”

RAG里Prompt的作用常常被低估。很多人图省事直接把检索到的文本拼到系统提示词后面,却没有明确告诉模型“你要做什么、不能做什么”,结果模型依然按照自己的习惯放飞自我。

一个合格的RAG系统Prompt,至少要包含三层信息:

  • 角色定义:你是谁,遵循什么原则。
  • 约束条件:只依据资料回答、不得编造、资料中未提及的内容要明确说明。
  • 输出格式:必要时规定回答结构,比如先结论、再依据、最后补充说明。

实践里我常用的中文模板大概是这样的:

你是企业内部的智能问答助手。 严格依据给定的资料内容回答用户问题。 如果资料中没有直接答案,明确回答“资料未覆盖该问题”,不要尝试推测。 回答时尽量引用资料中的关键信息,但不要逐字复制整段。

这个Prompt在多数项目里都能直接套用。需要注意的是,{context}本身也要在“资料:”之后明确给出,让模型知道哪部分是“可信输入”,哪部分是“用户问题”。两者的边界如果不清晰,模型可能会把用户问题也当成资料引用,导致回答混乱。

5. 常见问题与排查技巧:我从实操里踩过的坑

5.1 答非所问:检索结果压根不对,怎么排查

先说明一个很容易被忽视的事实:RAG问答里模型答错,绝大多数情况下不是模型的错,而是检索环节已经把内容找偏了。

遇到答非所问,第一件事永远是打开调试开关,把检索到的片段打印出来人工看一眼。如果你发现检索到的片段和问题毫无关系,按这三个方向排查:

  • 是不是Embedding模型不适合当前语言或领域?换一个专门针对中文或该领域优化的Embedding模型再试。
  • 是不是chunk切得太碎,导致一个完整的知识点被拆散了?适当调大chunk_size。
  • 是不是文档本身质量太差,比如扫描件、排版混乱、大量表格?这类文档要先做文本清洗,否则下游做什么都白搭。

这里分享一个排障技巧:开发阶段在问答界面旁边加一个“引用来源”展示区,把模型引用的文本片段原样展示出来。这不仅是排障工具,也是让用户信任问答系统的关键设计。用户看到“这个回答出自文档第几节”,对结果的接受度会高很多。

5.2 模型还是胡编乱造:上下文没有真正约束住

如果你确认检索到的片段已经很相关了,模型还是给出资料里没有的内容,问题大概率出在Prompt约束上。

最常见的原因是Prompt没有说清楚“只能依据资料回答”。默认情况下,模型会把资料当作参考,但它仍然倾向于结合自己的预训练知识来“补充润色”。解决办法就是把约束条件写进Prompt,并且把这句话放在资料之前,让模型优先读取到规则。另外temperature=0也是一个必要的防线。

还有一种可能是k值设得太大。你以为“多给几段资料更保险”,实际上资料越多,模型越容易从中挑出一些边缘内容展开联想。我一般控制在3到5个片段,多了就进入“信息过载”状态。

5.3 Token消耗太大、响应太慢怎么办

知识库问答的token开销主要来自三块:检索到的片段、Prompt模板和历史对话记录(如果做了多轮会话)。

先算一笔账:如果每个chunk是500字符,k=4,那么每次问答大约要消耗2000字符的上下文,折合约500到800个token。按GPT-4的价格可能不算便宜,但换成便宜一些的模型或者用小参数模型,成本能降一个数量级。如果已经是便宜模型了,还是觉得贵,那就从三个方向下刀:

  • 调小k值,比如从5降到3。
  • 调小chunk_size,比如从800降到400,让单次上下文变短。
  • 精简Prompt模板,把不影响效果的修饰语删掉。

响应速度方面,最大的瓶颈往往是向LLM提交的长上下文。减小chunk、减小k同样能加速。另外,如果昂贵模型只是用来做“答案润色”,不如试试两步走:先用便宜模型做检索和草稿,再决定是否调用更贵的模型做最终生成——不过这个策略会让系统复杂度上升,适合在量真的大起来之后考虑。

5.4 向量库构建慢、占用大:持久化与规模化管理

用FAISS跑本地小规模知识库时,几千个文本片段构建索引大概只要几秒,完全不用焦虑。但文档数量到了几十万级别时,内存占用和构建时间就会变成一个问题。

我的建议是:把“构建”和“查询”分开管理。构建侧可以做成离线任务——知识库文档更新时,后台增量构建索引;查询侧只加载已构建好的索引文件。FAISS本身支持save_local和load_local,正好适合这种模式:

# 构建阶段 vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index") # 查询阶段(每次启动时) vectorstore = FAISS.load_local("faiss_index", embeddings, allow_dangerous_deserialization=True)

注意:新版本的FAISS加载本地索引时,需要显式传入allow_dangerous_deserialization=True。这个参数的意思是“信任这个索引文件的来源”——只加载自己构建的索引文件,别下载不明来源的索引来加载。

数据量更大、需要多人同时查询时,就该考虑换成带服务端的向量库了。这时候选型的逻辑会从“怎么存得下”变成“怎么并发查得动”,生产环境常见的方案包括Milvus、Elasticsearch内置的向量检索能力,或者直接用云数据库自带的向量字段。从单机FAISS升级到这类组件,LangChain的优势就体现出来了——你的检索代码基本不用大改,只是换一个VectorStore类。

6. 写在最后:几个我个人的经验之谈

RAG这套方案我前前后后搭过好几次,最大的体会是:它的门槛不高,但要做好很考验细节。链路里每一个环节都有自己独立的坑,chunk怎么切、Embedding怎么选、Prompt怎么写、k值设多少,任何一步没到位,最后呈现的效果都会差一口气。

如果只让我留三条最重要的建议,我会说:第一,先建好调试链路,务必把“检索到什么片段”可视化,这是所有优化工作的起点;第二,第一版全部用最简单、最稳定的组合,先跑通,再谈混合检索和重排序;第三,写Prompt时一定要把“不知道就说不知道”写死,这是挡住幻觉最有效的一道防线。

RAG的下一个阶段,我最近在尝试的是把它和多轮会话、长期记忆结合起来——用户连续问几个问题时,系统需要判断哪些问题可以复用之前检索到的上下文,哪些需要重新走一遍检索。这个方向坑更深,但做出来后体验会有本质提升。如果你已经把手上的RAG应用跑通,建议也往这个方向试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进