基于 langchain 的 RAG 问答应用实战:从搭建到调优

发布时间:2026/10/6 15:09:52
基于 langchain 的 RAG 问答应用实战:从搭建到调优 简介面向大模型应用开发与RAG实战学习者这份PDF以百度百科藜麦数据模拟私域数据完整演示基于LangChain构建检索增强问答系统的流程覆盖环境搭建、本地数据加载、文本分割、向量化入库与检索问答等关键环节适合准备大模型相关岗位面试或正在入门LangChain的开发者参考。资源为单个PDF文件压缩包大小390KB文档结构紧凑可直接作为快速上手的精简教程。目前已有283人学习下载。资料中详细给出了CUDA、Python、PyTorch等版本搭配建议并展示了CharacterTextSplitter、HuggingFaceBgeEmbeddings、Chroma等核心组件的实际用法同时对OCR扫描文档可能出现的识别误差也做了提醒能帮助读者避开常见坑点快速跑通一个最小可用的RAG问答示例。通过这份实战案例读者既能理解RAG应用从数据构建到检索生成的完整链路也能掌握LangChain文档处理、向量检索等基础技能。1. 大模型八股文面试里RAG 问答应用为什么总被拿来当考题面试大模型岗位时十个面试官里有八个会问 RAG剩下两个直接让你手写一个问答应用。原因很简单RAG 是当前大模型落地最稳、投入产出比最高的方案它不要求你重新训练模型却能解决知识过期、幻觉、私有数据不可泄露这三座大山。基于 langchain 的 RAG 问答应用几乎成了「会背八股但不会干活」的照妖镜——从文档加载、文本分割到向量检索、Prompt 组装每一环都藏着可以深挖的细节。这篇实战笔记就是按我自己做过的项目路径来写的从最小可跑通的代码开始到检索质量调优、踩坑记录最后给你一套面试时能直接用的评估方法和话术。适合正在准备大模型岗位面试、或者刚接手知识库问答项目的工程师跟着步骤走完你就能讲清楚「为什么这么设计」而不只是「怎么调 API」。2. 拆开 RAG 问答应用一句话原理和 langchain 的选型理由2.1 RAG 的核心流程检索增强不是把知识塞进模型RAGRetrieval-Augmented Generation的核心思想是不把知识写进模型参数而是在回答前先从外部知识库检索相关片段把片段拼进 Prompt 再让大模型生成。这样模型权重不用动知识库却能随时更新。常见做法是离线阶段把文档切片、向量化存入向量数据库在线阶段把用户问题向量化检索 top-k 相似片段和问题一起交给 LLM 生成答案。这个流程听起来简单但落地时每一步都有坑。比如文本切分切大了检索不精准切小了语义不完整向量化模型选不好中文长尾词全匹配不上检索回来的片段顺序不对大模型读起来逻辑混乱。langchain 的价值在于它把这些步骤封装成了标准化组件你可以像搭积木一样替换其中的任何一环而不需要自己重写一套编排逻辑。面试时被问到「RAG 和微调的区别」你就可以从这条链路展开RAG 改的是推理时的上下文微调改的是模型参数本身——前者适合知识快速更新、需要可追溯的场景后者适合改变模型风格、能力边界的场景。2.2 langchain 不是必须但它是面试和原型验证的最优解说实话生产环境里很多人会自己写编排逻辑甚至直接裸调 OpenAI SDK因为 langchain 的抽象层有时会让人头疼版本升级频繁、回调机制复杂。但作为面试准备和快速原型验证langchain 依然是最优解社区文档全、中文教程多、组件几乎覆盖了 RAG 的所有环节。面试官问你用过什么框架你回答「基于 langchain 搭建但深入理解每个组件的原理能脱离框架复现」——这话一出口印象分就上来了。我记得最早用的是 langchain 0.1.x后来升级到 0.3.x很多 API 都变了。如果你现在看网上的老教程可能会发现load_qa_chain这类接口已经废弃。这也是我建议你重点看官方文档对应版本的原因。下面我会用当前常用的langchain-core、langchain-community组件写法尽量让你复制就能跑同时也会指出哪些地方容易因版本差异翻车。3. 基于 langchain 搭建最小 RAG 问答应用从 PDF 到答案的完整代码3.1 环境准备和依赖安装注意版本锁定我一般会建一个独立的虚拟环境然后用 pip 安装以下依赖。这里强烈建议锁定版本不要直接装最新版——langchain 的 breaking change 很频繁今天能跑的代码下周可能就报ModuleNotFoundError。pip install langchain0.3.7 langchain-community0.3.7 langchain-openai0.3.0 pip install chromadb0.5.15 faiss-cpu1.9.0 pip install pypdf5.1.0 tiktoken0.8.0这套组合我跑了半年基本稳定。langchain-openai是用来接 OpenAI 兼容接口的后面接 Ollama 本地模型时也可以复用这个类只需要改base_url和api_key。chromadb和faiss-cpu是向量数据库二选一即可我常用 Chroma因为它是本地文件型调试时能看到存进去的原始文档和向量。pypdf负责解析 PDFtiktoken用来数 token、控制文本分片大小。3.2 加载 PDF 并切分文本chunk_size 不是越大越好先写一个最核心的加载函数。常见做法是用PyPDFLoader把 PDF 按页面加载成文档对象然后再做文本切分。这里的切分参数直接决定后续检索的精度。from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader PyPDFLoader(面试知识库.pdf) documents loader.load() print(f共加载 {len(documents)} 页) text_splitter RecursiveCharacterTextSplitter( chunk_size500, # 每个 chunk 的最大字符数 chunk_overlap80, # chunk 之间的重叠字符数 separators[\n\n, \n, 。, , , , ], length_functionlen, ) chunks text_splitter.split_documents(documents) print(f切分为 {len(chunks)} 个 chunk)为什么chunk_size设成 500 而不是 1000这取决于你的文档类型和 embedding 模型的输入上限。像百度问言、OpenAI 的text-embedding-3-small支持 8191 token但检索精度并不随 chunk 增大而提升。太大的 chunk 会混入多个主题向量表示被平均化检索回来一看好几个段落都在讲别的事大模型就容易被带偏。chunk_overlap80是为了避免一句话正好被切断检索时丢失关键上下文。我试过 overlap 设成 0结果很多答案里出现半句话。这里有个关键点RecursiveCharacterTextSplitter是按字符数切的不是按 token 数。中文场景下len计算的是字符数一个「大」是一个字符一个英文单词是五个字符。如果你想按 token 切可以换TokenTextSplitter但中文分词不准时反而可能切得更碎。我常用的还是字符切分简单可控。3.3 构建向量库并实现检索embedding 模型的选择决定检索上限向量化是整个 RAG 链路中最玄学的一环。同一个问题用不同的 embedding 模型检索结果可能天差地别。生产环境我推荐用商用的中文 embedding API面试准备阶段可以用本地模型比如BAAI/bge-small-zh-v1.5通过langchain_community.embeddings.HuggingFaceEmbeddings加载。不过本地模型首次下载比较慢而且要装sentence-transformers。想快速跑通可以直接用 OpenAI 兼容接口的 embedding。from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma embedding OpenAIEmbeddings( modeltext-embedding-3-small, base_urlhttp://localhost:1234/v1, # 本地网关 api_keylocal, # 本地不需要真实 key ) vectorstore Chroma.from_documents( documentschunks, embeddingembedding, persist_directory./chroma_db, )base_url指向任意的 OpenAI 兼容服务这里我习惯用一个本地 API 网关做统一转发方便切换模型。Chroma.from_documents会把切好的 chunk 向量化并持久化到本地目录。如果你是第一次跑这一步会有点慢几百页 PDF 可能要几分钟。跑完后你会在./chroma_db下看到 sqlite3 文件和 embedding 文件——这就是你的知识库了。检索时核心参数是search_kwargs里的k。k 太小答案可能缺关键论据k 太大无关片段混进来会干扰生成。我一般先设 k4拿到结果后看召回的相关性再调。retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 4} ) question langchain 中 RecursiveCharacterTextSplitter 的作用是什么 docs retriever.invoke(question) for i, doc in enumerate(docs): print(f--- 第 {i1} 个片段 ---) print(doc.page_content[:100])这一步输出的就是检索结果。你在面试时可以熟练说出「相似度检索」的原理问题向量和文档向量做余弦相似度取 top-k。但面试官大概率会追问一句「如果两个片段语义相近但一个很简短一个很详尽怎么保证你能拿到详尽的那个」这就是引入 MMRMaximum Marginal Relevance的场景。search_typemmr可以在相关性和多样性之间做平衡避免返回的片段全是同一段的重复改写。我常用的配置是search_typemmr,search_kwargs{k: 4, lambda_mult: 0.7}。lambda_mult越大结果越偏向高相关性越小越偏向多样性。当你的知识库里有多篇内容相近的文章时MMR 能显著提升回答质量。3.4 组装 RAG Prompt 并调用大模型生成答案检索到片段之后最关键的一步是把片段和问题组装成 Prompt。这里不要直接拼接几百行原文大模型记不住也容易超窗口。常见做法是把每个片段按照「第几篇文档」标注来源然后倒序排列把最相关的放在最后因为 LLM 对离问题最近的上下文更敏感这是我实际对比后的经验。from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough llm ChatOpenAI( modelqwen2.5-7b-instruct, base_urlhttp://localhost:11434/v1, # Ollama 默认地址 api_keyollama, temperature0.3, ) prompt ChatPromptTemplate.from_messages([ (system, 你是一个严谨的技术问答助手。请基于以下知识片段回答问题。 要求 1. 如果片段中没有答案直接说「知识库中未找到相关信息」不要编造。 2. 回答中请标注信息来源片段编号例如 [1]。 3. 如果片段之间存在矛盾请指出矛盾点并给出你的判断。 知识片段 {context} 问题{question} ), (human, {question}), ]) def format_docs(docs): return \n\n.join(f[{i1}] {doc.page_content} for i, doc in enumerate(docs)) rag_chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) answer rag_chain.invoke(langchain 中 RecursiveCharacterTextSplitter 的作用是什么) print(answer)这里我用了ChatOpenAI接 Ollama 的本地模型实际体验中 7B 模型配合好的 Prompt 已经能答得像模像样。temperature0.3是为了减少幻觉同时保留一定的表达能力。如果你追求稳定输出可以直接设 0。format_docs函数会给每个片段编个号这是为回答中的引用标注做准备的面试官看到这个细节会认定你考虑过可追溯性。Prompt 里那句「如果片段中没有答案直接说知识库中未找到相关信息」非常重要。没有这条约束大模型会强行凑答案给你编一个看似合理但完全不在知识库里的内容这就是 RAG 最怕的幻觉。我实测过加了这条之后模型在知识盲区上会明显「变笨」但正确率大幅提升——这恰恰是知识库问答想要的效果。4. 检索质量调优让问答应用从「能跑」到「好用」的四个关键设置4.1 文本切分策略调优按标题层级切比纯按字符切更可靠上面我们用的RecursiveCharacterTextSplitter是按分隔符优先级切分的遇到\n\n会先按段落切。但如果你的 PDF 有明确的章节结构直接按固定长度切会把同一个章节的内容拆到不同的 chunk 里检索时容易丢上下文。我一般会先解析 PDF 的目录或标题用MarkdownHeaderTextSplitter按标题层级切或者自己在加载文档时给每个 chunk 打上「章节」的元数据。from langchain_text_splitters import MarkdownHeaderTextSplitter splitter MarkdownHeaderTextSplitter( headers_to_split_on[ (#, 一级标题), (##, 二级标题), (###, 三级标题), ] ) markdown_docs splitter.split_text(pdf_to_markdown_text)这个做法的前提是 PDF 能转成干净的 Markdown实际操作中扫描版 PDF 很难做到。退而求其次我会在切分后给每个 chunk 加上来源页码和章节名存进metadata。这样检索时可以做元数据过滤比如用户问「第三章的内容」时只检索该章节的 chunk避免跨章节干扰。调整切分参数不能用肉眼盯着几个案例调要看统计指标。我通常会先切一批文档统计 chunk 数量、平均长度、包含完整句子比例。如果发现大量 chunk 以逗号结尾说明分隔符里需要加。和如果某个 chunk 特别长说明该处文档结构没有合适的分隔符需要人工查看。4.2 Embedding 模型选型和微调的现实边界市面上 embedding 模型很多效果差异巨大。我实测过一个内部技术文档的知识库用 OpenAI 的text-embedding-3-small和国产的bge-large-zh做对比中文专业术语的召回率差了将近 20%。如果你的知识库是中文为主建议优先选择中文语料预训练的模型比如bge-*系列或者阿里的text-embedding-v3。不要迷信模型参数越大越好bge-small在长尾词上的表现可能比bge-large差但速度快一倍项目初期先用 small 跑通再根据效果升级。还有一点很多人不知道 embedding 模型可以继续微调。如果你的知识库领域性极强比如法律、医疗通用 embedding 的检索效果会明显乏力。业界做法是用领域内的句子对问题、答案构造训练集用sentence-transformers库做对比学习微调。这属于大模型微调里相对轻量的任务训练成本远低于 LLM 微调。不过面试阶段你只要能把「embedding 需要领域适配可以通过微调提升」这个思路讲清楚就已经超过大多数只会调 API 的候选人了。参数设置上有个容易被忽略的细节embedding 的normalize_embeddings参数。Chroma 默认对向量做 L2 归一化余弦相似度等价于内积。如果你换了向量库比如 FAISS需要确认它内部用的是内积还是余弦否则检索排序会不对。我踩过这个坑后面避坑章节会详细写。4.3 检索后处理rerank 是性价比最高的优化手当你检索回来的 top-k 片段质量参差不齐时直接丢给大模型往往得不到好答案。有些片段主题相关但不包含答案有些片段答案完整但排序靠后大模型容易被前面的干扰项带偏。这时候加一个 rerank 环节通常能让准确率提升 5 到 10 个点而且改动很小。常见做法是用cohere的 rerank API或开源的bge-reranker-base。langchain 社区提供了一些封装也可以自己写一个简单的逻辑用重排序模型计算每个片段和问题的相关分然后按分重排取前 2 个片段。from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder reranker CrossEncoderReranker( modelHuggingFaceCrossEncoder(model_nameBAAI/bge-reranker-base), top_n2, ) compression_retriever ContextualCompressionRetriever( base_compressorreranker, base_retrieverretriever, ) docs compression_retriever.invoke(langchain 中 RecursiveCharacterTextSplitter 的作用是什么)这里top_n2表示压缩后只保留 2 个片段。你可能会担心丢弃了其他片段会漏答案但实际测试中rerank 模型给原始检索结果中的高相关片段重新打分后保留前 2 个的回答完整度远高于保留 4 个未重排片段。因为大模型的上下文窗口有限喂 4 个半相关片段不如喂 2 个精准片段。这个结论在多个 benchmark 上也有验证。4.4 Prompt 组装把「角色」「知识」「边界」写清楚RAG 应用的最终输出质量一半取决于检索一半取决于 Prompt。我发现很多人在检索调优上花了很多精力却忽视了 Prompt 里「如何引用知识片段」「遇到矛盾怎么办」这些指令。好的 RAG Prompt 至少包含四层角色定义、任务描述、知识片段引用格式、不能做什么。这里再给你一个生产可用的 Prompt 模板角色你是企业内部的智能客服只能依据给定的知识片段回答禁止使用你自身预训练知识。 任务回答用户问题时优先引用知识片段中的原话答案不超过 200 字。 引用格式在答案句末标注片段编号如 [1]。如果多个片段支持同一结论列出所有编号。 边界如果知识片段不足以回答问题回复「知识库暂未覆盖该问题」并建议用户联系人工。 冲突处理如果多个片段说法不一致分别列出不同说法并标注各自来源片段编号。把这段写进system消息比只写「基于以下内容回答」要可靠得多。你可以在面试时说RAG 应用的上限由检索决定下限由 Prompt 兜底。检索不到时Prompt 必须能拦住幻觉否则系统就是个不可信的摆设。5. RAG 实战避坑PDF 解析、中文切分、向量检索和上下文溢出的 5 个翻车记录5.1 扫描版 PDF 加载后全是空文本现象用PyPDFLoader加载 PDF打印documents发现page_content全是换行符检索结果为空。原因PDF 是扫描图片构成的没有文本层。PyPDFLoader只能提取嵌入的文本无法识别图片中的文字。解决先判断 PDF 是否有文本层可以用pdfplumber加载后尝试提取字符。没有文本层的需要 OCR。我常用RapidOCR或PaddleOCR对每页图片做文字识别再按页面拼接成文本。这一步会慢很多但没法绕过。如果你要处理的扫描版 PDF 量大建议优先找原始 Word/Markdown 文档省掉 OCR 的时间和精度损耗。5.2 中文按标点切分后句子被拦腰截断现象检索回来的片段里经常有以「我们」结尾、下一段以「认为」开头的诡异切分答案读起来断断续续。原因RecursiveCharacterTextSplitter的默认分隔符是英文标点没有把中文的句号、问号、感叹号纳入优先分隔符。解决把separators显式配成[\n\n, \n, 。, , , , , ]中文标点的优先级要高于空格。另外chunk_overlap设为 80 只能缓解截断不能根治。如果你发现句子在中间被硬切检查一下该位置是否有特殊 Unicode 字符如全角逗号、竖线干扰了分隔符识别。我遇到过文档里有「•」这种列表符号默认分隔符不认识它导致一大段文本被当成一个不可分的块最后 chunk 大小超出模型上限。5.3 换 embedding 模型后向量库无法查询现象之前用 OpenAI embedding 存了 Chroma后来换成bge-small-zh用新模型查询时报错dimension mismatch或者返回结果完全不对。原因向量库里的向量维度由第一个 embedding 模型决定。OpenAI 的text-embedding-3-small输出 1536 维bge-small输出 512 维维度不一致相似度计算直接炸。解决embedding 模型一旦选定就不能中途更换。如果非要换必须重建向量库用新模型对全部文档重新向量化。对于生产环境我会在向量库的元数据里记录embedding_model字段每次查询时校验当前模型与库内模型是否一致。代码里可以加一个断言不一致时直接报错提醒而不是返回一堆垃圾结果。5.4 上下文窗口溢出答案被截断现象知识库片段很多每片段按 500 字符、k8 计算拼完 Prompt 就超过了模型 4k 上下文窗口报错context_length_exceeded。原因很多人只关注chunk_size却忘了k和chunk_size是乘数关系。窗口固定时两者不能同时取大。解决先确定模型的最大上下文留出 30% 给问题和回答剩下 70% 用于知识片段。比如 4k 窗口模型回答最多占 1k那知识片段只能占 3k 约 1500 个汉字对应 chunk_size500、k3。我可以给你一个参考公式max_context * 0.7 / chunk_size k。这还没有考虑 embedding 模型本身的 token 限制。在代码里我会用tiktoken计算拼接后的 token 数超出阈值时自动减小 k而不是直接报错。5.5 答案看似流畅但关键数字和名称是编的现象用户问「项目中提到的召回率是多少」模型答出「87.3%」但你在知识库里搜不到这个数字完全是大模型自己编的。原因检索到了相关片段但片段里没有具体数字模型在生成时把训练中学到的常见数字补进去了。这是 RAG 应用最隐蔽的问题比回答「不知道」更糟糕。解决在 Prompt 里强制要求「只能使用片段中出现的数字未出现则明确说未提供」。更硬的办法是用正则或规则对输出做校验检测片段中没有出现过的数字串如果出现了就二次生成或拒绝回答。我通常会在服务端加一个后置校验逻辑提取回答中的所有数字判断这些数字字符是否至少包含某个片段中的数字子串不满足则返回「知识库信息不足」。这个方法不完美但能把编造数字的概率降一个量级。6. 用评估驱动迭代一个 20 条问题集就能让 RAG 效果翻倍说到 RAG 应用的质量很多人凭感觉调参今天觉得 top_k 大了明天觉得 chunk 小了最后也不知道哪个改动起了作用。我的习惯是先建一个评估集再做任何改动。评估集不用大20 条问题就够了但要覆盖三种类型可以在知识库中找到明确答案的问题、需要跨多个片段才能回答的问题、知识库中根本没有答案的问题。每条问题标注标准答案和来源页码。评估代码用RAGAS框架最省事它能计算忠实度、答案相关性、上下文精确率三个指标。忠实度是看回答是否忠于检索的上下文有没有额外编造答案相关性是看回答是否对上了问题上下文精确率是看检索回来的片段里有多少真正用上了。我一般跑完一轮得到三个分数然后针对性调参上下文精确率低就调切分和检索忠实度低就调 Prompt 和后处理。from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy, context_precision result evaluate( dataseteval_dataset, # 需要按 ragas 格式准备 metrics[faithfulness, answer_relevancy, context_precision], ) print(result)这套评估跑完之后你会很清楚瓶颈在哪。我遇到过一个小项目忠实度从 0.72 提升到 0.95不是因为换了大模型而是把 Prompt 里「基于片段回答」改成了「只能引用片段原文中的短句回答」。这一步纯粹是 Prompt 工程不花一分钱推理成本。另一个项目上下文精确率一直不高最后发现是切分时把大量相关句子拆到了相邻的 chunk 里检索只取到一个另一个明明就在旁边。解决办法是把chunk_overlap从 80 调到 120精确率立刻涨了 8 个点。面试时你可以把这个评估流程讲成你个人的工作方法「我不会凭感觉说效果好了我会用 RAGAS 跑指标用数据说服自己和同事」。这比背十篇八股文更有效。最后提醒一句RAG 的优化是无底洞不要一开始就追求完美。先把 20 条评估集跑通把指标固定下来再往生产环境加日志、缓存和监控。这半年我最大的教训是不要在没评估的情况下反复横跳调参那纯粹是玄学。希望帮到你。本文还有配套的精品资源点击获取