☰
LangChain+RAG实战指南:从索引构建到检索生成全流程落地
2026/10/22 15:28:44 网站建设 项目流程

有没有遇到过这种情况:好不容易接入了大语言模型,结果它一本正经地编造不存在的事实,对最新的内部资料一问三不知,还被老板追问“这套系统到底能不能落地上线”?如果你正在做知识库问答、私有文档检索、企业内部智能助手这类项目,那你大概率绕不开一个词——RAG(Retrieval-Augmented Generation,检索增强生成)。这篇文章我会从实战视角出发,用LangChain把RAG架构从头到尾拆一遍,包含索引构建、检索优化、生成链路的完整实现,以及我实际踩过的坑和排查经验,给想快速落地RAG项目的同学一份真正能“抄作业”的参考。

1. 为什么是RAG + LangChain:架构选型背后的核心思考

1.1 RAG到底解决什么问题

先看一个最典型的场景:企业内部的员工手册、产品文档、历史项目报告散落在各个角落,你要让大模型基于这些内容回答提问。直接问大模型不行——它训练时没见过这些私有数据,也没法知道一周前刚更新的版本。你当然可以微调模型,但微调成本高、周期长,而且每次资料更新都得重新训练,运维负担非常重。

RAG的思路非常朴素:既然模型不知道答案,那就先“查资料”,再把查到的资料作为上下文塞给模型,让模型基于资料回答。整个过程分两段:索引段(把文档切碎、向量化、存进向量数据库)和检索引擎(把用户问题转成向量去数据库里捞相关片段),最后进入生成段(把检索结果和问题组装成Prompt交给大模型)。

RAG能火起来,表面上是解决了“幻觉”和“知识时效性”问题,本质上其实是把“模型记忆”和“外部知识”解耦了。模型只需要负责理解与生成,知识从哪里来、什么时候更新,都由外部的检索系统控制。这个设计在工程上的收益极大:想更新知识,重新跑一遍索引就行,模型本身完全不用动。

1.2 直接手写Pipeline vs 使用LangChain

其实RAG的技术链路并不复杂,每一步都有现成的SDK:用PDF解析库读文档,用开源嵌入模型做向量化,用向量数据库的客户端做检索,最后拼一个Prompt调大模型。理论上不用LangChain也能做,我最初就在一个模拟项目X里纯手写过一遍,调了各种库拼装,代码很快就变得难以维护,原因有两个。

一是胶水代码太多。文档加载、切分、向量化、存储、检索、Prompt组装、输出解析,每个环节都有各自的配置和异常处理,一旦接口升级或换组件,改动就蔓延到全链路。

二是检索逻辑的复杂度被低估了。生产环境不可能只做“单路向量检索”,通常要做查询改写、多路召回、重排序、过滤条件组合。这些逻辑自己写,工作量不小,而且容易出边界问题。

LangChain的价值恰恰在于编排,它不是帮你做某个环节,而是把整条流水线抽象成可组合的组件。Document Loader负责加载,Text Splitter负责切分,Embeddings负责向量化,VectorStore负责存储与检索,Retriever和Chain负责把检索和生成串起来。你想替换任何一环,只需要换一个组件实例,不需要改动整体结构。对于做工程落地的人来说,这种“可替换性”比什么都重要。

当然,LangChain也不是银弹。如果你的项目极其简单,只有几十个文档,不需要复杂的查询分析,那直接调向量数据库的API更轻量。LangChain的适用边界是链路复杂、需要灵活编排、需要多组件协同的场景。一开始就判断好复杂度,别为了框架而框架。

2. 索引构建实操:从一堆文档到可用的向量库

2.1 文档加载与清洗:这一步决定后面的一切

RAG的第一道工序是把原始文档读进来。很多人觉得这步简单,无非就是调loader。实际上大部分RAG项目效果不好,根因恰恰是文档加载和清洗没做好。

常见的文档类型有这么几类:PDF、Word、Markdown、HTML,还有数据库导出的文本。LangChain为每类都提供了对应Loader:

from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader, UnstructuredMarkdownLoader, TextLoader # PDF:注意 PDF 可能包含扫描件,需要 OCR,PyPDFLoader 只处理文本层 pdf_loader = PyPDFLoader("docs/产品手册.pdf") # Word:Docx2txtLoader 依赖 python-docx word_loader = Docx2txtLoader("docs/需求文档.docx") # 纯文本:指定编码很关键,避免乱码 text_loader = TextLoader("docs/notes.txt", encoding="utf-8")

我踩过的第一个坑是PDF里的表格。PyPDFLoader会把表格内容按文本流读出来,行列顺序经常错乱,存入向量库后检索出来的片段根本没法看。后来处理带表格的文档,我一般用Unstructured库的partition_pdf配合模式参数,表格可以单独提取为HTML结构或图片再额外处理。如果表格是核心知识,千万别直接用默认loader。

清洗也是一个容易被低估的环节。文档里的页眉页脚、导航栏、水印、重复的标题,如果不清掉,都会被切分和向量化。别指望嵌入模型自动区分“这是页眉”,模型只会忠实地把噪音也编码进去。我习惯在加载后做一轮规则清洗:去掉空行、规范化空白字符、去掉明显的页眉页脚、过滤过短的无意义段落。

实操建议:清洗代码放在加载之后、切分之前。保持清洗逻辑独立成函数,以后换文档源只需要增加新的清洗规则,不用动切分和检索逻辑。

2.2 文本切分策略:chunk_size怎么定才算合理

文档清洗完,下一步是切分。这一步堪称RAG的“命门”,因为切分直接决定了检索的最小粒度。切得太粗,单个片段塞进上下文会占用大量token,而且一个chunk里如果混了多个主题,检索相关性会被稀释;切得太细,语义可能不完整,一句话被拦腰截断,检索到了也没法回答问题。

LangChain最常用的切分器是RecursiveCharacterTextSplitter,它按一组分隔符的优先级递归切分,优先保留段落、句子这些自然边界:

from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=50, length_function=len, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ",", ",", " ", ""], )

参数选择上,我建议chunk_size先定在300~800之间,具体要看你的文档类型和嵌入模型的上下文长度。像BGE这类模型窗口一般是512个token,如果你切的是中文文本,512个token差不多对应400~700个汉字。chunk_overlap一般取chunk_size的10%~20%,目的是让相邻片段共享一小段重叠文本,避免某个知识点正好落在切割线上导致丢失。50~100的overlap对于512的chunk_size是比较稳的区间。

我踩过的第二个坑是忽视文档结构。对于有明确章节的Markdown或HTML文档,用全文本切分器会把标题和正文拆散,检索出来的片段丢失了上下文。后来我改用MarkdownHeaderTextSplitter,它先按标题层级把文档拆成带结构信息的小节,再在节内做长度切分,检索结果会带上标题路径,模型能知道这段话出自哪个章节,回答质量明显提升。

from langchain_text_splitters import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "H1"), ("##", "H2"), ("###", "H3"), ] splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) splits = splitter.split_text(markdown_document)

如果你处理的是代码仓库或者日志,还应该考虑按函数定义、时间戳来切。核心心法只有一条:切分要尊重内容的天然边界,而不是机械地按字符数砍。

2.3 嵌入模型选型与向量库配置

切分完成之后,每个chunk需要转成向量。这一步要选一个合适的嵌入模型(Embedding Model)。选型时主要看三点:维度与效果、中文支持度、部署成本。

市面上最省事的是直接调用云端Embedding接口,比如OpenAI的text-embedding-3-small,质量稳定,但你的文本要出网,很多企业内部场景过不了这关。私有化部署场景下,我比较推荐国产开源模型,比如BGE系列(BAAI/bge-large-zh-v1.5)和M3E系列,中文效果不错,在CPU上也能跑。以BGE为例,使用方式如下:

from langchain_community.embeddings import HuggingFaceBgeEmbeddings bge_embeddings = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-large-zh-v1.5", model_kwargs={"device": "cuda"}, encode_kwargs={"normalize_embeddings": True}, )

注意这里有个细节:normalize_embeddings要设成True。向量相似度计算用的是余弦相似度,归一化之后所有向量模长为1,点积就等价于余弦相似度,计算效率更高,用向量数据库的默认内积距离也能得到正确结果。

向量库的选型,用一句话总结:项目小、想快速验证选Chroma;项目大、要上生产推荐Milvus或Qdrant;已有ES集群且团队熟悉它的查询语法,可以选ES的knn检索能力。

向量库适合场景部署方式特点
Chroma原型验证、小规模个人项目嵌入式/本地简单,零运维,适合学习
Qdrant中小规模生产Docker/云过滤能力强,支持丰富Payload
Milvus大规模生产分布式集群性能强,适合海量向量
阿里云/ES已有ES体系云服务/自建复用现有基础设施,混合检索方便

无论用哪个库,元数据都要认真存。每个chunk对应的来源文件名、章节标题、页码、更新日期,这些信息不仅要存,还要在检索阶段用上。来源信息有两个用处:一是回答时可以附上引用出处,方便人工验证;二是可以做过滤条件,比如只检索某个目录或某个时间之后的内容。

3. 检索与生成链路实现:从能跑到跑好

3.1 用LangChain搭建一条基础版RAG问答链路

索引建好之后,就到了最让人兴奋的部分:把检索和生成串起来。用LangChain搭一条可用的基础链路其实很快,下面这段代码就是最小可用版本:

from langchain_community.vectorstores import Chroma from langchain_openai import ChatOpenAI from langchain.chains import RetrievalQA # 加载已有向量库 vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=bge_embeddings, ) # 配置检索器:检索前4个片段 retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) # 配置大模型 llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.1) # 组装问答链 qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, return_source_documents=True, ) result = qa_chain.invoke({"query": "员工年假最长可以休几天?"}) print(result["result"])

RetrievalQA的from_chain_type默认使用stuff模式,也就是把所有检索到的片段拼接后一次性塞进Prompt。这个模式在片段少、总token量可控的情况下最简单有效。但如果检索返回的片段很多,或者单个片段很长,stuff模式会把上下文撑爆,此时就要换map_reduce或refine模式。我的经验是:能用stuff就用stuff,上下文越完整、模型信息损失越少,另两种模式都会增加一次甚至多次额外的大模型调用,成本和延迟都会上升。

temperature参数我习惯调低到0.1左右。RAG场景下我们希望模型忠实于检索到的资料,而不是发挥创造力,温度调低可以减少自由发挥的空间。return_source_documents=True这个参数务必打开,线上排查问题时,你要能看到每一步回答到底引用了哪些片段,否则出了问题只能干瞪眼。

3.2 进阶:查询改写、多路召回与重排序

基础链路跑通之后,你会很快发现它的召回质量问题。用户的问题常常是口语化的,比如“咱们公司加班怎么算钱”,和文档里的书面表述“加班工资按照...执行”在字面上相差很大,纯向量检索可能召回不到。此时需要做查询改写。

查询改写最简单的实现是用大模型把用户问题改写成更规范、更贴近文档表述的形式,或者生成多个不同角度的子查询:

from langchain.prompts import PromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough query_rewrite_prompt = PromptTemplate.from_template( """你是一个搜索专家,请根据用户问题生成{num}个不同表述的搜索查询,用于检索知识库。 要求:涵盖关键词变体、同义词、不同句式的表述,每个查询用一行输出。 用户问题:{question}""" ) rewrite_chain = ( {"question": RunnablePassthrough()} | query_rewrite_prompt | llm | StrOutputParser() )

改写出的多个查询分别去检索,再把结果合并去重,就是多路召回。这一步能明显提升召回率,但要注意控制召回总量:4个查询各返回4个片段,合并后可能有十几个片段,全部塞给模型既费token又稀释注意力。此时就需要引入重排序。

重排序(Rerank)的思路是:第一轮用嵌入向量做粗召回(快但精度有限),第二轮用一个更重的模型对“查询-文档”对做精细相关性打分,只保留分数最高的片段。LangChain社区里支持多种reranker,我常用的是CrossEncoderReranker搭配BGE的reranker模型:

from langchain_community.cross_encoders import HuggingFaceCrossEncoder from langchain_community.retrievers.document_compressors import CrossEncoderReranker from langchain.retrievers import ContextualCompressionRetriever reranker = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-large") compressor = CrossEncoderReranker(model=reranker, top_n=3) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=retriever )

用了重排序之后,检索质量基本会上一个台阶。代价是响应时间变长,CPU机器上尤其明显。如果对延迟敏感,可以在重排序前先按时间或标签做一次过滤,缩小候选集。我在实际项目里的经验是:重排序的候选集控制在10~20个片段以内,最终保留3~5个片段,这是质量、延迟、成本三者比较平衡的点。

3.3 Prompt设计:怎么让模型“老实”地使用检索结果

检索质量再好,如果Prompt写得不行,模型照样答非所问。RAG场景的Prompt设计有几个要点。

第一,明确告诉模型只能基于提供的上下文回答。这句话要写得很直白,并且约定好“资料里没有就明确说不知道”,不要自己编。加了这句话之后,幻觉率会肉眼可见地下降。

第二,把上下文和问题分开包装得很清楚。用XML标签或Markdown引用块把上下文包起来,划定清晰的边界,模型不容易把资料里的内容和用户问题混在一起。

第三,要求模型在回答中标注引用来源。让模型用[1]、[2]这样的标号引用对应的上下文片段,既方便追溯,也在形式上引导模型严格依据资料做答。

rag_prompt = PromptTemplate.from_template( """你是一位企业内部知识助手,请严格基于以下资料片段回答用户问题。 资料片段: {context} 注意事项: 1. 只使用资料中出现的信息回答,不要添加任何推测或编造。 2. 如果资料中找不到答案,请直接回复“资料中未找到相关信息”。 3. 回答时请在对应句子末尾用[序号]标注依据的资料编号。 4. 回答使用简体中文。 用户问题:{question} """ )

这个Prompt模板结合LangChain的create_retrieval_chain和create_stuff_documents_chain使用即可:

from langchain.chains.combine_documents import create_stuff_documents_chain from langchain.chains.retrieval import create_retrieval_chain combine_docs_chain = create_stuff_documents_chain(llm, rag_prompt) rag_chain = create_retrieval_chain(compression_retriever, combine_docs_chain) answer = rag_chain.invoke({"input": "员工年假最长可以休几天?"})

注意create_retrieval_chain中的输入字段名默认是input,输出的答案字段在answer里,中间产物在context里。调试时可以打印context检查检索质量,这是每天排查问题都要用到的核心手段。

4. 实战排查实录:那些测试时没发现、上线才爆的问题

4.1 检索不到内容:十有八九是切分和编码的锅

召回为空是最常见的线上问题。用户问了一个问题,系统回答“资料中未找到相关信息”,但资料库里明明有相关内容。排查路径一般这样走。

先打印检索结果,看向量检索到底返回了什么。如果返回了不相关内容,说明相似度计算或嵌入模型对领域文本的适配有问题;如果连结果都没有,问题多半出在索引侧。我遇到过一次最隐蔽的坑:某些PDF文档用PyPDFLoader读出来是正常的,但文本里有大量全角空格和不可见字符,切分出来的chunk看起来正常,实际上被嵌入模型编码成了很奇怪的向量,跟查询的相似度极低。后来加了一道字符规范化(全角转半角、去掉零宽字符),召回率立刻上来了。

经验:遇到检索为空,不要先怀疑向量数据库,先用关键词在原始chunk文档里搜索,判断内容到底有没有进库、有没有被正确切分。大多数时候是源头的脏数据问题。

4.2 模型回答千篇一律:上下文太长稀释了关键信息

另一个高频问题是:模型给出的回答总是泛泛而谈,没有针对用户问题中的关键细节作答。打开context一看,返回的片段里确实包含相关信息,但被其他不相关的长片段淹没了。这个问题的根源通常是召回太多和切分太大。

解决办法有三个方向:降低top_k,从4降到2~3;缩小chunk_size,让每个片段更聚焦;加强重排序,确保最相关的片段排在前面。从成本和延迟的角度看,前两个是免费的,建议先做。还有一个进阶手段是给chunk打上关键词标签,在检索阶段,如果用户问题里包含某个标签词,直接过滤出对应的chunk,把范围缩得很小。

4.3 表格内容答错:解析层的坑要到源头去补

涉及表格数据的回答,准确性是最容易翻车的。问题不在生成模型,而在最初的文档解析。PDF里的表格被PyPDFLoader读出来后,行列关系已经乱了,后续所有处理都是基于错误数据。这个坑必须在解析阶段解决。

我的处理方式是:把文档里的表格单独提取出来,一张表作为一个独立的chunk存储;回答问表格内容的问题时,优先走表格问答链路,用结构化的方式处理表格,而不是当作普通文本。如果表格非常复杂,另一种更稳妥的方案是直接把表格转成图片,用多模态模型识别,在RAG项目里这属于“外挂能力”,可以在后续迭代中逐步加入。

4.4 模型强行编造来源:输出解析和引用对齐问题

开启了来源标注之后,模型偶尔会编造出并不存在的[3]引用,或者把引用标号张冠李戴。这种问题一方面靠Prompt约束,另一方面可以在后处理阶段做一个校验:解析出模型回答中所有的引用标号,检查对应的片段是否真实存在于本次检索结果中,不存在的就删掉引用或整句标红提示。

这个校验逻辑不复杂,但很实用。我通常在回答链后面接一个后处理函数,把答案字符串中的[数字]全部提取出来,和context中的文档编号做比对,非法引用直接替换为“(内容待核实)”。这样即使模型偶尔不老实,输出也不会误导使用者。

5. 项目落地:从Demo到生产环境的最后几步

5.1 别忘了评估环节:没有标准就谈不上优化

很多团队的RAG项目止步于“能跑通”,却说不清“跑得有多好”。等到优化的时候,改了一版检索参数,也不知道是变好了还是变差了。所以要尽早建立一套评估集。

评估集不需要多,50~100条典型Q&A对就够了。每条包含:用户问题、标准答案(或答案要点)、期望命中的文档片段。之后每次改动索引、检索策略或Prompt,都用同一套评估集跑一遍,统计三项指标:命中率(期望片段是否被召回)、忠实度(回答是否基于召回内容)、正确率(回答内容与标准答案的一致性)。

LangChain的社区里也有评估工具,比如LangSmith可以用来追踪每次调用的检索片段和最终回答。无论用什么工具,评估集才是核心资产。没有评估,所有优化都是在盲调。

5.2 性能与成本控制:三个容易被忽视的细节

RAG链路的延迟主要有三部分:检索、重排序、生成。检索本身很快,几十毫秒量级;重排序最耗时,尤其在CPU上跑cross-encoder;生成最慢,取决于输出token数。

成本控制方面,最值得关注的是不要把大段无关文本塞进Prompt。很多RAG实现为了省事,把top_k调大,每个chunk都完整放进上下文,结果一次问答消耗几千token。优化之后每轮问答可以控制在1000 token以内,效果反而更好。另一个细节是给嵌入模型和reranker做批量推理,索引构建阶段大批量向量化时能节省可观时间。

5.3 一个可复用的目录结构

最后分享一个我在模拟项目X中沉淀下来的RAG项目目录结构,供参考:

rag_project/ ├── configs/ │ ├── splitter_config.yaml # 切分参数配置 │ └── prompt_template.yaml # Prompt模板 ├── data/ │ ├── raw/ # 原始文档 │ └── processed/ # 清洗后的文档 ├── src/ │ ├── loaders/ # 文档加载器封装 │ ├── splitters/ # 切分逻辑 │ ├── embedders/ # 嵌入模型封装 │ ├── retrievers/ # 检索与重排序逻辑 │ ├── chains/ # 问答链路组装 │ └── evaluate/ # 评估脚本 ├── vectorstores/ # 向量库持久化目录 ├── tests/ # 单元与集成测试 └── main.py # 入口

把配置和代码分开尤为重要。切分参数、Prompt模板、检索参数(top_k、重排序候选数)经常需要调整,如果硬编码在代码里,每次实验都要改代码,容易改乱。用YAML配置文件统一管理,再配合评估集,你可以迅速地做参数比对实验,找到当前文档集上的最优配置。

回到开头的问题。RAG不是一个高门槛的技术概念,但它是一个对细节极其敏感的系统工程。用LangChain能把流水线快速搭起来,但真正拉开效果差距的,往往藏在文档切分粒度、查询改写策略、重排序开关以及Prompt边界设计这些容易被忽略的细节里。我建议你从小型文档集起步,先把基础链路跑通,再对照这篇文章逐项做优化。等你把检索质量调上来了,你会明显感觉到模型回答的“踏实感”——每句话都有出处,每个结论都经得起追问。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询