前段时间我在做一个某公司的内部知识库问答机器人,技术栈选的就是LangChain加RAG。先说结论:这套组合解决的是大模型“不知道”的问题——不训练也能让模型基于企业内部资料回答问题。很多人一听到RAG,第一反应是“把文档切碎丢进向量库,然后检索拼接”,这句话方向没错,但离真正能落地还差着一整套细节。这篇文章把我从理论到实战的完整过程梳理了一遍,它适合已经开始做大模型开发、但对RAG还是“知其然不知其所以然”的开发者,也适合刚被分到知识库问答需求、准备从零动手搭的读者。里面会有组件选型、参数设计的理由,也有我实际踩过的坑。全文不绕弯子,都是我在多个项目里反复调出来的流程。
1. 为什么说RAG是目前大模型落地的最短路径
1.1 大模型的“记忆力”边界
很多团队第一次接触知识库问答,第一反应是微调。这个思路不奇怪,因为传统模型开发确实是这样:数据不够就收集数据,效果不好就调模型。但大模型的“知识”和传统模型不一样,它本质上是一座用训练语料堆出来的参数记忆库。训练截止时间一到,之后发生的事情它不知道;公司内部没公开过的流程、制度、表格,它也没见过。微调确实能塞进去一部分,但成本高、周期长,而且每次文档更新,可能都要重新训一次。
RAG的思路恰恰相反:不把答案“背进”模型参数里,而是让模型在回答问题的时候“翻开书”。用户提问后,先从你准备好的资料库里检索出相关段落,再把这些段落一起塞进上下文,让模型基于眼前的证据作答。这相当于给模型配了一位随叫随到的研究助理,资料库更新,助理手里的材料就更新,模型本身不用动。理解这一点之后,再看LangChain的架构,你会发现它的每一层抽象都是为了支持“翻开书”这个动作。
1.2 微调与RAG怎么选:我的决策矩阵
我在项目评审时通常会摆一张表出来,让团队先对齐决策维度,而不是凭感觉选路线。
| 维度 | RAG | 微调 |
|---|---|---|
| 知识更新速度 | 替换/新增文档即可,分钟级 | 需要重新训练,小时到天级 |
| 算力成本 | 主要消耗在向量化和检索,门槛低 | 需要训练环境与GPU资源 |
| 可解释性 | 可以明确指出答案依据来自哪篇文档 | 知识融进参数,无法追溯 |
| 幻觉控制 | 限定上下文后显著改善 | 仍可能产生参数记忆污染 |
| 对模型能力要求 | 依赖模型的阅读理解与指令遵循 | 依赖训练数据质量 |
| 典型场景 | 企业内部知识库、实时政策问答 | 风格迁移、固定话语体系、私有能力 |
结论很清楚:如果你只是想“让模型知道一些参数里没有的资料”,RAG是更短、更稳的路径。微调并不是被取代了,而是解决另一类问题,比如让模型模仿某种表达风格、稳定输出固定模板,这些RAG做不好。我自己的一般原则是先RAG打底,如果发现还有风格、术语层面的硬需求,再考虑微调叠加,而不是把微调当作第一选择。
1.3 为什么偏偏用LangChain
不做也行,完全可以手写原生代码:读文件、切分、调向量化接口、调模型接口,麻烦但可控。但换个模型、换一种文档类型、加一个打分环节,手写代码往往就要重来。LangChain的价值在于它把“读什么、怎么存、怎么搜、怎么答”拆成了可以插拔的组件,我只需要定义数据流,不需要每次都从零搭流水线。更关键的是,它抽象出了一套相对稳定的工作流概念,团队协作时可以共用同一套术语,讨论问题效率高很多。
当然,LangChain也有代价:版本迭代快、API变动频繁、抽象层可能掩盖细节。这些问题在后文会专门讲,但你只要把它当作“半成品脚手架”而不是“黑盒框架”,就能扬长避短。
2. LangChain的5个核心组件:我的理解与习惯用法
先说结论:LangChain里我实际每天都用的组件其实就5类——文档加载器、文本切分器、Embedding模型、向量库、链(Chain)。前四个负责“资料准备与召回”,最后一个是“组织生成”。理解了这五类组件的边界和配合方式,LangChain一大半的架构逻辑就清楚了。
2.1 文档加载器与文本切分器
文档加载器解决的是“把各种乱七八糟的资料变成统一文本”的问题。PDF、Word、Markdown、HTML、数据库导出,格式完全不同,但LangChain提供了统一的Document对象,每个对象里有页面内容和metadata。metadata非常关键,一定要维护好来源文件名、章节号、更新时间,甚至权限标签,后面做精确过滤和引用溯源都靠它。
很多人上来就把PDF整个丢给切分器,其实不对。我一般先做一步“结构感知清洗”:表格转成文本描述、页眉页脚去掉、代码块保留缩进。目的很单纯,让切分器切出来的每一块都是语义相对完整的段落,而不是断在表格中间或句子中间。
然后才是文本切分器。我默认用的是RecursiveCharacterTextSplitter,它按“段落→句子→字符”的顺序逐层切,比固定长度硬切要聪明。这里的chunk_size和chunk_overlap两个参数值得花时间调:chunk_size是每一块的最大字符数,决定上下文粒度和向量化成本;chunk_overlap是相邻块的重叠字符数,防止一句话被从中间截断,导致语义缺失。我的经验是普通规章制度文档用500到800的块大小,重叠80到100;代码类内容用300左右;表格多的内容必须预处理。
2.2 Embedding模型与向量库
Embedding模型负责把文本变成一串数字向量,让语义相近的文本在向量空间里距离更近。这里要区分“生成embedding的模型服务”和“存向量的数据库”,两者很容易被混为一谈。我见过不少项目把向量库和embedding服务绑死,导致想换embedding模型时被迫换库,这是没必要的。embedding模型决定了你对语义的理解能力,向量库只负责存储和近似搜索,两者解耦,后面才好优化。
选embedding模型时,我主要看三个点:一是语言能力,中文场景优先看中文语料表现;二是向量维度,维度高理论上信息量更大,但存储和检索成本也更高;三是服务部署方式,线上调用还是本地部署。向量库方面,项目小的时候可以用轻量方案,数据量大了再迁移到分布式向量库,接口层面稍微封一层,迁移成本并不高。衡量召回结果的指标主要有两个:召回率和顶k命中率,前者看相关文档有没有被捞回来,后者看最前面几条有没有把正确答案放进去。
2.3 检索器与链
检索器在LangChain里是“输入问题、输出相关文档”的组件,它把向量库、打分器、重排器包装成一个简单接口。这样上层的问答逻辑不用关心你是用什么算法做召回,想从“向量召回”升级成“向量加关键词混合召回”,只需要换检索器,不用改问答逻辑。
链(Chain)则把整个过程串成流水线:模板、模型、输出解析器。现在LangChain更推荐用LangChain表达式语言来定义链,语法类似管道操作,把“上下文检索结果”和“用户问题”拼进提示词,交给模型,再把输出解析成结构化结果。用不用LangChain表达式语言是习惯问题,但对团队来说,统一表达数据流确实能减少沟通成本。
3. RAG核心工作流拆解:从文档入库到首轮回复
我会把工作流分成两个阶段:索引(Indexing)和检索生成(Retrieval and Generation)。这两个阶段的边界必须清楚,排错时才能快速定位问题出在哪一环。
3.1 索引阶段:清洗、切分、向量化、入库的先后顺序
索引是把原始文档变成可检索向量的过程,顺序不能乱:
- 文档加载与格式解析,把所有资料统一成
Document对象。 - 清洗与结构化,去掉噪声,把表格、图片里的关键信息整理成文字。
- 文本切分,按语义完整性切成块。
- 向量化,逐块生成embedding向量。
- 写入向量库,连同metadata一起存储。
- 建立索引与自检,抽查若干条query,确认能检索到对应文档。
这六步里最容易被跳过的就是第六步。很多人入库完直接开问答,等到效果差才开始回溯,效率极低。我在项目里会在入库后立刻写一个自检脚本,准备二三十个“问题-期望文档”对,批量跑召回,看命中率。这一步成本低,但能省下后面调提示词的大量时间。因为如果检索阶段就没有把正确答案捞进来,模型看得再多也不可能凭空答对。
3.2 检索阶段:query改写、混合检索与重排
用户的问题往往是口语化的,而且可能有指代,比如“那个报销流程是不是改了”。直接拿原话去做向量检索,效果通常打折扣。我习惯先给检索步骤加一个query改写:让模型把用户问题改写成更适合检索的短句,比如“报销流程最新规定”。这个改写和最后回答的模型调用可以共用一个接口,成本很低,但召回效果提升明显。
检索本身也不只一种方式。纯向量检索擅长语义相似,但遇到精确的数字、型号、人名,往往不如关键词检索。实践里我越来越多使用混合检索:向量召回一批,关键词召回一批,合并去重后交给重排模型打分。重排(rerank)听起来复杂,其实目的很朴素:初筛拿回来的候选可能有几十条,质量参差不齐,用一个更精细的排序模型重新打分,只留下最靠前的几条送进提示词。重排是投入产出比很高的一步,加与不加,答案质量差距肉眼可见。
3.3 生成阶段:提示词结构与引用溯源
检索到的文档块不能直接堆给模型,要组织成清晰的提示词结构。我常用的模板包含四部分:角色设定、资料上下文、用户问题、约束条件。约束条件必须明确写“优先使用资料原文,资料中没有的信息要明确说明不知道,不要编造”。这部分不仅是提示词规范,也是幻觉控制的基础。
引用溯源也是生成阶段的一部分。我会要求模型在回答末尾列出依据文档的编号,同时要求每个块在送入模型前携带来源metadata,这样既让用户可核对答案,也让开发者定位检索问题。对知识库场景,这条几乎是必备需求,因为答错了影响业务决策,没有出处就没人敢信。到这里,一个完整的RAG工作流就跑通了:先让资料变得可检索,再让问题变得可命中,最后让答案变得可追溯。
4. 一个可跑通的最小实战案例:企业内部知识库问答
标题说了“从理论到实战”,那就必须有代码。下面这个案例我故意做得很小,目的是让你看清楚数据流,而不是被包管理问题拖住。
4.1 环境准备
我默认你已经有Python 3.10以上的环境,并且能访问一个对话模型接口和一个embedding模型接口。代码里我会把模型服务调用封装成伪接口,你用自己实际的服务替换即可。
pip install langchain langchain-community langchain-text-splitters版本变化很快,建议装完后锁定版本,不要直接在新项目里用“最新版”裸奔,否则隔两周你的导入路径可能就变了。
4.2 索引脚本
# build_index.py from langchain_community.document_loaders import DirectoryLoader from langchain_text_splitters import RecursiveCharacterTextSplitter # 1. 加载某目录下所有 md 文档 loader = DirectoryLoader("docs", glob="**/*.md") docs = loader.load() # 2. 切分 splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80) chunks = splitter.split_documents(docs) # 3. 向量化 def embed_texts(texts): # 这里调用你实际使用的 embedding 模型服务 # 返回与 texts 一一对应的向量列表 ... # 4. 入库(伪代码,请替换为实际向量库客户端) def index_chunks(chunks): texts = [c.page_content for c in chunks] metadatas = [c.metadata for c in chunks] vectors = embed_texts(texts) vector_store.add_texts(texts, vectors, metadatas) if __name__ == "__main__": index_chunks(chunks)注意切分后的metadata里保留了原文档路径,这是后面“引用溯源”的基础,不要在这里丢掉。
4.3 问答脚本
# ask.py def retrieve(query: str, top_k: int = 5): query_vec = embed_query(query) hits = vector_store.search(query_vec, top_k=top_k) return hits def build_prompt(query: str, contexts: list) -> str: context_text = "\n\n".join( f"[{i+1}] {c.page_content}" for i, c in enumerate(contexts) ) return f"""你是企业知识库助手。 请严格基于以下资料回答问题。 资料中没有的信息,请直接说明“资料中未找到相关内容”,不要编造。 回答末尾请附上参考片段编号。 {context_text} 用户问题:{query} """ def ask(query: str): contexts = retrieve(query) prompt = build_prompt(query, contexts) answer = chat_model(prompt) return answer, contexts这段代码看起来精简,但整个RAG的链路已经全有了。我实测在几十篇文档的小型知识库上,常见问题的可接受回答率在八成以上,前提是文档切分没有大问题、embedding模型选得合适。
4.4 我实测的效果与优化方向
回到上案例,加上query改写和重排之后,原来“漏掉正确答案”的现象少了很多。再往后优化空间基本都在数据端:文档标准化整理、元数据规范化、更新策略。系统做到一定程度后,你会发现瓶颈通常不是模型,而是“资料本身能不能被检索到”。
5. 我踩过最深的三个坑:切分、召回与提示词
说实话,RAG项目的坑基本集中在数据端和检索端,模型反而是最省心的。下面三个坑是我在不同项目中反复掉的,拿出来分享。
5.1 切分粒度与召回效果的微妙关系
第一次做知识库时,我图省事把整个章节作为一块入库,chunk_size设得很大,结果用户问一个细分问题时,召回出来的内容太宽泛,模型回答得模棱两可。后来把块大小调到300到500,细粒度块让命中更精准,回答质量明显提升。但也不能死记这个数字:如果块太小,一条完整逻辑被切散,模型拿到的上下文不完整,照样答不好。切分是在“语义完整”和“召回应答精准”之间找平衡,没有绝对正确的参数。
我现在的做法是按文档类型给不同参数:章条式制度用500到800,技术手册用300到500,表格型文档先转成描述文本再做语义切割。每改一次参数,都要用自检集验证,而不是凭感觉。
5.2 相似度阈值不能跨库直接复用
另一个翻车点:我在一个项目里用某一套embedding服务,调用时习惯性设定相似度0.7以上才算相关,结果换了一家模型服务后,同样的文档、同样的问题,分数普遍降到0.6以下,系统几乎答不出任何问题。原因很简单,不同embedding模型产出的向量分布不一样,相似度分数根本没有跨模型可比性。阈值一定要结合你自己的数据集做校准,办法也简单:抽一批已知相关和无关的问题对,分别算分,看分布差异,再取一个能把两者分开的阈值。直接照抄网上的数字是最不靠谱的。
5.3 提示词里没有强调“引用原文”就会开始编
有一次我在演示环境下不加“必须基于资料”的约束,模型遇到资料里没有的细节,会用很流畅的语言自动补全,而且完全看不出破绽。这不是模型故意撒谎,而是它默认要给出“像样”的回答。在RAG场景下,提示词里的约束强度直接决定幻觉程度。我现在每次都会写死这几条:资料中没有就明说;不要猜测;回答末尾标注依据编号。如果还出现幻觉,就检查是不是相关文档压根没被召回来,而不是继续堆提示词。
6. 一次完整排查:库里明明有答案,为什么它答不出来
为了让你感受真实的排查链路,这里还原一次我在某内部知识库问答项目里遇到的问题。这可能是RAG落地过程中最典型的一种失败场景。
6.1 现象与初步确认
用户问的是:“最新的研发报销标准是什么?”系统回答:“资料中没有找到相关内容。”但我很确定资料库里刚上传了新版报销制度文档。第一步不是改提示词,而是确认资料是否真的入库了。我直接查向量库里有没有这个文档的块,发现确实有,而且切分结果看起来正常。于是问题进一步缩小:检索阶段没把该文档的块捞出来。
6.2 逐层定位的排查路径
我把用户原话拿到检索接口里跑了一遍,看返回的前10个块是什么,结果返回的全是其他制度文档,压根没有报销文档。这就更奇怪了,语义上明明相关。接着我怀疑是措辞差异,就把query改成“报销标准”,结果命中了。关键词变动带来的差异相当大,问题定位到“用户口语表达与文档表述的语义鸿沟”上。
再进一步看,为什么“最新的研发报销标准是什么”和“报销标准”差这么多?原来文档里写的是“研发费用报销细则”,向量模型在语义匹配时把“最新”“标准”带偏到其他文档;而且“研发报销”这种组合词,在这个场景下的意图和文档描述并不完全一致。单纯向量检索容易在这种细节上吃亏。我在这一步给问题加了query改写环节,先把用户问题转成检索关键词,然后再走混合检索,结果立刻正常了。
6.3 修复后的效果对比
| 方案 | 是否命中目标文档 | 回答可用性 |
|---|---|---|
| 原query直接向量检索 | 否 | 差 |
| 加query改写 | 是 | 中 |
| 加改写+混合检索+重排 | 是 | 好 |
修复之后,类似的“看似没收录,其实没召回”的问题大幅减少。这个例子说明,排错的时候要先分清是“数据没进去”还是“没被检出来”还是“模型没生成好”,按这个顺序推进,大部分问题都能在三步内定位,而不是反复调整提示词碰运气。
我整理这套流程花了不少时间,最想强调的还是那句话:RAG的效果上限由资料质量和检索质量决定,模型只是最后一棒。我在项目里现在习惯先花60%的时间在资料清洗、切分参数和检索自检上,最后再调提示词,效果比反着来的项目稳定很多。还有一个容易忽略的小技巧:每次修改切分或检索策略,都保留一个基线自检集,记录命中率和回答质量评分,不然很难判断某个调整到底是在变好还是变坏。这条经验几乎适用于所有知识库类需求。如果你正准备上LangChain和RAG,建议先用最小闭环跑通,再逐步扩展,不要一上来就追求大而全的架构。