ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用LangChain搭建开箱即用的RAG知识库问答系统实战

用LangChain搭建开箱即用的RAG知识库问答系统实战 前阵子我们团队整理了一套内部技术文档零零散散小一百篇散落在共享盘、语雀和Notion里。新人入职要翻一天老人回答重复问题翻到崩溃。我花了一个周末用 LangChain 拼了一个开箱即用的 RAG 问答库起名 langchain-rag-chat现在团队问答效率高了不少。这篇文章把这个项目的完整实现拆给你看从选型到踩坑能直接抄作业。这个项目只做一件事把一批本地文档变成可对话的知识库。你扔进去 PDF、 Markdown、TXT它替你完成切分、向量化、检索、生成最后暴露一个 HTTP 接口前端随便接就能用。适合已经熟悉 Python 基础、想快速落地 RAG 问答案例的开发者也适合要给团队搭内部知识库的工程同学。1. 项目定位RAG 问答库到底解决了什么问题1.1 大模型幻觉之外还有数据实时性和权限问题先说一个常见误区很多人以为知识库问答就是把文档塞给大模型。实际上直接塞文本进去既不现实也不安全——你没法把几百兆的文档全塞进上下文窗口也不能让模型背诵公司内部资料。RAG 的价值在于“按需检索”用户提问时先从库里检索最相关的几段文本再让模型基于这些文本作答。这样生成内容有凭有据模型不会凭空编造文档更新后也不需要重新训练模型改库就行。RAG 同时解决了私有数据隔离的问题。基础模型只见过公开语料公司内部文档、产品手册、个人笔记这些数据如果直接进模型上下文就有泄露风险而 RAG 模式下只有当前问题命中的片段会被传出去控制面清晰很多。1.2 为什么“开箱即用”是关键LangChain 社区里 RAG 教程不少但绝大多数要么只讲单机 Jupyter 演示要么一步跳进 LangGraph、Agent 这些复杂框架新手直接劝退。我把目标定成“开箱即用”意思是三条命令以内能跑起来接口、配置、向量库初始化全给默认值但默认值不是拍脑袋定的背后都有取舍逻辑。向量库使用嵌入式方案不依赖额外服务文本切分参数按通用中英文文档调过一版模型层面做了接口抽象换模型只改配置这套设计思路是“先跑通再调优”。你可以先用默认配置把整个链路拉通看到检索结果和生成效果之后再按自己的文档类型逐项优化。1.3 适用场景画像我实测下来这个项目最适合三类内容技术文档、培训材料、产品 FAQ特点是结构清晰、事实密度高、答案能从片段中直接定位。不适合的场景也有——需要强实时性的数据查询、需要复杂多轮推理的问答、对数字准确性要求极高的场景纯靠 RAG 不够得叠加外部工具或人工校验。2. 技术选型LangChain 生态里怎么拼出最省心的组合2.1 为什么用 LangChain 而不是从零手写有人觉得 LangChain 重、抽象多自己写 loader、splitter、retriever 也就几百行。这话有道理但我选择 LangChain 的原因是生态兼容性。文档加载器覆盖几十种格式向量库接口统一换库不用改业务代码模型调用也能平滑切换。手写方案前期看着简单后期每加一种文档格式、换一个向量库都要自己造轮子。另外 LangChain 的表达方式直观尤其是新版 LCEL 语法用管道符组合组件代码读起来跟流程图一样协作成本低。对团队项目来说可维护性比省几个依赖更重要。2.2 向量库选型Chroma、FAISS、Milvus 怎么选“开箱即用”限制了我選向量库的思路。维度ChromaFAISSMilvus部署方式嵌入式零服务嵌入式文件型独立服务需单独部署适合规模单机、百万级向量以下单机、内存可控分布式、亿级向量中文支持好一般好上手成本极低低中高我最后选了 Chroma原因是它默认就能落盘自动持久化索引存储在本地目录重启不丢数据。FAISS 性能更好但持久化要自己管Milvus 功能最全但对“开箱即用”目标来说太重了。如果你后续数据量超过百万级或需要多机部署再迁移到 Milvus 也不迟因为上层代码已经被 LangChain 抽象好了。2.3 Embedding 模型中文场景的选型思路Embedding 模型直接决定检索质量英语文档用 OpenAI 的 text-embedding-ada-002 没问题但中文场景我建议优先考虑国产开源模型比如 bge-m3、m3e-base。实测下来这些模型在中文语义匹配上的效果比通用英文模型好一截而且本地部署无需外部 API 调用数据链路更短。如果你的团队用的是兼容 OpenAI 协议的大模型接口Embedding 也可以走同一套协议配置上只改模型名和请求地址就行。我的建议是中文文档为主选 bge-m3中英混合且 API 调用成本可接受用 OpenAI 或国产商业接口对数据敏感就全部本地。2.4 大模型接入兼容层设计项目里我封装了一个统一的 LLM 调用层底层用 LangChain 的 ChatOpenAI 对接所有兼容 OpenAI 协议的接口。这么做的好处是不管后面换 GPT、通义、DeepSeek还是切到 Ollama 跑的 Qwen2.5只需要改环境变量里的模型名和 base_url业务代码零改动。Ollama 本地部署往往能省下不少 API 费用对实验环境和中小团队尤其友好。3. 核心实现从文档加载到问答响应的完整链路3.1 文档加载与预处理LangChain 提供了统一接口我把本地文件夹加载统一封装成load_documents方法支持 PDF、Markdown、TXT 三种格式。from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader def load_documents(docs_dir: str): loaders { .md: DirectoryLoader(docs_dir, glob**/*.md, loader_clsTextLoader, loader_kwargs{encoding: utf-8}), .txt: DirectoryLoader(docs_dir, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8}), .pdf: DirectoryLoader(docs_dir, glob**/*.pdf, loader_clsPyPDFLoader), } docs [] for ext, loader in loaders.items(): docs.extend(loader.load()) return docs这里有一个容易踩的坑TextLoader默认编码是 UTF-8但不少中文老文档是 GBK 编码直接加载会报UnicodeDecodeError。我加了一层异常处理检测到解码失败就自动尝试 GBK 编码避免整个流程中断。预处理阶段还有一件事要做——过滤无意义内容。PDF 转换出来的文本经常带页码、页眉、页脚这些碎片进向量库只会污染检索结果。我在加载后做了一步正则清洗把页码、连续的重复分隔符、孤立 URL 都去掉这一步对后续效果影响很大。3.2 文本切分chunk_size 和 overlap 的门道切分是整个 RAG 链路里最容易被低估的环节。切太大检索粒度粗容易把不相关的信息带进上下文切太小语义不完整检索召回的片段前言不搭后语。我用的RecursiveCharacterTextSplitter它按字符递归切分先按段落切、再按句子切、最后按字符切能尽量保留语义边界。from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ], ) split_docs text_splitter.split_documents(docs)chunk_size500、chunk_overlap50是我在多个中英文混合文档上试出来的平衡点。chunk_size 取 500 是因为这个长度大约能涵盖 35 个完整段落既保留了上下文又不至于让向量检索的精度下降。overlap 取 50 是为了防止一句话被从中间截断——如果一句话跨了两个 chunk检索时关键词被切开召回质量会明显下降。这个参数没有绝对最优跟文档类型强相关。技术文档如果段落本来就清晰chunk_size 可以放大到 800问答对形式的文档按“一问一答”切一个 chunk 效果最好。建议你搭好链路后根据实际检索效果反推调整。3.3 向量化与 Chroma 存储加载和切分完成后需要把文本块向量化并写入向量库。核心代码不长from langchain_community.vectorstores import Chroma from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings(model_nameBAAI/bge-m3) vectorstore Chroma.from_documents( documentssplit_docs, embeddingembeddings, persist_directory./data/chroma_db, ) vectorstore.persist()这里有两个关键点。第一persist_directory参数指定了向量库落盘路径第二次启动时如果目录已存在Chroma会直接加载已有索引不需要重新向量化节省大量启动时间。第二embedding 模型和向量库的匹配关系要固定如果中途换了 embedding 模型旧的索引向量和新向量不在同一语义空间检索结果会莫名其妙地差这种问题排查起来很隐蔽我后面在常见问题里细讲。3.4 检索策略普通相似度还是 MMR检索环节决定了模型能看到什么材料是整个 RAG 效果的上限。默认方案是similarity_search直接用向量余弦相似度取 Top-K。这个方案实现的检索结果在大部分场景下够用但存在一个典型问题——召回的片段之间可能高度相似从多个角度重复描述同一件事信息冗余覆盖不全。max_marginal_relevance_searchMMR能缓解这个问题。它一边选相关性高的片段一边排除与已选片段过于重复的候选项让召回结果在“相关”和“多样”之间做权衡。retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 5, fetch_k: 20}, )k5是送入大模型的片段数fetch_k20是 MMR 的候选池大小。我第一次跑 RAG 时用k8效果反而更差因为片段太多模型容易被无关信息带偏回答变得冗长且重点模糊。k5是在回复质量和上下文窗口占用之间的折中。如果你的文档是碎片化的k可以调小到 3如果问题需要跨多个文档综合回答可以调大到 68。3.5 问答链搭建LCEL 写法与 Prompt 调优新版 LangChain 推荐用 LCEL 表达式来组装链路代码更简洁。我的问答核心逻辑如下from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_openai import ChatOpenAI prompt ChatPromptTemplate.from_messages([ (system, 你是一个严谨的智能助手。请基于以下资料回答问题。 资料 {context} 要求 1. 只根据资料回答资料中没有的信息不要编造 2. 如果资料不足以回答明确回答“资料中未提到” 3. 引用资料中的关键信息时尽量保留原文表述), (human, 问题{question}), ]) llm ChatOpenAI( modelqwen2.5:7b, base_urlhttp://localhost:11434/v1, api_keyollama, temperature0.1, ) chain ( { context: retriever | format_docs, question: lambda x: x[question], } | prompt | llm | StrOutputParser() )Prompt 调优是效果提升最立竿见影的环节。这个 system prompt 里我写了两条硬约束“只根据资料回答”和“资料不足时明确说不知道”。这两条直接压制了大模型的“编造欲”。我试过不加任何约束的版本同一个知识库模型能言之凿凿地给出资料里根本不存在的结论加上约束后虽然模型偶尔会回答“未找到相关信息”但至少不会误导人。temperature0.1是我固定的低随机值。知识库问答属于事实型任务温度越低越稳定太高了模型会自行发挥。如果要让回答更灵活最多调到 0.3再高就不适合 RAG 场景了。3.6 FastAPI 封装把 RAG 变成可调用的服务为了让团队其他人能直接用我把链路封装成了 FastAPI 服务暴露POST /ask接口。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): question: str app.post(/ask) def ask(req: AskRequest): result chain.invoke({question: req.question}) return {answer: result}同时加了一个POST /upload接口支持上传本地文件后即时更新向量库。这里要注意每次上传都要重新加载文档并做 embedding耗时比较长我做了异步处理并返回任务 ID前端轮询后台状态。生产环境建议用 Celery 或者 FastAPI BackgroundTasks避免阻塞其他请求。4. 开箱即用的工程化细节4.1 目录结构与配置设计项目结构我刻意保持扁平方便理解langchain-rag-chat/ ├── app.py # FastAPI 入口 ├── rag_core.py # RAG 核心逻辑加载、切分、向量化、检索 ├── config.yaml # 业务配置 ├── .env.example # 环境变量模板 ├── data/ │ ├── docs/ # 放入待索引的文档 │ └── chroma_db/ # 向量库持久化目录 ├── requirements.txt └── Dockerfileconfig.yaml里集中管理切分参数、检索参数和模型配置。环境变量单独放.env避免把密钥写进代码仓库。.env.example给出所有需要填写的变量名和示例值新环境拷贝一份改后缀就能启动。# config.yaml splitter: chunk_size: 500 chunk_overlap: 50 retriever: search_type: mmr k: 5 fetch_k: 20 embedding: provider: huggingface model: BAAI/bge-m3 llm: provider: openai_compatible model: qwen2.5:7b base_url: http://localhost:11434/v1 temperature: 0.14.2 Docker 部署一键启动为了让“开箱即用”名副其实我写了 Dockerfile 和 docker-compose。服务的启动拆分成了两步首次启动先跑索引构建命令之后正常启动服务。这里的核心思路是数据卷挂载data/目录让容器重启不会丢失向量库。# 首次构建索引 docker build -t langchain-rag-chat . docker run --rm -v $(pwd)/data:/app/data langchain-rag-chat python index.py # 启动服务 docker run -d -p 8000:8000 -v $(pwd)/data:/app/data --env-file .env langchain-rag-chat踩坑记录容器里运行 HuggingFace Embedding 模型时如果内存不足进程会被 OOM Kill。我在 Dockerfile 里给 Python 进程设置了一个内置 4GB 内存的启动参数同时把 chorma 的临时目录指向数据卷避免容器重启后索引丢失。5. RAG 知识库到底能不能存图片这个问题在团队内部讨论了很久也是搜索热词里的高频问题。我的答案是能存但要看你怎么理解“存”。5.1 区别文件存储和内容理解是两码事把图片文件作为附件存进知识库任何文件系统都能做到。但 RAG 问答要求的是“根据图片内容回答问题”——比如你上传一张架构图问“这个系统用了什么中间件”如果你的检索链路只处理了文件名和文件路径那模型什么都答不出来。5.2 多模态 RAG 的两种落地模式真正支持图片内容检索的方案有两种。第一种是多模态 Embedding比如 CLIP 这类模型把图片直接映射成向量检索时可以和文本向量在同一空间做相似度匹配。第二种是“图生文”用视觉模型把图片内容转成一段文字描述再把描述文本纳入向量库检索命中后既能返回图片又能返回对应的文字描述。第二种方案我实际用过对大多数业务场景够用了因为问题通常关心图片里的关键信息和结论而不是像素级细节。实现方式也不复杂调一次视觉模型拿到描述把描述当普通文本走 RAG 链路同时保存图片路径检索返回时附带地址给前端展示。5.3 图文混排文档的最佳实践对于 PDF 里的图文混排内容我建议先做 OCR 或版面解析把图片区域单独抽出来生成描述再和正文段落一起切分、建向量。这样“图 文”都能被检索到而不是 PDF 解析时把图片区域直接丢弃。如果只是把图片压缩后硬塞进 PDF再用普通 PDFLoader 解析出来的文本里往往只有图片说明或完全空白这种就是无效索引。6. 常见问题与排查技巧实录实际操作里遇到最多的问题都是环境、编码和参数层面的我把排查过程整理成速查表。6.1 中文文档乱码和加载失败问题现象根因解决办法加载 txt/md 报 UnicodeDecodeError文件是 GBK 编码加载时设置 loader_kwargs 自动尝试 GBKPDF 解析出来是乱码/空白PDF 本身是扫描件没有文本层先做 OCR 再转换文本文件夹里有隐藏文件导致加载报错DirectoryLoader 默认包含.DS_Store等glob 条件过滤或加载前排除隐藏文件我踩得最多的是第一个。团队文档库里有大量从旧系统导出、编辑工具默认保存成 GBK 编码的 txt 文件。后来我在TextLoader外面包了一层编码探测简单粗暴但很有效:def autodetect_encoding(filepath): for enc in [utf-8, gbk, gb18030]: try: with open(filepath, r, encodingenc) as f: f.read() return enc except UnicodeDecodeError: continue return utf-86.2 检索召回质量差症状是检索返回的片段看起来跟问题无关或者相关片段被淹没在噪声里。排查链路按照“由近及远”来排查切分是不是有问题如果一句话横跨两个 chunk检索时关键词对不上。Embedding 模型选对了没有中文文档用英文 embedding效果会很差。检索类型和参数有没有调过默认search_typesimilarity遇到同质化片段就容易重复召回换成 MMR 并调整fetch_k能缓解。向量库里的数据是不是旧的改了分块参数或者重新加载文档后旧的向量残留导致命中过时内容。这里给出一个实操小技巧调试时打印出检索出来的原始片段不要只盯着最终回答。如果片段本身就答非所问问题出在检索层怎么调 prompt 都没用如果片段是对的回答却不对才需要调 prompt 和模型参数。这个区分能大幅缩短排错时间。6.3 幻觉问题压制不住我用temperature0.1和 system prompt 约束之后幻觉问题已经小了很多但仍然存在。遇到模型一本正经说资料里不存在的细节时可以先检查两件事一是是否有多个相似片段拼接后产生了结论冲突二是是否把不相关的片段推给了模型导致模型在综合时自由发挥。最彻底的做法是加一道“引用溯源”让模型在回答末尾列出引用的片段编号前端渲染时展示来源。这样回答即使有偏差也能快速回溯到是哪段资料导致的迭代修正时效率高很多。实现方式就是在 prompt 里加一条输出要求让模型把用到的片段来源列出来。6.4 大文档内存占用过高跑完一个 500 页的 PDF直接把 Docker 容器干重启了。排查发现PyPDFLoader一次性把整份 PDF 解析成一个超大 Documentembedding 时内存直接爆掉。解决方法是按页拆分用递归切分器之前先按页或其他边界拆成多个 Document再统一切分。另一种做法是改用流式加载一次只处理一页。6.5 换了 embedding 模型后检索效果变差这个问题最隐蔽。我在实验阶段把 embedding 模型从 m3e-base 换到 bge-m3 后检索效果反而退步了——因为 Chroma 持久化目录里存的是旧模型生成的向量新模型查询时拿新向量去比对旧索引语义空间根本不匹配。后来每次换 embedding 模型都强制清除data/chroma_db重新建索引问题才解决。7. 从 RAG 到 Agent后续还可以怎么扩展7.1 引入 LangGraph 做复杂流程RAG 检索是一个“单轮问答”的闭环遇到需要多步推理的问题就力不从心了。比如“对比 A 方案和 B 方案的成本差异”需要先检索各自成本数据、再综合对比。这种场景适合引入 LangGraph 做多步 Agent 流程。LangChain 官方有个 agent-inbox 方向让 Agent 可以接收外部输入、挂起等待人工确认对复杂任务挺有用。我在实验环境里试过 LangGraph把 RAG 链包成一个节点做成“先检索-再判断是否足够-不够再检索”的循环。效果比一次性 RAG 稳定但成本也翻倍适合对准确性要求高的场景。7.2 知识图谱 RAG结构化知识库的进阶纯向量的 RAG 处理关系型问题很弱。“有哪些服务依赖数据库 X”这类问题在纯向量检索下回答只能靠文档里的片段偶然命中。如果知识库里同时维护一份实体和关系图谱KG可以在向量检索之外做图谱查询这两类结果合并后再喂给模型能显著提升关系型问题的正确率。这个方向叫 GraphRAG 或 KG RAG适合团队文档有大量跨文档引用关系的情况。代价是知识图谱的构建成本高初期需要人工梳理实体关系所以我的建议是先跑通纯 RAG等确实遇到关系型问题质量瓶颈再上图谱。7.3 RAG 的瓶颈和评估思路做这个项目之前我一直以为 RAG 最大的瓶颈是模型能力实际操作后才发现检索质量才是天花板。文档质量差、切分不匹配、向量检索召不回相关片段再强的模型也白搭。所以项目里我预留了一个评估脚本从知识库里抽出几十个高频问题作为测试集每次改完参数就批量跑一遍统计命中率和回答完整度。没有评估的 RAG 优化就是盲人摸象——你觉得改好了其实只是这次运气好。最后说一点实际部署的体会RAG 项目的复杂度不在代码而在数据质量。把文档整理干净、统一格式比调任何参数都更有效。这套项目跑通之后我们团队现在开始整理规范化的文档标注体系了——好的输入才有好的输出RAG 的架构和代码可能只能占一半功劳。
返回列表