直接开工,这是一篇基于 LangChain 实现 RAG 问答库的实战分享,方便大家参考整个搭建思路和落地经验。
用 LangChain 搭建 RAG 问答库,最容易被忽视的其实是检索链路的设计
很多人一听到 LangChain 和 RAG,第一反应是"装个库、跑个 demo、接个大模型,完事"。但真到自己搭一个能用的问答库时,才发现问题远不止"有没有答出来"这么简单:切分粒度不对,命中片段就是一堆碎片;向量模型选得随意,召回的相关性就飘;检索策略只用了相似度,同一个问题换种问法结果全变;更不用说部署到生产之后,文档更新、权限隔离、多轮对话这些问题一个一个往外冒。
我做的这个 langchain-rag-chat 项目,目标很朴素:用最小成本跑通一条"文档进来、答案出去"的完整链路,同时把检索质量做到能上生产的基本线。它适合两类人看:一是刚接触 RAG、想快速理解整套机制的同学;二是已经跑过 demo、但对检索效果不满意、想知道卡点在哪的开发者。下面从动机、选型、实现到优化,把我踩过的坑和调参思路完整摊开。
1. 为什么用 LangChain 搭 RAG:开箱即用背后的取舍
1.1 "开箱即用"的真实含义
标题里写了"开箱即用",这四个字要看怎么理解。它不是说装完 LangChain 直接就能得到一个完美的问答系统,而是指:你不需要从零去写文档解析、文本切分、向量索引、相似度检索这些底层组件,LangChain 把这些环节抽象成了组合式的模块,像搭积木一样把整条管线拼出来。
我自己之前用原生 Python 写过一版检索系统,光是处理 PDF 表格和长文本分段就写了快两千行,还要自己维护向量索引的读写逻辑。后来切到 LangChain,文档加载用PyPDFLoader,切分用RecursiveCharacterTextSplitter,向量化用OpenAIEmbeddings或者HuggingFaceEmbeddings,检索用VectorStoreRetriever,每一块都是现成的,替换某个环节只需要改几行代码。这个"开箱即用"的真正价值,是让开发者把精力集中在调优检索效果和应用场景上,而不是重复造轮子。
1.2 但 LangChain 不是银弹:版本和抽象层是个大坑
先给一句大实话:LangChain 的抽象层在带来便利的同时,也带来了学习和排障成本。尤其是它的 API 在 0.1 到 0.2 版本期间改动频繁,网上教程大量过时。我在项目里锁定的是langchain>=0.2.0和langchain-community的配套版本,目的是避免教程里写的from langchain.vectorstores import Chroma在新版本里变成from langchain_chroma import Chroma时直接报错。
还有一点要提醒:不要急着上 LangChain 的LCEL(LangChain Expression Language)花哨写法,虽然链式|操作符很酷,但调试起来不如普通函数直观。我在项目里先用最朴素的函数式写法把流程跑通,再加上 LCEL 封装成接口,这样每一环出问题能直接打印中间结果。
提示:如果你的环境里已经装过旧版 LangChain,先
pip uninstall langchain再重装,避免版本冲突。这个项目里我统一用pip install langchain langchain-community langchain-openai langchain-chroma一条命令装齐基础依赖。
2. 从文档到答案:langchain-rag-chat 的整体架构和数据流
2.1 完整链路拆解
整个系统的数据流可以分成两条线:一条是离线的索引构建,另一条是在线的问答推理。离线阶段处理的是"把文档变成可检索的向量索引"这一步,在线阶段处理的是"把用户问题变成答案返回"这一步。
离线构建的核心步骤我大概列一下:
- 文档加载:读取 PDF、Markdown、TXT、Word 等格式的文件,提取纯文本内容。
- 文本切分:把长文档切成固定大小的 chunk(文本块),目的是让每个块在向量化之后有明确的语义边界,也方便检索时精准定位。
- 元数据打标:给每个 chunk 附上来源文件、页码、标题等元数据,这样答案生成时可以溯源,回答也能带上引用来源。
- 向量化:用 embedding 模型把每个 chunk 转成向量(我用的 1536 维的 OpenAI 向量,也测试过 384 维的本地模型)。
- 写入向量库:把向量和原文、元数据一起存入向量数据库,同时建立索引。
在线推理则是:
- 问题向量化:用户输入问题后,用同一个 embedding 模型转成向量。
- 相似度检索:在向量库里找出与问题最相似的 top-k 个 chunk。
- 重排(可选):用 reranker(比如 bge-reranker)对召回结果做精排。
- 构造 Prompt:把问题 + 检索到的上下文拼进预设的提示模板。
- 大模型生成:交给 LLM 生成最终答案,并要求它基于提供的上下文作答。
2.2 技术选型三个核心决策
先亮一下我在这个项目里的选型清单,再逐个说理由:
| 环节 | 选型 | 理由 |
|---|---|---|
| 编排框架 | LangChain 0.2.x | 生态成熟,模块替换方便 |
| 向量数据库 | Chroma | 轻量、零依赖、适合起步,后续可平滑迁移到 Qdrant/Milvus |
| Embedding | OpenAI text-embedding-3-small(对比过 BGE 中文模型) | 中文效果稳定,且 API 成本低 |
| LLM | gpt-4o-mini 或本地 Qwen 系列 | 按场景选择:要速度选 4o-mini,要私有化选 Qwen |
| 文档解析 | PyPDFLoader + Unstructured + 自研表格补丁 | 解决 PDF 表格和复杂排版问题 |
第一个决策是向量库选 Chroma 而不是 Milvus。很多教程一开始就让你上 Milvus,但起步阶段没必要。Chroma 是嵌入式向量库,不需要单独部署服务,数据存在本地目录里,对开发和单机场景非常友好。实测几千个 chunk 的检索延迟在几毫秒级别,完全没有性能压力。真要到了几百万级向量、需要分布式部署的时候,再迁移到 Milvus 也不迟,因为 LangChain 对向量库的接口是统一的,切换成本很低。
第二个决策是embedding 模型的选择要匹配你的文档语言。我最初用 OpenAI 的text-embedding-ada-002,后来换了text-embedding-3-small,发现维度从 1536 降到 1536(small 也是 1536)但效果更好、价格更低。对于文档以中文为主的项目,建议你同时用中文语料实测对比:OpenAI 的向量在中英混合场景下确实稳,但如果你必须私有化部署或追求零成本,BAAI/bge-small-zh-v1.5或m3e-base这些开源模型效果也不差。
第三个决策是LangChain 版本锁定。这一点在前面提过,再强调一次:社区里 90% 的过时教程都是因为langchain和langchain-community的模块拆分导致的。0.2 版本之后,Chroma从langchain_community.vectorstores移出到langchain_chroma,OpenAI的 LLM 和 Embedding 要分别从langchain_openai导入。搞清楚这个,后面照着写代码才不会一路报错。
3. 核心环节实现:文档加载、切分策略与检索管线
3.1 文档加载:不同格式的姿势不一样
文档加载是整个管线的入口,很多人在这上面翻车。比如 PDF 文件,直接用PyPDFLoader加载后,遇到扫描版 PDF 拿到的全是空文本;遇到排版复杂的表格,文本会被拆得七零八落。我这个项目里的处理思路是分级处理:
- 对于文本型 PDF(文字可选中、复制),直接用
PyPDFLoader搭配pdfplumber做表格内容提取。 - 对于扫描版 PDF,需要先接入 OCR。我调研了一圈,最终选了开源的PaddleOCR,识别中文效果不错,但部署稍重;如果文档量不大,也可以直接用百度云的 OCR API 顶一下。实测下来扫描版 PDF 的识别准确率直接决定了后续检索质量,这一步不能省。
- 对于Markdown 和 TXT,LangChain 的
TextLoader就够用,但注意指定编码,Windows 上默认gbk容易乱码,我在代码里强制写了encoding='utf-8'。 - 对于Word 文档(.docx),用
UnstructuredWordDocumentLoader最省心。注意unstructured这个包依赖一堆系统库,在 Mac 上需要用brew install libmagic之类的命令先装依赖,后面会踩到。
这一步的核心原则是:加载到什么质量的文本,决定了后面检索效果的上限。文本都提取不清楚,切分和向量化做得再好也白搭。
3.2 切分策略:chunk_size 和 overlap 不是拍脑袋定的
文本切分是 RAG 管线中最容易被低估、对效果影响却最大的一环。切得太细,每个 chunk 表达的含义不完整,检索经常召回一些"半句话",大模型看着上下文也答不完整;切得太粗,一个 chunk 包含多个主题,向量化之后语义被稀释,检索时"命中但不准"。
LangChain 默认给的RecursiveCharacterTextSplitter是一个递归式切分器,它会按分层分隔符(从段落到句子再到字符)反复切分,尽量减少语义断裂。我在项目里采用的参数组合是:
| 参数 | 值 | 说明 |
|---|---|---|
| chunk_size | 500(中文约 500 字) | 大约 3~5 个段落,语义完整且利于定位 |
| chunk_overlap | 100 | 相邻块之间保留重叠,避免关键句被拦腰截断 |
| length_function | 按字符计 | 中文场景按字符比按 token 更可控 |
chunk_overlap设置的逻辑是:如果文档里某一段关键的因果句子刚好落在两个 chunk 的边界上,检索时可能两边都搜不到。加上重叠之后,这个句子至少会完整出现在至少一个 chunk 里。但 overlap 不是越大越好,太大会让相邻 chunk 大量重复,检索时返回的几个结果内容几乎一样,白白浪费上下文窗口。
对于不同类型的文档,我做了差异化调整:
- 代码文档:把
separators扩展成["\n```", "\n\n", "\n", "。", ". ", " "],优先保护代码块完整性。 - 技术规范类文档:chunk_size 适当调到 800,因为这类文本句式长、逻辑链条长,切得太碎会丢失上下文。
- FAQ 类文档:按"一问一答"为单位做自适应切分,避免一个问题被切成上下两半。
实际调参方法建议这样操作:把切分结果用表格形式打印出来(chunk 序号、内容预览、字符数),肉眼扫一遍,重点检查有没有语义被腰斩的地方,比看什么理论都有用。
3.3 检索管线:相似度之外还有 MMR 和重排
检索是 RAG 问答库的核心能力,但在很多入门教程里,它就是"向量库里查 top-k"这么简单。实践中你会发现,纯相似度检索的问题很明显:
- 同一个语义,不同表达方式(比如"如何安装"和"安装步骤是什么")在向量空间里可能距离并不近;
- 返回的 top-k 结果往往高度相似,内容冗余度高;
- 相关性低但向量距离相近的噪声片段会混进来。
我在项目里做了三层优化,实测效果提升明显:
第一层:采用 MMR(最大边际相关性)检索。LangChain 的Retriever支持search_type="mmr",在保证相关性的同时强制结果之间有一定多样性。核心参数lambda_mult我调到了 0.7,这个值控制"相似度"和"多样性"的权重,0.7 意味着更偏相关性,但也留了 30% 的空间给多样性,防止返回的几段内容一模一样。对于回答"步骤类"问题,MMR 的效果比纯相似度好很多。
第二层:加一个 reranker 重排模型。一开始我嫌麻烦没上,直到一次测试里发现,纯向量检索召回的 4 个片段里经常有 1~2 个和问题毫无关系。后来接入了bge-reranker-base做二次精排,逻辑是:先向量召回 20 个候选片段,再用重排模型逐条计算和问题的相关度打分,取前 4 个进上下文。这一步让最终回答的准确率提升非常明显。重排模型跑在 CPU 上也不慢(几百毫秒级别),完全可以接受。
第三层:混合检索策略。对于技术文档库,我额外加了一个 BM25 关键词检索通道,和向量检索的结果做加权融合。向量检索擅长语义匹配,但有时候用户会用很精准的术语提问(比如"LangChain LCEL 是什么"),关键词检索在这里命中率反而更高。两路结果取并集后进重排,效果最稳。
这三层优化下来,我对检索质量的评价不再依赖单个问题的手感,而是建了一套简单的评估方式:准备 20 个有标准答案的问题,人工标注每个问题在知识库中对应的原文片段,然后计算召回率和命中率。开始调之前命中率大概 60% 左右,调完之后稳定在 85% 以上。这个评估集很小,但比"随手问一个感觉不错"靠谱得多。
4. 问答生成链路与 Prompt 设计的几个关键细节
4.1 让模型"只依据上下文"回答的技巧
检索做得再好,如果 Prompt 和生成链路没设计好,最终答案照样翻车。RAG 的生成环节,核心要解决两件事:一是让模型严格基于检索到的资料作答,不要信口开河;二是让模型在资料不足时承认不知道,而不是强行编造。
我在项目里使用的 Prompt 模板核心结构如下:
你是一个企业内部知识问答助手。请基于以下【参考资料】回答问题。 【参考资料开始】 {context} 【参考资料结束】 要求: 1. 回答时只使用资料中出现的信息,不要引入资料之外的内容。 2. 如果资料中没有明确答案,直接回答"根据现有资料无法回答",不要猜测。 3. 回答需要给出依据,在回答末尾标注引用的资料来源(文件名和页码)。 4. 如果问题是开放性的,结合资料给出条理清晰的回答。 问题:{question}这个模板有几个细节值得注意:
- 用**【参考资料开始】/【参考资料结束】**这样明确的边界标记,比简单的"以下是参考资料"效果好,模型能更清晰地区分"资料"和"指令"。
- 明确要求"标注来源"可以倒逼模型忠实于资料,减少编造。即使模型偶尔编造,至少引用的文件名和页码能帮我们快速定位答案的真实来源,便于审核。
- 加上"没有明确答案就承认不知道"的兜底指令,是降低幻觉最有效的手段之一。实测中,加上这条之后,模型面对检索结果不相关的情况时,不再强行回答,而是措辞得体地拒绝,整体可信度大幅提升。
4.2 RetrievalQA 还是 ConversationalRetrievalChain?
LangChain 提供两种常见的问答链:RetrievalQA和ConversationalRetrievalChain。前者适合单轮问答——问题直接、不依赖上下文;后者支持多轮对话——能记住用户前面问过什么。
我在这个项目里两条链都实现了。基础问答用的是RetrievalQA,结构简单、好调试;但要做到"小助手"级别的对话体验(比如"那第二步呢?""这个方案的缺点是什么?"这种追问),就必须用ConversationalRetrievalChain。多轮对话的关键问题在于历史上下文和检索知识库的关系:用户问"那第二步呢",本身不包含足够信息用于检索,因此需要把历史对话也纳入检索范围,或者将历史记录一并传入重写模块。
LangChain 处理这件事的逻辑是:在每次检索之前,先压缩历史对话,结合当前问题生成一个能独立检索的"standalone question",再拿这个重新表述后的问题去向量库检索。这个机制解决了多轮对话的检索漂移问题,但代价是多一次 LLM 调用。如果你是本地模型,延迟会明显增加;用 API 模型时,也要算好每次对话的成本。我给这个项目加了一个开关,只在对话轮次超过 1 次时才启用历史压缩,单轮问答直接走最快路径。
4.3 流式输出与溯源格式化
这个项目是聊天问答库,所以我把流式输出(streaming)打好了。LangChain 的RetrievalQA默认不支持流式,需要在构建llm时设置streaming=True,配合CallbackHandler把 token 逐段推给前端。这一步的效果非常直观:用户看到的不再是"转圈等 10 秒然后一大段文字突然出现",而是像聊天软件一样逐字输出,主观体验差异巨大。
溯源格式化这块,我倾向于在回答后面追加一个"参考资料"区块,列出本次回答用到的 chunk 来源(文件名 + 页码)。这里有个细节:不要把所有召回片段全部展示给用户,我实测只展示实际被生成逻辑采纳的片段效果最好,但 LangChain 默认不做这个区分。简单做法是把 top-k 里面重排分数最高的 2~3 个来源展示出来,基本够用。
5. 从"能跑"到"好用":检索质量评估与常见问题排查
5.1 建立你自己的黄金问答集
优化 RAG 系统最大的困难是:没有量化指标,所有调整都靠"感觉"。这个项目做到后期,我筛选了 50 个真实场景问题作为黄金测试集,覆盖知识库里高频问题、模糊表述、复合问题、边界情况四大类。正确率从最开始的 40% 一路调到了目前的 92% 左右,每一步调整都有指标可看,而不是靠拍脑袋。
具体的评估步骤可以套用这个流程:
- 先准备好一个包含 20~50 个问题的黄金集,每个问题标注"理想答案要点"或"答案所在文档位置"。
- 跑一遍全流程,得到每个问题的答案。
- 人工打分(二分类:回答正确 / 回答错误),算出整体正确率。
- 对回答错误的问题逐条分析原因:是检索没召回,还是召回但不相关,或是大模型没正确使用上下文。
- 针对占比最大的问题类型做定向调优,重测。
这个方法的投入产出比非常高。我每次调整要么改切分参数,要么换检索策略,要么调 Prompt,然后重跑黄金集对比正确率,效果一目了然。这比"凭感觉试"高效得多。
5.2 三个高频问题的排查链路
我在实际开发和让朋友试用这个项目的过程中,遇到的高频问题基本集中在以下三个。每个问题我都记录了自己的排查思路:
问题一:回答像在"编造",明明知识库里没有的内容也说得头头是道。
排查链路:先看检索结果——把本次回答对应的检索片段打印出来,确认模型参考的上下文是什么。如果检索结果本身就不相关,是大模型的忠实度问题,说明 Prompt 的约束不够强,需要加那句"没有答案就承认不知道"的兜底;如果检索结果相关但仍然回答错误,多半是模型能力不足,换更强的模型(比如从 4o-mini 换到 4o)通常能解决。我把这两个环节拆开排查,定位根因的速度快很多。
问题二:换个问法,同样的内容就答不出来了。
这是 embedding 模型的典型表现,纯向量检索对"用词差异"比较敏感。解决办法按优先级排序:先加关键词检索走混合检索,再看是否需要换更好的 embedding 模型。我在项目里增加了BM25Retriever做关键词通道,和向量检索结果做融合。实测"换个问法"这类现象,混合检索的命中率提升了 30% 左右。
问题三:知识库更新后,旧答案依然被反复召回。
这是向量索引更新的问题。Chroma 的持久化目录里,旧的向量索引不会自动清除,你需要做的是在更新文档后重建索引或按元数据删除旧片段再插入新片段。这个项目里我用collection.delete(where={"source": "docs/xxx.pdf"})这种按来源批量删除的方式,比全量重建快得多。记住,元数据打标这一步做得好,后面增量更新就能省大量功夫。
5.3 部署与性能:本地跑和线上跑的注意事项
系统在 Mac 上本地跑通之后,要考虑部署时的性能和稳定性问题。先说 Mac 本地的坑:如果你打算完全本地跑,embedding 模型用 BGE,LLM 可以用ollama跑 Qwen,这样全程不依赖外网 API,体验很顺。但要注意,本地模型的启动时间和推理速度都不如 API,尤其是流式输出时,如果用的是 CPU 推理,建议把模型量化到 Q4 或者选用更小的 7B 模型。
如果走 API 路线,性能和稳定性会更可控,但要注意几个问题:API 的 token 限制,上下文塞入的 top-k 越多,Prompt 越长,成本和延迟同步上升,我需要根据实际效果把 top-k 控制在 4~6 个片段;并发控制,在线 API 服务都有 RPM 限制,多用户同时提问会出现 429 错误,我的做法是在检索层加了一个信号量并发队列,最多同时放行 5 个请求。Chroma 本身支持并发读,但在写索引时会有文件锁冲突,这个阶段我选择了串行写。
生产部署的一个建议:不要直接把 Chroma 的本地目录裸挂在服务器上,建议用 Docker 把整个问答服务容器化,并把 Chroma 的持久化目录挂载到宿主机卷上。这样迁移、备份、回滚都有余地。这个项目的 Dockerfile 我放在了根目录下,基础镜像用python:3.11-slim,启动命令只需要docker compose up -d就够,日常维护成本很低。
6. 再进一步:RAG 的瓶颈在哪,什么时候考虑换思路
这个章节算是回应一下热词里很火的"RAG 瓶颈"和"RAG vs 知识图谱"这两个话题。我自己在做了这个项目之后,对 RAG 的能力边界有了更实的体感。
6.1 RAG 目前最真实的瓶颈:复杂推理和全局理解
单靠向量检索 + 大模型生成,RAG 在处理"多跳问题"(比如"A 方案的缺点会不会影响 B 方案的选用标准"这类需要跨文档推理的问题)时表现不稳定。原因在于检索的粒度是 chunk,每个 chunk 只承载了局部信息,即使 top-k 能召回相关片段,也缺少把多个片段之间的关联关系显式建模的能力。
知识图谱(KG)和 ontology RAG 这类方案,之所以会被反复提起,核心就是针对这个问题:用实体和关系显式组织知识,把"检索"从"找相似文本"升级为"沿着关系路径找答案"。但这个方案成本也不低——构建图谱本身就是一项持续维护的知识工程,对工具链和人力要求都很高。做技术选型时,我的建议是:内容以零散文档为主、需要快速上线的场景,选 RAG 没问题;内容实体关系强、需要复杂推理的场景(比如企业规范、组织架构、法规问答),再考虑知识图谱。
6.2 图片和表格进了 RAG 吗
回答热词里的另一个高频问题:"RAG 知识库能存储图片吗?"答案是能,但不是把图片直接塞进向量库。常规做法有两条路:
- 一条是视觉语言模型路线:用支持视觉的 embedding 模型(如 CLIP、Piccolo)把图片直接向量化,检索时将图片本身作为上下文送入多模态大模型。
- 另一条是文本化路线:先对图片做 OCR 或者用多模态模型(如
gpt-4o)生成图片的文字描述,再把这段描述作为文本 chunk 存进知识库。对工程化落地来说,第二条路简单可靠得多。表格的处理思路类似:表格用pdfplumber或camelot解析成结构化数据,再结合上下文把表格转成 Markdown 格式文本存入 chunk,实践效果比直接向量化表格图像好得多。原因很简单:文本检索管线已经成熟稳定,多模态检索在精度和成本上的优势目前还没有体现出来。
这个项目的名字虽然是 langchain-rag-chat,但做完之后我最大的体会反而是"项目里最难的部分往往不是 LangChain,而是数据本身"。文档质量参差、格式五花八门、切分边界难定,这些都是决定效果的关键变量。在做这个项目之前,我一直以为 RAG 的瓶颈在模型和框架,做了之后才明白,把数据整明白、把评估体系搭起来,比换一个更强的模型带来的提升大得多。最后分享一个我自己的习惯:每次改完一个环节,不急着"再随手问一个问题试试",而是重新跑一遍黄金测试集,用数据说话。这套方法论,才是我做 langchain-rag-chat 最大的收获。