☰
基于LangChain从零构建RAG基础版:文档问答系统实战与避坑指南
2026/10/19 4:07:04 网站建设 项目流程

RAG 这两年几乎是每个做 AI 应用的人都绕不开的话题。简单说,就是先把文档切碎、向量化,建成一个能快速搜索的“资料库”,用户提问时先从库里找回相关片段,再把这些片段连同问题一起交给大模型生成答案。基于 LangChain 框架去做这个流程,是当前上手最快、生态也最完整的一条路。

这篇文章不聊高深理论,我把自己从零搭一个“RAG 基础版”的完整过程整理出来:数据加载、文本切分、向量化、检索、生成,每一环怎么选型、怎么写、有哪些坑。适合刚开始接触 RAG、想快速跑通一个能回答自己文档问题的开发者,也适合已经照着教程跑通过、但不太清楚每步为什么要这么做的朋友。

需要提前说的是,基础版讲的是“最小可用”,主线很清晰:文档进、答案出。我尽量把每个选择背后的理由说清楚,这样你后面想往生产环境走,也知道该在哪些地方下功夫。

1. 先想清楚:基础版 RAG 到底解决什么问题

1.1 RAG 是什么,以及它为什么能缓解“编答案”

大模型在训练时看到的语料是固定的,如果你问它一个内部文档里的问题,它大概率会一本正经地胡说。RAG 的做法是“先查资料、再回答”——把回答的依据从模型参数里,挪到外部可检索的文档库里。

这个思路有三个直接好处。第一,答案可以带上来源,别人问“你凭什么这么说”,你能翻出原文。第二,资料更新成本低,改文档就行,不用重新训练模型。第三,模型只需要基于一小段相关内容做推理,回答的准确度和可控性都会好不少。

我见过不少人把 RAG 理解成“把 PDF 喂给大模型”,其实没那么简单。PDF 只是数据源,重点在于:怎么切、怎么存、怎么找、怎么把找到的内容以正确的姿势交给模型。这四个问题处理好了,整个系统才稳。

1.2 为什么选 LangChain,而不是自己拼一堆 SDK

你可以不用 LangChain,直接调 embedding 接口、调向量数据库、调大模型,拼出来也就几十行代码。但 LangChain 的价值在于它把这条链路抽象成了标准组件:加载器、切分器、向量库、检索器、模型封装。

对于基础版,这个抽象最大的好处是“每个环节都能独立替换”。你今天用 Chroma 做向量库,明天想换成别的,改一行配置就行;今天用外部模型接口,明天换本地开源模型,也只需要动模型封装那一层。对刚开始做 RAG 的人来说,这种可替换性比性能更重要,因为你还在摸索阶段,很可能跑通之后发现某个环节不合适,要换掉。

另一个好处是社区资料多,踩坑记录也多。你搜“某个加载器报错”,几乎都能找到解决方案。自己拼虽然灵活,但出了问题要自己从头排查,对新手不友好。

2. 核心链路拆解:一个最小 RAG 由哪几环组成

2.1 离线建库:加载、切分、向量化、入库

RAG 分两条线,先看离线这条。

第一步是加载文档。LangChain 里叫 Document Loader,不同类型的文件对应不同的加载器:PDF 有专门的处理方式,纯文本和 Markdown 又是另一套,Word、网页也各有各的加载器。这一步的目标是统一的:把各种乱七八糟的格式,变成同一种结构——Document,也就是“内容 + 元数据”。元数据很重要,后面检索过滤就靠它。

第二步是切分。这是基础版里最容易被低估的一步。原始文本不适合直接向量化,因为太长,一刀切的话,一片文档的向量信息会被平均稀释掉,检索时反而找不到关键内容。所以要把文档切成一个个大小合适的 chunk,顺序上还要保证语义相对完整。

切分没有标准答案,我一开始就选了个比较省心的参数:chunk_size 设 500,chunk_overlap 设 50。这个参数的意思是每个块最多 500 个字符,块与块之间重叠 50 个字符。重叠的目的,是避免一句话或一个知识点刚好被切成两半,两边都缺上下文。实际效果后面还会细说。

第三步是向量化,也就是把每个 chunk 转成一组浮点数。这一步要选 embedding 模型,不同模型的输出维度不同,对语义的理解能力也不同。基础版不需要追求最强模型,关键是“中文场景选对模型”,否则后面检索效果会很飘。

第四步是入库。向量化之后的 chunk 连同向量一起写入向量数据库。基础版用 Chroma 就非常合适,它是本地嵌入式库,不需要独立部署服务,启动即用,数据落在本地磁盘。

2.2 在线问答:检索、拼接、生成

离线建好库之后,线上问答就是三步。

第一步,把用户的问题也做一次向量化,然后在库里做相似度搜索,找出和问题最相关的几个 chunk。基础版通常是“余弦相似度”或“点积相似度”,这一步的结果质量,直接决定最终答案的上限——如果检索到的片段本身就是错的,大模型再怎么聪明也答不对。

第二步,把检索到的 chunk 按顺序拼接成一个上下文,塞进提示词模板里。注意这里有个隐藏问题:chunk 的原始顺序。如果多段内容来自同一份文档,但检索结果把它们打乱了,拼接出来的上下文可能逻辑颠倒。基础版可以先用文档来源和页码排个序,后面进阶再做精细处理。

第三步,大模型根据“资料 + 问题”生成答案。这一步的关键是提示词约束:明确告诉模型,资料里没有的信息不要编。基础版用固定的提示词模板就够了,不需要搞复杂的 CoT 之类的东西。

2.3 三个容易忽略但很关键的选择

选型上有三个点我建议一开始就定下来,后面少走弯路。

embedding 模型的选择。如果文档是中文为主,不要拿通用英文 embedding 硬跑,效果会很差。开源中文模型里,bge 系列是被验证过很多次的,小模型参数少、本地能跑、效果也够用。基础版用 bge-small-zh 这个级别就够了,没必要一上来就上大模型。

向量库的持久化路径。Chroma 默认会把数据写到某个本地目录,这个目录要在一开始就规划好,别放在临时目录里。否则重启一次服务,发现检索结果全没了,你都不知道是在哪一步丢的。

返回的 chunk 数量。检索器返回几条片段,这个数是大有讲究的。返回太多,上下文物超所及,模型容易被无关信息干扰,还浪费 token;返回太少,可能漏掉关键信息。我一般先从 4 条开始调,再根据问题的复杂程度微调。

3. 实操:两小时搭一个最小可用的 RAG

3.1 环境准备与依赖安装

下面这套环境我实际跑过,语言用的 Python 3.10,虚拟环境建议用 venv 或 conda 单独建一个,避免和系统环境冲突。

pip install langchain langchain-community langchain-openai langchain-chroma chromadb pypdf

如果你想把 embedding 也换成开源本地模型,再加一个:

pip install langchain-huggingface sentence-transformers

安装过程可能遇到一个小麻烦:sentence-transformers 会顺带拉一些依赖,如果网络不好会卡很久。建议先用清华镜像源加速,或者提前把该装的 PyTorch 装好。

3.2 数据准备与文档加载

我先准备了几份 Markdown 格式的产品说明文档,里面包含功能介绍、配置参数、常见问题。为了顺利,我把文件名命名为“产品说明-1.md”、“产品说明-2.md”这样,方便批量加载。

加载用 LangChain 的目录加载器,它会把目录下所有匹配后缀的文件读进来,变成一个 Document 列表:

from langchain_community.document_loaders import DirectoryLoader, TextLoader loader = DirectoryLoader( "./docs", glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() print(f"加载了 {len(docs)} 个文档")

如果数据是 PDF,也可以换成 PyPDFLoader,但那会逐个文件加载,比较慢。目录加载方式的好处是批量化,文件多了不用手动写循环。

有一个细节:加载之后最好打印一两个 Document 的 page_content 看看,确认编码没有乱,内容没有缺行。我踩过一次 UTF-8 编码没指定,导致中文全乱码,向量化出来检索效果完全不可用的坑,所以这一步别省。

3.3 文本切分与向量化入库

切分器我用的是 RecursiveCharacterTextSplitter。它的逻辑是“递归地找分隔符切分”,先按段落分隔,再按句子,再按字符,尽量保证语义完整。这个切分器是 LangChain 社区里用得最多的,因为对大多数文档都有效。

from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", " ", ""], ) chunks = text_splitter.split_documents(docs) print(f"切分后共 {len(chunks)} 个片段")

注意 separators 这一行。我把中文句号、感叹号、问号也加进了分隔符列表,这样切分时会更贴近中文的语义边界。默认 sep 对英文友好,中文文本不调整的话,经常会把一整段不切分,或者从奇怪的位置切断。

切完之后,每个 chunk 会保留来源文件的元数据,这一点很重要,回答问题时可以回指出处。

接着做向量化。这里我以 OpenAI 的 embedding 接口为例,因为申请方便、效果稳定:

from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

如果你想完全离线、免费跑,可以换成:

from langchain_huggingface import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, )

然后建库。Chroma 的用法非常直接:

from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", ) print("向量库创建完成")

第一次跑这段代码会有点慢,因为要加载 embedding 模型 + 把所有文档向量化。如果文档量不大,应该在一分钟内完成。向量库建好之后,数据会存在 ./chroma_db 目录里,下次启动代码时可以直接从持久化目录恢复,不用重新向量化。

从持久化目录恢复的写法:

vectorstore = Chroma( embedding=embeddings, persist_directory="./chroma_db", )

这个用法在调试阶段特别有用,文档没变的话,不用反复跑昂贵的向量化流程。

3.4 检索与生成主流程

建好库之后,先把检索器定下来:

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

这里 k=4 是每次检索返回 4 个片段。这个数字我建议先保持默认,后面根据答案质量再调。

然后定义大模型和提示词。我示例里用 OpenAI 的接口:

from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

temperature 必须设置为 0 或者接近 0。原因很简单,RAG 场景希望模型忠实基于给定资料回答,而不是发挥创造力。temperature 一高,模型就可能自己加戏。

提示词模板是整套系统的“隐藏大脑”:

from langchain.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_template(""" 你是一个精准的问答助手。请严格基于以下资料内容回答用户问题。 资料: {context} 用户问题:{question} 要求: 1. 优先使用资料中的原文作答。 2. 如果资料中没有相关信息,请明确回答"当前资料中未找到相关内容",不要编造。 3. 回答尽量简明,控制在 200 字以内。 """)

注意我在提示词里显式约定了“资料中没有就不要编”。这个约束极其重要,没有它,模型很容易在资料缺漏时脑补出看似合理但实际上错误的答案。

最后把整条链路串起来。这里我用 LangChain 新版的 LCEL 语法,它比旧的 RetrievalQA 更直观,也更容易调试:

from langchain.schema.runnable import RunnablePassthrough def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm )

这个链路的含义是:接收用户问题 -> 检索器找到相关片段 -> 格式化拼接 -> 填入提示词模板 -> 大模型生成回答。每一步的输出都清晰可控,中间哪一环出问题,单独测试哪一环就行。

3.5 用命令行快速验证效果

跑一个简单的问题试试:

question = "这个产品的最大并发连接数是多少?" answer = rag_chain.invoke(question) print(answer.content)

如果文档里有对应的内容,模型应该能从检索到的片段里找到答案并输出。这也是第一轮验证时最关心的:不是“回答得有多漂亮”,而是“回答是否来自文档本身”。

我建议你把问题和答案逐条记录下来,因为 RAG 调优很大程度上依赖这种人工评估。改一个参数,就重新问同一组问题,对比答案有没有变好,这是最朴素的评估方式。

4. 跑通之后必看的四类问题与排查实录

4.1 检索效果差:切分粒度与 embedding 的匹配问题

好,代码跑通了,接下来才是真正花时间的地方。

我遇到最典型的问题,是问一个文档里明确写了的细节,系统却答不上来。排查询问流程,发现检索器返回的前 4 个片段里,压根没有包含正确答案的那段。

查下来原因有两个。第一,chunk_size 设得太大。500 个字符对某些文档来说还是太长了,一个 chunk 里包含多个知识点,向量化之后被平均了,导致和具体问题匹配时相似度被稀释。我尝试把 chunk_size 降到 300,结果明显好了不少。第二,我最初用的是英文通用 embedding 模型,中文资料效果很差;换成 bge-small-zh 之后,检索质量有了质的改变。

注意:embedding 模型和切分大小是基础版最值得反复调的两个旋钮。调检索效果,先固定一个变量,再动另一个,别两个一起改,否则你根本不知道是哪个起了作用。

4.2 答案像在“编”:提示词与召回排序的问题

另一类常见问题是:答案看起来有模有样,但稍微仔细一核对,发现和原文对不上。这种情况多半不是模型的问题,而是你给模型的“资料”里面混入了干扰信息。

为什么这么说?因为 similarity 检索返回的是“和问题向量最相似”的片段,但这个“相似”未必是语义相关。比如一个片段里同时提到了“并发”和“缓存”,当用户问到缓存时,这个片段也可能被召回,但它真正讲的是并发配置,缓存部分只是顺带一提。模型分不清主次,就可能张冠李戴。

解决思路有三个方向:一是加大返回条数,让更多相关片段进来,但这是双刃剑,噪声也变多了;二是用 MMR 检索模式,它在相似性和多样性之间取了平衡:

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

fetch_k=20 表示先取 20 个候选,再从中挑 4 个既相关又不重复的。实测下来,MMR 对“一个 chunk 里有多个主题”的场景确实有帮助。

三是调整提示词,要求模型“只依据与问题直接相关的内容作答,无关内容忽略”。这不能根治问题,但能减少模型被噪声带偏的概率。

4.3 中文问答效果不佳:几类 embedding 的实际表现

中文场景翻车概率最高的环节,基本都在 embedding。

我用过几类模型,客观说一下感受。openai 的 text-embedding-3-small 对中文效果能接受,但不是最便宜的方案。多语言模型在中文上表现算是均衡,细节上偶尔有偏差。真正让人惊艳的是经过中文语料专门训练的开源模型,比如 bge 系列,中文语义理解明显更细腻,而且完全离线部署,不需要调用外部接口。

但要注意,bge 这类模型不是无脑最优。如果你的文档是纯英文,或者中英混合很杂,需要根据实际效果测试。我的建议是:基础版直接选开源中文模型,因为它在中文场景下性价比最高,同时省去外部接口的延迟和网络风险。

另外,使用 bge 这种模型时,要开启 normalize_embeddings 参数。这会把向量归一化,使余弦相似度的计算更稳定,不开启的话,检索分数可能会偏高或偏低,影响阈值判断。

4.4 响应太慢:向量检索和模型调用的性能瓶颈

基础版在数据量小的时候,响应是很快的,基本卡在大模型生成那一步。如果发现整体很慢,先分清楚慢在哪一段。可以在链路里加几个计时点,分别测“检索耗时”和“模型调用耗时”。

检索慢,一般是两个原因:数据量大但向量库没有索引,或者是在线调用外部 embedding 接口。前者可以通过向量库索引解决,Chroma 默认就有 ANN 索引,一般不用管;后者无解的地方在于网络延迟,解决方案是把向量化也改成本地模型,一次检索的耗时能降不少。

模型调用慢,基本就是生成太长。把提示词里的“200 字以内”约束加上,再把 max_tokens 限制设小一点,体感会好很多。另外,gpt-4 这类大模型比 mini 版本慢不少,基础版没必要用太强的模型。

4.5 一个容易被忽略的坑:重复导入与脏数据

最后说一个非常隐蔽的问题。我在调试时发现,向量库里的片段总数比切分出来的 chunks 数量多,检索结果里还出现了重复内容。排查之后发现,是我在多次运行脚本时,往同一个持久化目录里重复写入了数据。

Chroma 的 from_documents 每次调用会重新写入,但如果你用的是 add_documents 增量写入,同一份文档跑两遍,库里就会有两份同样的向量,查的时候自然会把重复内容都返回。

解决办法很粗暴:如果数据源没变化,就别重复建库,直接从持久化目录加载;如果确认要重建,把这个目录整个删掉再跑 from_documents。这一步虽然简单,但真的能省掉很多莫名其妙的排查时间。

5. 基础版之后的几条扩展方向

5.1 扩展一:多路召回与重排序

基础版只做一路向量召回,复杂度不高,但精度也就那样。想要明显提升,可以考虑“多路召回 + 重排序”的模式:同时用向量检索和关键词检索,各取一批结果,合并去重后,再交给一个 rerank 模型统一打分排序。

Rerank 是检索质量提升最明显的一步,代价是多一层模型调用、多几十毫秒延迟。基础版阶段可以先不加,但如果你发现向量召回总差一口气,优先考虑这个方向。

5.2 扩展二:对话历史与查询改写

基础版是一次性问答,用户问“那价格呢”,系统不知道“那”指的是什么。要支持多轮对话,需要把对话历史带上,并在每次检索前做一次“查询改写”——把用户的指代词替换成明确的问题。

LangChain 里有现成的 history-aware retriever,原理不复杂:先用一个小模型根据历史对话把当前问题补全,再拿补全后的问题去检索。这一步对真实用户场景几乎是刚需,值得做。

5.3 扩展三:引用来源与结构化表格问答

基础版没有把答案和资料片段对应起来。生产环境通常要求答案能追溯,需要在生成时让模型指出“这段答案来自第几段资料”,或者在检索时直接把 chunk 的元数据带出来展示给用户。

再来,如果你的文档里有大量表格,普通切分方式会把表格拆得七零八落。一种常见处理是先识别表格区域,把表格转成 Markdown 表格格式,再单独切分。LangChain 生态里有一些专门处理结构化文档的 loader,值得后面研究。

我个人在实际项目里的体会是:基础版不是终点,它是一个非常好的“调试架”。先把链路跑通、把每个环节的工具用熟、把上面那些坑都踩一遍,你对 RAG 的理解会远比看十篇教程深入。后续再加 rerank、加会话记忆、加结构化处理,都会顺手很多。

最后再分享一个小建议:每改一次配置,都保留一份记录。我用的是一个简单表格,列清楚“改了什么参数、问题是什么、结果如何”。时间一长你会发现,RAG 调优最有价值的资产不是哪个参数,而是你对这套系统行为的感知能力。祝你能早点跑通第一个属于自己的 RAG,然后一点点把它变得更聪明。

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

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

立即咨询