用LangChain搭RAG知识库,我最近一个月至少被问了五遍。大多数人的第一反应,是把公司文档一股脑塞进大模型的上下文窗口,让模型“记住”这些内容——这个思路听着合理,真跑起来要么上下文爆炸,要么回答串味。直到你把RAG(检索增强生成)这套流程落地,问题才算真正解决。这篇文章是LangChain快速入门的第三篇,我把一次完整的知识库搭建过程拆开来讲:文档加载、文本切分、Embedding向量化、向量库存储、检索召回、Prompt组装、生成回答,全链路跑通,最后附上我实测中踩过的一些坑和排查思路。适合已经会跑LangChain基础LLM调用、正准备做知识库问答项目的同学参考。
1. 先弄清RAG的机制:它不是在“训练模型”,而是在“检索拼接”
很多人一听“知识库”就以为要微调模型,其实RAG走的是另一条路:模型参数一动不动,靠的是每次提问时,从外部索引里把相关资料捞出来,拼进Prompt,再让模型基于这些材料作答。你可以把它理解成一个“先查资料、再写报告”的助手,而不是一台被灌输了知识的考试机器。
1.1 一次问答背后完整跑过的四个阶段
RAG的标准流程可以拆成四个阶段,我按先后顺序说清楚:
- 索引(Indexing):把知识文档切分成小块,逐块算出向量表示,写入向量库。这一步是离线的,可以在服务上线前批量完成,文档更新时只需要重跑对应部分。
- 检索(Retrieval):用户提问后,先把问题也转成向量,然后在向量库里做相似度搜索,召回最相关的几个文本块。注意这里检索的单位是“块”,不是整篇文档。
- 增强(Augmentation):把召回到的文本块和用户问题一起放进Prompt,并在Prompt里明确告诉模型“以下内容来自知识库资料”。
- 生成(Generation):大模型只负责基于给定的上下文组织语言、回答问题,不再依赖它自己的“记忆”。
用个生活化的类比:向量库相当于档案室的索引柜。以前的做法是把档案室所有文件都搬到你的办公桌上,让你自己翻;RAG的做法是你先去索引柜按关键词找出最相关的三五页,再让助手只根据这几页写答复。省时省力,关键是过滤掉了无关信息。
1.2 上下文窗口再大,也不该把文档全塞给模型
现在模型上下文窗口普遍不小,但“塞得下”不等于“效果好”。我实际跑下来有三个直接问题:
- 成本是线性涨的。一份几十万字的资料,每次提问都全量带进Prompt,Token费用和接口延迟都让人肉疼。
- 噪音会干扰判断。文档里真正和当前问题相关的内容常常只有几百字,其余大量无关段落混进来后,模型回答的准确率反而下降,有时候还会把不同段落的信息错误地拼在一起。
- 更新很不灵活。文档一改,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( path="docs", glob="**/*.md", loader_cls=TextLoader, ) docs = loader.load()注意PDF加载出来是“按页”的,一页一个Document,页面里如果跨页段落会被拆开,所以要留给后面的切分器处理。如果用DirectoryLoader做批量加载,尽量把不同类型文档分目录放,每种格式用对应的Loader,混在一起容易出编码问题。
3.2 文本切分:先按结构切,再按长度兜底
我推荐用RecursiveCharacterTextSplitter,它按分隔符优先级递归切分:先尝试按段落切,段落太长再按句子切,句子还长再按标点切。这样能最大限度保留语义完整的段落和句子,而不是生硬地按字符数砍。
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, 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( model="text-embedding-3-small", # 换成你的Embedding接口实际支持的模型名 ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", )persist_directory指定的是向量库的持久化目录,跑完之后这个目录里会存下向量数据和元信息,下次直接加载复用,不用重新算一遍向量。这一步很关键,尤其Embedding接口按调用量计费时,重复入库等于重复花钱。
3.4 增量更新与去重:知识库早晚要面对的问题
知识库不是一次建完就不动了。文档更新、新增内容,都需要往向量库里追加。追加本身很简单:
vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=embeddings, ) 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( "服务器启动失败时怎么排查", k=5, ) for doc, score in results: print(f"score={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_type="mmr", 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( model="gpt-4o-mini", # 换成你实际能访问的模型即可 temperature=0, ) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=retriever, return_source_documents=True, ) answer = qa_chain.invoke("服务器启动失败时怎么排查") print(answer["result"])我建议上线调试阶段一定把return_source_documents设为True,这样每次回答都能看到模型用到了哪些文档、哪些片段。既方便你判断召回是否正常,也方便给用户展示依据来源,是降低“AI胡说”信任成本的关键设计。
5.2 chain_type怎么选:stuff、map_reduce、refine
RetrievalQA支持三种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( template=template, input_variables=["context", "question"], ) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=retriever, chain_type_kwargs={"prompt": prompt}, return_source_documents=True, )这里{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_function=embeddings, ) retriever = vectorstore.as_retriever(search_kwargs={"k": 5})另外几个实用的优化点:
- 入库时用批量Embedding,一次传多段文本,比逐条调用快得多。
- 对高频的查询片段做缓存,相同问题短时间内的检索结果直接复用。
- 如果并发访问量上来了,本地Chroma要注意锁和并发写问题,读多写少基本可以接受,写多就要考虑换远端向量库。
- LLM层先选小模型跑通效果,再按需升级,第一版别一上来就用最贵的大模型。
性能问题的本质是流程设计问题:把入库、检索、生成三个环节解耦,每层都能单独扩展和优化。
最后说点个人体会。我给知识库项目做验收时,第一件事不是跑Demo,而是手工整理二三十个典型问答对,做成评估集,每次调整后都拿它过一遍,比对模型换参数可靠得多。另一个小技巧是入库时把来源文件名和章节写进metadata,调试时用similarity_search_with_score打印出来,一眼就能看出召回是不是跑偏,这个习惯帮我省了不少排查时间。RAG的入门门槛其实不高,但把每个环节的细节都摆弄明白,才是它真正值钱的地方。