简介基于Langchain的RAG问答应用实战PDF面向希望快速上手大模型问答开发的技术人员。以百度百科藜麦数据作为私域数据源完整演示了从环境搭建CUDA、Python、PyTorch等版本说明、Langchain框架使用到文档加载、固定长度分割、向量化入库并构建简易问答系统的全流程。资源共1个PDF文件压缩包约390KB整体内容紧凑、小而精适合快速阅读与本地复刻。目前已有285人学习下载。读者可通过该实战案例掌握RAG架构关键环节使用TextLoader加载本地文本用CharacterTextSplitter按固定字符长度切分并输出元数据以m3e-base嵌入模型和Chroma向量库完成入库同时获知OCR扫描文档时的错漏字应对思路并了解藜麦品类、产地等背景知识对问答理解的帮助整体内容既覆盖技术细节也包含业务背景是大模型八股文面试与动手实践的高性价比参考资料。1. 大模型八股文面试里的高频考点用LangChain把RAG问答链路完整跑通大模型面试里RAG是出现频率极高的八股文考点而“给你一段私有数据用LangChain做一个问答机器人”几乎是必考项目。很多人背得清概念却说不清 chunk_size 怎么设、检索到的东西怎么喂给模型、多轮对话怎么避免历史干扰。这篇文章用一个完整实例讲清楚拿一段公开百科词条藜麦模拟私域文档从加载、分割、向量化到组装 ConversationalRetrievalChain把 RAG 问答链路一步步落地。适合准备大模型岗面试、刚接触 RAG、或者手里有文档想快速做问答的人。你会发现真正难的不是背八股文而是每个环节的参数为什么这么设。2. 环境与数据准备先把版本踩实再谈RAGRAG 链路从开始到生成依赖链很长。最让人头疼的往往不是算法而是环境。原项目给出的软件组合是 CUDA 11.7、Python 3.10、PyTorch 1.13.1cu117以及一个特定时期的 LangChain API。如果你跟着最新版装很可能在第一行 import 就翻车。所以这一章把环境搭建和数据准备这两个最容易出错的环节单独拎出来讲。2.1 软件版本矩阵为什么要对齐 CUDA 和 Python先说结论不要无脑装最新版。CUDA 11.7 对应 PyTorch 1.13.1 的 cu117 轮子这个组合在很多大模型项目中已经验证过很多次稳定且资料多。Python 3.10 则是因为 PyTorch 1.13.1 和 LangChain 老版本对这个解释器支持最友好3.11 在早期版本里偶尔会遇到依赖编译问题。创建环境的命令如下conda create -n py310_chat python3.10 -y conda activate py310_chat pip install datasets langchain sentence_transformers tqdm chromadb langchain_wenxin逻辑说明第一行用 conda 创建全新环境避免污染系统 Python第二行激活第三行安装依赖。datasets 虽然这里用不到但在很多官方示例里会出现装上是给后面调试留余地。sentence_transformers 是加载 m3e-base 这类 embedding 模型的底层依赖langchain_wenxin 则负责对接第三方中文大模型 API。参数说明conda 环境名可改但保持 py310_chat 可以让你直接复制后面的命令。如果你的机器没有 NVIDIA 显卡可以跳过 CUDA把下一章 embedding 的 device 参数设为 cpu照样能把流程跑通。PyTorch 建议单独安装避免 pip 自动装出一个和 CUDA 不匹配的版本。比如pip install torch1.13.1cu117 --index-url https://download.pytorch.org/whl/cu117这是常见做法。如果卡在下载可以换国内 PyPI 镜像源但要注意镜像源里 PyTorch 的 cu117 轮子不一定全还是优先官方源。2.2 加载本地文本TextLoader 的中文编码问题数据文件准备上我把百科词条页面另存为纯文本命名为藜.txt。原文很长包含大量换行、引用标记和特殊空格。加载用 langchain 的 TextLoader 就可以它会自动生成 Document 对象。from langchain.document_loaders import TextLoader loader TextLoader(./藜.txt, encodingutf-8) documents loader.load()逻辑说明loader.load()返回一个列表里面只有一个 Document。page_content 是完整文本metadata 里记录了 source 为 ./藜.txt。后续分割和检索都会带着这个 metadata 走方便定位来源。参数说明encodingutf-8是我强烈建议加上的。Windows 环境下默认编码可能是 GBK不加这一句碰到带中文的文件就报 UnicodeDecodeError。如果文件是其他编码比如 UTF-8-sig带 BOM也可以对应调整。百科网页里常见的 \xa0不间断空格会原样进入文本它看起来像空格但实际不参与分词后面分割时可能导致 chunk 边界很奇怪最好在保存时先替换掉。常见做法是提前做一遍清洗with open(./藜.txt, r, encodingutf-8, errorsignore) as f: text f.read() text text.replace(\xa0, ).replace(\n\n, \n) with open(./藜_clean.txt, w, encodingutf-8) as f: f.write(text)逻辑说明先把原文件读出来把不间断空格换成普通空格压缩连续换行再存成清洗后的文件。之后加载时指向 _clean.txt 即可。这一步能减少分割时莫名其妙切到空白的位置。如果你用 OCR 扫出来的文档还会有个别字识别错或漏识别必须人工校对一遍不能指望 RAG 自己纠错。2.3 文档分割chunk_size、chunk_overlap 和切块边界原项目用 CharacterTextSplitterchunk_size128chunk_overlap0。这个配置跑起来很快但有一个明显问题128 个中文字符对百科这种信息密度很高的文本来说太短容易把完整叙述切碎。先看原始代码from langchain.text_splitter import CharacterTextSplitter text_splitter CharacterTextSplitter( chunk_size128, chunk_overlap0 ) documents text_splitter.split_documents(documents)逻辑说明分割后的 documents 列表会多出很多块每个块都是独立的 Documentmetadata 里的 source 会保留。后面向量化时一个块对应一个向量所以分割质量直接决定检索质量。参数说明chunk_size 控制每个块的最大字符数。128 偏小适合做短文本问答如果你的文档是长段落建议调到 256-512。chunk_overlap 控制相邻块重叠字符数设为 0 时最简单但答案如果跨块就会丢信息。实际项目中我更推荐 RecursiveCharacterTextSplitter它按换行、句号、空格逐级切分切出来的块更天然。一个很典型的坑是chunk_overlap0 chunk_size128问“藜麦怎么防治虫害”时检索只命中了“虫害有象甲虫、金针虫”这一句而具体的防治方法正好在下一条 chunk 里检索排名不高模型就只能回答一半。这个在第五章会专门展开。3. 向量化与检索m3e-base Chroma 的选型与参数分割完的文本要变成向量才能被语义检索。这里有两个角色embedding 模型负责把文本映射成向量向量数据库负责存储和检索。原项目选 m3e-base 和 Chroma是中文 RAG 场景下性价比很高的组合。这一章不仅讲代码还把为什么选这个模型、向量库里到底存了什么讲清楚。3.1 embedding 选型m3e-base 在中文场景下的取舍embedding 模型决定了“语义是否相似”判断得准不准。如果选英文为主的模型处理中文检索质量会非常差。m3e-base 是中文社区常用的模型它在中文句子相似度、文本检索任务上表现稳定权重体积不大CPU 也能跑。相比云端 embedding 接口本地模型最大优势是私有数据不出机器也不按调用次数收费适合知识库场景反复检索。加载方式如下from langchain.embeddings import HuggingFaceBgeEmbeddings model_name moka-ai/m3e-base model_kwargs {device: cpu} encode_kwargs {normalize_embeddings: True} embedding HuggingFaceBgeEmbeddings( model_namemodel_name, model_kwargsmodel_kwargs, encode_kwargsencode_kwargs, query_instruction为文本生成向量表示用于文本检索 )逻辑说明HuggingFaceBgeEmbeddings 是 langchain 里加载 BGE 系列模型的封装。model_name 指向模型仓库地址第一次运行会下载权重到本地缓存之后离线可用。model_kwargs 中的 devicecpu 表示在 CPU 上做向量化如果显存充足可以改为 cuda:0速度能快不少。encode_kwargs 中的 normalize_embeddingsTrue 表示对生成的向量做 L2 归一化归一化后内积和余弦相似度计算结果一致检索分数更好解释。参数说明query_instruction 这个参数非常关键。BGE 系列模型在训练时查询向量和文档向量用了不同的指令前缀。如果不传这句模型会把查询也当成文档来编码查询向量和文档向量不在同一个向量空间里语义匹配效果掉一截。这个参数随模型走换成其他模型时必须同步更换。3.2 向量入库Chroma 的持久化与重载Chroma 是一个本地向量数据库把向量和原文一起存下来查询时按相似度排序返回。原项目直接调 Chroma.from_documents它会先对每个 document 调用 embedding再写入内存库。如果只是演示没问题但重启程序后向量就没了所以要单独做持久化。from langchain.vectorstores import Chroma persist_directory ./chroma_db db Chroma.from_documents( documents, embedding, persist_directorypersist_directory ) db.persist()逻辑说明persist_directory 指定向量库落盘目录persist() 把当前内存中的数据写到磁盘。以后重新启动不需要再对全文做 embedding直接从磁盘加载就好。db Chroma( persist_directory./chroma_db, embedding_functionembedding )参数说明注意新版 Chroma 已经不建议把 persist_directory 当作位置参数最好用关键字参数指定。embedding_function 需要和建库时用同一个 embedding 对象否则向量维度或编码不一致检索会报错或返回空结果。检索验证results db.similarity_search(藜一般在几月播种, k4) for doc in results: print(doc.page_content[:100]) print(doc.metadata)逻辑说明similarity_search 按余弦相似度返回 top-k 文档块。输出前 100 字符和 metadata可以直观看出检索是否命中。如果你发现返回的结果和“播种日期”完全无关多半是 embedding 的 query_instruction 没设好或者分割参数导致语义被切碎。3.3 检索质量的自查方法我一般会先跑一轮 similarity_search把问题、返回块、相似度分数一起打印看看检索阶段是不是就已经偏了。similarity_search_with_score可以拿到距离分数分数越低代表越相似但这个值只有在同一批向量库内才有比较意义。这里有个经验如果准确答案在原文里明确存在但检索不到大概率是 chunk_size 设置不合理而不是 embedding 模型的问题。chunk_size 调到 1000一个块包含多个主题查询时虽然命中了块但块里杂讯太多生成时模型容易被无关内容带偏。chunk_size 调到 64又可能只拿到半句话。另外Chroma 默认按余弦相似度检索对同义句有不错的召回但对精确数字、特定农药名称这类实体可能反而不如直接词频召回。如果你的业务里有大量固定名词可以在检索后加一层关键词过滤只保留包含这些实体的 chunk 并优先排列。这个技巧在农业、医疗这类领域非常实用。4. 组装RAG问答链从Prompt到多轮对话检索命中之后接下来就是把文档块、用户问题、历史对话和生成模型串起来。LangChain 的 ConversationalRetrievalChain 是这条链的骨架但你需要知道它内部做了什么才能真正用好。4.1 Prompt 模板把“必须用上下文回答”写成硬约束RAG 的 Prompt 比普通对话复杂因为它同时注入背景知识和问题。我习惯用模板把任务描述、背景知识、回答要求分开template 【任务描述】 请根据用户输入的上下文回答问题并遵守回答要求。 【背景知识】 {context} 【回答要求】 - 严格根据背景知识回答禁止使用常识或已知信息。 - 如果背景知识中没有答案直接回答未找到相关答案。 ----------- {question} 逻辑说明{context} 是检索到的多个文档块拼接后的文本{question} 是用户问题。这个模板把模型限制在“只能从给定材料中找答案”的范围里能明显降低幻觉。但要注意模板只是约束不是保险。当检索到的材料本身有错时模型也会跟着错这是 RAG 的固有边界。参数说明背景知识里的 {context} 变量名要和合并文档链的 document_variable_name 保持一致否则在组装 chain 时找不到变量。{question} 由 chain 默认注入。如果你尝试在模板里手工拼接 chat_history会在多轮场景下重复注入造成上下文爆炸应该把历史交给 memory 组件处理。4.2 用ConversationalRetrievalChain串起记忆与检索老版本 LangChain 用 ConversationalRetrievalChain.from_llm 一行代码搞定。它的内部流程是先把 memory 里的历史问题和当前问题合并成一个新问题用新问题去 retriever 检索再把检索到的内容和问题一起交给 LLM 生成答案。from langchain.memory import ConversationBufferMemory from langchain.chains import ConversationalRetrievalChain llm Wenxin( modelernie-bot, api_keyos.environ.get(API_KEY), secret_keyos.environ.get(SECRET_KEY), ) memory ConversationBufferMemory( memory_keychat_history, return_messagesTrue ) retriever db.as_retriever() qa ConversationalRetrievalChain.from_llm( llmllm, retrieverretriever, memorymemory )逻辑说明Wenxin 是 langchain_wenxin 插件里的大模型适配类调用前需要设置环境变量 API_KEY 和 SECRET_KEY。ConversationBufferMemory 会把每轮问答记录下来memory_key 必须和 chain 默认键名一致。retriever 由 db.as_retriever() 生成默认 k4可以通过 search_kwargs 调整返回数量。调用res qa({question: 藜怎么防治虫害}) print(res[answer])参数说明如果你在某个版本里找不到 ConversationalRetrievalChain说明 LangChain 已经重构。新版推荐用 create_history_aware_retriever 配合 create_retrieval_chain。老代码迁移时最大的变化是 memory 不再自动管理需要自己把 chat_history 传进去。所以看懂原理比背一个 API 更重要。4.3 高级组装手动控制历史压缩与文档融合from_llm 方式隐藏了很多细节方便但不灵活。当你要在多轮场景里压缩历史或者想自定义文档合并策略时应该手动拼 chain。question_generator 负责结合历史生成独立问题combine_docs_chain 负责把检索到的文档合并成一段连续的上下文。from langchain.chains import LLMChain, StuffDocumentsChain from langchain.prompts import PromptTemplate from langchain.prompts.chat import ChatPromptTemplate, HumanMessagePromptTemplate, SystemMessagePromptTemplate combine_template 根据背景知识回答问题不得编造。 【背景知识】 {context} 【问题】 {question} prompt ChatPromptTemplate.from_messages([ SystemMessagePromptTemplate.from_template(combine_template.strip()), HumanMessagePromptTemplate.from_template({question}) ]) llm_chain LLMChain(llmllm, promptprompt) combine_docs_chain StuffDocumentsChain( llm_chainllm_chain, document_separator\n\n, document_variable_namecontext )逻辑说明这里用 StuffDocumentsChain 把检索到的 documents 按顺序拼接中间用两个换行隔开。它适合检索结果少、上下文窗口够用的情况。如果检索结果很多或文档很长需要使用 MapReduce 或 Refine 策略但那会牺牲响应速度。question_generator 的 prompt 设计condense_template 根据历史对话把当前问题改写成一个独立、明确的问题。 历史对话{chat_history} 当前问题{question} 独立问题 q_gen_chain LLMChain( llmllm, promptPromptTemplate.from_template(condense_template) )最终组装qa ConversationalRetrievalChain( combine_docs_chaincombine_docs_chain, question_generatorq_gen_chain, retrieverretriever, return_source_documentsTrue, return_generated_questionTrue )调用显式传入 chat_historyres qa({ question: 那怎么防治呢, chat_history: [] })这里“那怎么防治呢”会被 question_generator 改写成“藜麦常见虫害的防治方法有哪些”然后去检索检索质量远比直接拿原问题去查要好。return_generated_questionTrue 会在返回结果里带上改写后的问题方便调试。5. 常见问题与避坑四个实测踩坑记录RAG 链路短但每个环节都有坑。以下是我复现这个项目时真实遇到过的问题按“现象-原因-解决”拆解。5.1 现象安装依赖后import报错API找不到现象执行from langchain.chains import ConversationalRetrievalChain终端提示 ImportError或者Chroma.from_documents报缺少参数。原因LangChain 版本迭代很快老教程的 API 在新版本里被移到其他模块甚至被删除另外 langchain_wenxin 插件可能拉取最新 langchain导致和旧代码不兼容。解决把 langchain 固定到教程对应的稳定版本。我的处理方式是pip install langchain0.0.267然后重新安装其他依赖。如果环境已经乱掉不要手工卸载直接删掉环境重建conda remove -n py310_chat --all conda create -n py310_chat python3.10 -y conda activate py310_chat pip install ...经验以后凡是跑开源 RAG 项目先把 requirements.txt 锁死不要用盲目的 latest。这不是玄学是 LangChain 社区血泪教训。5.2 现象chunk_size128导致回答缺一半现象问“藜怎么防治虫害”模型回答出害虫列表却漏掉了具体的每亩用药量。对话看起来通顺但缺失关键信息。原因原文档中“防治方法”写到两个段落里chunk_size128 硬切后第一段只留到了“可每亩用3%的辛硫磷颗粒剂”第二段才是“也可以每亩用40%的辛硫磷乳油”。检索返回的前几个 chunk 恰好没把涉及用量的那块排进来模型就没法从上下文里看到完整答案。解决给分割器加 overlap或者直接换成 RecursiveCharacterTextSplitter。from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size256, chunk_overlap30 ) documents text_splitter.split_documents(documents)参数说明chunk_size 从 128 升到 256 后单个 chunk 能覆盖更完整的防治操作解决很多跨块问题。chunk_overlap30 意味着每两个相邻块有 30 个字符重叠即使句子跨块也可以被下一块补全。这个改动会让向量库里的 chunk 数量减少查询速度更快回答完整度提高。但注意如果 chunk_size 太大比如超过 512一个 chunk 包含多个主题检索命中后生成的回答可能混入不相关内容需要按文档类型做取舍。5.3 现象检索结果与问题无关返回一堆相似句现象问“藜麦适合在哪里种植”similarity_search 返回的前几个 chunk 讲的是“种植历史”和“营养成分”。原因embedding 模型的 query_instruction 没有设置或者查询向量与文档向量没有对齐。BGE 系列模型在训练时查询和文档使用了不同的指令前缀必须通过 query_instruction 告诉模型当前是查询任务否则相似度计算会偏移。另外如果文档里“种植”这个词出现频率极高检索也会偏向这些段落。解决第一检查 HuggingFaceBgeEmbeddings 是否带 query_instruction 参数没有则补上。第二把 embedding 对象保存下来确保建库和加载用的是同一个。第三在向量检索后再加一个简单的关键词过滤比如要求返回的 chunk 必须包含“海拔”或“种植地区”等词否则跳过。这个后过滤逻辑可以用几行代码实现def filter_results(docs, keywords): return [d for d in docs if any(k in d.page_content for k in keywords)] filtered filter_results(results, [海拔, 种植])逻辑说明虽然粗糙但在领域实体明确时非常有效。缺点是可能漏掉表达不同但语义相关的 chunk所以只建议在问答链之外做调试用不要直接写进生产。5.4 现象多轮对话中模型复读历史答案而不看新文档现象第一轮问“藜麦有哪些虫害”第二轮问“怎么防治”模型回答里直接复述了第一轮的虫害列表并没有给出防治用药。原因ConversationBufferMemory 把全部历史对话放进了上下文其中包含“虫害”相关问答。模型认为用户要的是同一类信息于是历史答案的权重高于新检索的文档块产生“记忆覆盖检索”。解决给链条加 question_generator把含历史上下文的“那怎么防治”改写成独立的“藜麦常见虫害的防治方法有哪些”。改写后的查询去检索得到的文档块聚焦在“防治”而不是“虫害列表”生成时就不会被历史带偏。同时可以限制 memory 保留的轮数比如只保留最近两轮from langchain.memory import ConversationBufferWindowMemory memory ConversationBufferWindowMemory( memory_keychat_history, return_messagesTrue, k2 )参数说明k2 表示只保留最近两轮用户和 AI 消息。这样既支持多轮上下文又避免历史过长淹没检索结果。单轮问答场景可以直接传入空列表不启用 memory。6. 进阶让RAG结果可追溯一个 source_documents 排查技巧RAG 上线前最怕的就是模型一本正经地胡说。哪怕 Prompt 里写了“禁止编造”模型依然可能基于不完整检索给出错误答案。我习惯在每次生成后把答案和检索来源一起打印出来人工核对一遍。这个动作虽然朴素但能在几分钟内暴露 80% 的问题。具体方法是在组装 chain 时打开return_source_documentsTrue然后在拿到结果后遍历source_documents把其中的原文片段和答案对应打印。res qa({ question: 藜麦的适宜种植海拔是多少, chat_history: [] }) print(答案:, res[answer]) print(生成时用的来源:) for idx, doc in enumerate(res.get(source_documents, [])): print(f--- 第 {idx} 个来源 ---) print(doc.page_content)逻辑说明source_documents是检索阶段命中的文档块。通过它我可以验证答案的每个数字、每个结论都在原文里有对应。如果答案说“海拔3000-4000米”而来源文档里确实有“最适高度为海拔3000-4000米”这个答案就是可信的。如果找不到对应来源就要怀疑模型在硬编。这个技巧不只是调试用还能作为 RAG 系统的最后一道防线。不少项目把来源信息直接展示给用户页面下方列出“参考了哪几段内容”用户自己就能判断答案靠不靠谱。在企业知识库场景里溯源更是刚需——业务方在收到用户投诉时必须回到原文核实。如果你希望更自动化可以再增加一次校验调用把答案和 source_documents 拼接成一个新 Prompt让模型判断答案是否完全来源于文档如果判定为否就统一回复“未找到相关答案”。这个做法的代价是多一次 LLM 调用但能显著降低幻觉输出。从那以后我每次搭 RAG 应用都强制先跑一遍“答案来源对照”检查确认没有丢信息、没有张冠李戴才敢把服务开放给真实用户。这个习惯帮我挡掉了好几次因为 chunk 分割导致的“看似合理但实际错误”的回复。完整的项目代码和说明已经按本文流程整理进资源包下载后照着命令跑一遍就能复现希望帮到你。本文还有配套的精品资源点击获取