
1. 从“能跑通”到“能落地”检索与文档在LangChain里的真实位置很多人学LangChain前面玩PromptTemplate、玩LLMChain、玩Memory都挺顺一到检索与文档这块就卡住了。原因不复杂前面那些东西是“无状态”的你给什么它答什么而检索与文档是“有状态”的它要求你先把手头的资料变成机器能理解、能定位、能召回的结构化形态再让模型基于这些召回结果去回答。这一步跨过去RAG检索增强生成才算真正入门跨不过去就只能停留在“把整篇文档塞进上下文”的玩具阶段。我写这篇的出发点很直接LangChain里检索与文档这条链路看起来只是几个类的组合——Document、TextSplitter、Embeddings、VectorStore、Retriever——但真正落到项目里坑几乎全在“组合方式”和“参数取舍”上。比如同样是切分RecursiveCharacterTextSplitter和TokenTextSplitter在中文场景下效果能差出一大截同样是向量化选OpenAI的embedding还是本地模型直接决定你的成本和召回质量同样是检索相似度检索和MMR检索在“答案多样性”上的表现完全不是一个量级。这篇文章适合三类人看第一类是把LangChain当工具用、想快速搭一个能查自己文档的问答系统的开发者第二类是被“切分粒度”“召回不准”“上下文超长”这些问题折磨过、想搞清楚底层逻辑的工程师第三类是准备把RAG从Demo推向生产、需要认真做技术选型的团队。我会把这条链路拆成“文档加载与切分”“向量化与存储”“检索策略”“上下文拼装与问答”几个真实环节每个环节都讲清楚为什么这么做、参数怎么定、实测中会遇到什么。提示本文讨论的所有内容都基于公开的LangChain框架能力不涉及任何特定网络环境或特殊访问方式所有模型和向量库均可根据自身条件选择本地或云端方案。2. Document与TextSplitter切分粒度决定了检索的天花板2.1 Document对象不只是“文本元数据”那么简单LangChain里的Document对象结构很朴素page_content存正文metadata存来源、页码、标题等附加信息。很多人第一次用的时候觉得这玩意儿没啥用直接把字符串丢给模型不就行了问题在于一旦你的资料超过几千字模型上下文就装不下而且就算装得下模型对长文本中间部分的注意力也会衰减——这就是所谓的“lost in the middle”现象。Document的存在是为了让每一段文本都能带着“我是谁、我从哪来”的信息独立流转。metadata的价值在检索阶段才会真正爆发。举个例子你有一批产品手册按章节切分后每个Document的metadata里带上chapter和section字段。当用户问“保修政策是什么”时检索命中某个片段你可以直接把chapter: 售后服务这个信息一起返回给模型模型在组织答案时就能说“根据售后服务章节的说明……”而不是干巴巴地复述一段文字。这种“可追溯性”在客服、法务、医疗等场景里是刚需。加载环节我常用的几个LoaderTextLoader处理纯文本PyPDFLoader处理PDF注意它按页返回Document扫描件需要先做OCRUnstructuredMarkdownLoader处理MarkdownDirectoryLoader批量加载整个目录。这里有个容易忽略的点DirectoryLoader默认只加载顶层文件要递归子目录得显式设置recursiveTrue和glob**/*.md这类模式。我见过有人把文档按文件夹分好类结果加载出来只有根目录那几个文件排查半天才发现是glob没配对。2.2 为什么RecursiveCharacterTextSplitter是中文场景的默认首选切分器选型这件事我的经验是除非你有非常明确的token预算约束否则优先用RecursiveCharacterTextSplitter。它的逻辑是“按优先级依次尝试分隔符”——先按段落分\n\n段落还太大就按换行分\n再大就按句号、问号、感叹号分最后才按字符硬切。这个“递归下降”的策略保证了切出来的片段尽可能在语义边界上断开而不是把一句话拦腰截断。中文场景下默认的分隔符列表需要调整。LangChain默认的分隔符是[\n\n, \n, , ]对中文来说空格分隔意义不大应该改成[\n\n, \n, 。, , , , , ]。这样切分器会优先在句号、问号这些中文标点处断开语义完整性明显更好。我实测过同一批技术文档用默认分隔符切出来的片段经常出现“半句话开头”的情况改成中文标点后片段开头的可读性提升非常明显。chunk_size和chunk_overlap这两个参数是重灾区。chunk_size不是越小越好也不是越大越好。太小比如100字符一个完整论点被切碎检索时召回的是碎片模型拼不出完整答案太大比如2000字符一个片段里混了好几个主题向量表示被“平均”掉检索精度下降。我的经验值是技术文档、说明书这类信息密度高的内容chunk_size设在500到800之间比较稳小说、新闻这类叙述性内容可以放到1000左右。chunk_overlap一般设成chunk_size的10%到20%目的是让相邻片段在边界处有重叠避免一个关键句子正好被切在边界上导致两边都召回不到。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size600, chunk_overlap100, separators[\n\n, \n, 。, , , , , ], length_functionlen, ) chunks splitter.split_documents(documents)2.3 TokenTextSplitter与MarkdownHeaderTextSplitter的适用边界TokenTextSplitter是按token数切分的适合你对模型上下文有精确预算的场景。比如你用的模型上下文窗口是4096个token那切分时就得保证单个片段加问题加回答不超过这个数。但它的缺点是不管语义边界纯粹按token数硬切中文里一个汉字可能对应一到两个token切出来的片段可读性不如递归切分器。我的做法是先用递归切分器按语义切再用token计数器校验超预算的片段再单独处理。MarkdownHeaderTextSplitter是另一个极端它按Markdown的标题层级切分把#、##、###这些标题作为切分点并且把标题内容写进metadata。这个切分器特别适合结构化的技术文档、Wiki、说明书。它的好处是每个片段天然带有“章节路径”检索命中后你能立刻知道这段内容属于哪个大章节。但它不适合没有标题层级的纯文本也不适合标题层级混乱的文档——如果一篇文档里#和###混用且没有规律切出来的片段大小会非常不均匀。我通常的组合策略是先用MarkdownHeaderTextSplitter按标题切出大块再对每个大块用RecursiveCharacterTextSplitter做二次切分。这样既保留了章节结构信息又控制了单个片段的大小。这个组合在处理公司内部Wiki时特别好用检索命中后返回的metadata里直接带着h1: 运维手册、h2: 数据库备份这样的路径模型组织答案时能引用得很自然。3. Embeddings与向量存储把文本变成可检索的数学对象3.1 Embedding模型选型不是越贵越好而是越匹配越好Embedding的本质是把一段文本映射成一个高维向量语义相近的文本在向量空间里的距离就近。选Embedding模型时很多人第一反应是“用OpenAI的text-embedding-ada-002”它确实稳但有两个问题一是成本大批量文档做一次全量向量化费用不低二是数据出境如果你的文档涉及内部资料把内容发到外部接口这件事本身就需要评估。本地可选的方案里BAAI/bge-large-zh系列在中文语义相似度任务上表现很扎实m3e-base也是常见选择。选本地模型的好处是数据不出内网、没有按量计费的压力代价是需要一块像样的GPU或者接受CPU推理的慢速。我的建议是如果文档量在几千到几万片段这个量级用本地模型做一次全量向量化之后增量更新完全可行如果文档量在百万级以上或者对延迟极其敏感再考虑云端方案。这里有个容易被忽略的点Embedding模型和LLM不一定是同一家。你完全可以用本地的bge做向量化用云端的GPT做生成两者通过向量库解耦。LangChain的Embeddings接口是统一的换模型只需要换一个类实例上层检索逻辑不用动。这种解耦设计在技术选型时给了很大灵活性。from langchain.embeddings import HuggingFaceBgeEmbeddings embedding HuggingFaceBgeEmbeddings( model_nameBAAI/bge-large-zh-v1.5, model_kwargs{device: cuda}, encode_kwargs{normalize_embeddings: True}, )normalize_embeddingsTrue这个参数值得单独说。它把向量归一化到单位长度这样计算余弦相似度时只需要做点积速度更快而且不同长度的文本向量在比较时更公平。bge系列官方推荐开启这个选项实测召回稳定性确实更好。3.2 向量库选型FAISS、Chroma、Milvus各自的主场向量库这块LangChain集成了几十种但常用的就那么几个。FAISS是Facebook开源的库特点是轻量、快、纯内存也可以持久化到磁盘适合单机、中小规模、快速验证。Chroma是LangChain生态里集成度最高的API简单支持持久化适合快速搭原型。Milvus、Qdrant、Weaviate这些是独立部署的向量数据库适合生产环境、大规模、需要高可用和水平扩展的场景。我的选型逻辑很简单验证阶段用FAISS或Chroma生产阶段看数据量和并发。FAISS的坑在于它是库不是服务多进程并发写会有问题而且没有内置的权限控制Chroma早期版本在持久化上有过一些bug现在稳定多了但大规模下的性能不如专业向量数据库。如果你只是做一个内部工具、文档量在十万片段以内FAISS完全够用而且部署最简单——一个FAISS.from_documents()就建好了索引save_local()存盘load_local()加载没有额外服务要维护。from langchain.vectorstores import FAISS vectorstore FAISS.from_documents(chunks, embedding) vectorstore.save_local(faiss_index) # 加载时注意allow_dangerous_deserialization参数 loaded FAISS.load_local(faiss_index, embedding, allow_dangerous_deserializationTrue)注意allow_dangerous_deserializationTrue这个参数在新版LangChain里是必须显式设置的因为它涉及pickle反序列化存在安全风险。只加载你自己生成的索引文件不要加载来源不明的索引。3.3 向量化的成本与增量更新策略全量向量化一次的成本取决于文档量和模型。用本地模型成本主要是电费和GPU折旧用云端接口按token计费几万页文档跑下来也是一笔钱。所以增量更新策略很重要文档库不是一成不变的今天加一篇明天改一段如果每次都全量重跑浪费太大。我的做法是给每个Document的metadata里加一个doc_id和updated_at字段向量库支持按metadata过滤删除。更新时先根据doc_id删掉旧片段再插入新片段。FAISS本身不直接支持按metadata删除但可以通过delete方法按ID删所以建索引时要保留ID映射。Chroma和Milvus对metadata过滤的支持更好直接where{doc_id: xxx}就能定位。另一个实践细节是向量化要批量做不要一条一条来。LangChain的embed_documents方法接受列表底层会做批处理。我见过有人写循环一条条embed_query速度慢十倍不止。批量大小根据显存和接口限制调整本地模型一般32到64一批云端接口看它的batch限制。4. Retriever检索策略相似度、MMR与多路召回的真实差异4.1 相似度检索的“过度集中”问题最基础的检索是相似度检索把用户问题向量化在向量库里找距离最近的k个片段。similarity_search(query, k4)就是干这个的。它的问题在于“过度集中”——如果文档里有好几段内容都在讲同一个概念相似度检索会把它们全召回导致上下文里全是重复信息而其他相关但表述不同的片段被挤掉。举个例子你问“如何配置数据库连接池”文档里有三段都在讲连接池参数相似度检索可能把这三段全召回但真正讲“配置步骤”的那段因为用词不同比如用的是“设置”而不是“配置”反而没被召回。结果模型看到的全是参数说明没有操作步骤答案自然不完整。4.2 MMR检索用“多样性”换“覆盖面”MMRMaximal Marginal Relevance检索就是为了解决这个问题。它的逻辑是先按相似度选一个最相关的片段然后选下一个时既考虑它和问题的相关性也考虑它和已选片段的差异性用一个lambda_mult参数控制两者的权重。lambda_mult1退化成纯相似度检索lambda_mult0变成纯多样性检索一般设在0.5到0.7之间。retriever vectorstore.as_retriever( search_typemmr, search_kwargs{k: 6, fetch_k: 20, lambda_mult: 0.6}, )fetch_k是先取20个候选再从里面挑6个。这个参数设大一点MMR才有挑选空间。实测下来MMR在“答案需要综合多个片段”的场景下优势明显比如“对比A方案和B方案的优缺点”这种问题相似度检索容易只召回A或只召回BMMR能兼顾两边。代价是MMR计算量更大延迟略高但在文档量不大的情况下感知不明显。4.3 多路召回与重排序把“召回率”和“精确率”分开优化单一检索策略很难同时保证高召回和高精确。工程上的常见做法是“多路召回重排序”用不同策略相似度、MMR、关键词BM25各召回一批合并去重后再用一个重排序模型Reranker对候选做精排取Top-N给LLM。LangChain里可以用EnsembleRetriever把多个Retriever组合起来给每个分配权重。BM25检索需要rank_bm25库它对关键词匹配特别敏感能补上向量检索在“专有名词”“代码符号”上的短板。比如用户问“FAISS.load_local的allow_dangerous_deserialization参数”向量检索可能召回一堆泛泛讲FAISS的内容BM25能精准命中包含这个参数名的片段。重排序模型我常用BAAI/bge-reranker-base它输入“问题片段”对输出相关性分数比向量相似度更准。流程是向量检索召回20个BM25召回20个合并去重后大概30个Reranker精排出前5个给LLM。这套组合拳下来召回质量和答案准确率比单路检索高一个档次。代价是链路变长、延迟增加适合对答案质量要求高的场景。检索策略优势劣势适用场景相似度检索实现简单、速度快结果集中、多样性差问题指向明确、文档主题分散MMR检索覆盖面广、避免重复计算量略大、可能引入弱相关需要综合多片段回答BM25关键词精准、专有名词友好不懂语义、同义词失效代码、术语、编号查询多路重排序召回和精确兼顾链路长、延迟高生产环境、质量优先5. 上下文拼装与问答链检索结果怎么“喂”给模型5.1 Stuff、MapReduce、Refine三种拼装策略的取舍检索出片段后怎么和问题一起送给LLMLangChain给了几种模式。Stuff是最简单的把所有片段拼成一个长字符串加上问题一次性发给模型。它的优点是只调一次模型、速度快、上下文完整缺点是片段总长度不能超过模型上下文窗口。对于大多数RAG场景Stuff是首选因为检索阶段已经控制了片段数量和大小通常不会超。MapReduce是先把每个片段单独和问题发给模型让模型对每个片段生成一个“局部答案”再把所有局部答案合并成一个最终答案。它适合片段很多、单个片段很长的情况但缺点是调用次数多、成本高而且局部答案合并时可能丢失细节。Refine是串行的先拿第一个片段生成初始答案再拿第二个片段去“精炼”这个答案依次迭代。它的优点是答案质量可能更高缺点是串行调用慢而且后面的片段可能覆盖前面的正确信息。我的经验是能用Stuff就用Stuff。如果片段总长度超了优先考虑减少召回数量或缩小chunk_size而不是换MapReduce。只有在文档量极大、必须召回很多片段时才考虑MapReduce或Refine。而且Refine在中文场景下有个坑迭代精炼时模型可能把前面已经答对的内容改错尤其是当后续片段包含矛盾信息时。5.2 Prompt模板里的“防幻觉”设计检索增强生成的核心价值是“让模型基于给定资料回答”但模型并不总是听话。它可能忽略检索到的片段用自己的预训练知识回答也可能在片段里没有答案时强行编造。Prompt模板的设计要针对这两点做约束。我常用的模板结构是这样的先给系统指令明确“只根据以下资料回答资料中没有的信息不要编造如果资料不足以回答就直说”然后给资料每个片段前面带上来源标记最后给问题。关键细节是资料片段要用明确的分隔符隔开比如---或者[片段1]避免模型把不同片段的内容混在一起。另外在资料末尾再强调一次“以上是全部资料”防止模型把问题和资料混淆。from langchain.prompts import PromptTemplate template 你是一个严谨的文档问答助手。请只根据下面提供的资料回答问题。 如果资料中没有相关信息请直接说“根据现有资料无法回答”不要编造。 资料 {context} 问题{question} 回答要求 1. 只使用资料中的信息 2. 如果引用了具体内容注明来自哪个片段 3. 资料不足时明确说明 回答 prompt PromptTemplate(templatetemplate, input_variables[context, question])5.3 返回来源让答案可追溯的工程实现生产环境里用户不只要答案还要知道答案从哪来。LangChain的Retriever返回的是Document列表每个Document带着metadata。在拼装上下文时可以把metadata里的来源信息文件名、页码、章节一起格式化进context同时在返回结果里单独带一份来源列表。具体做法是在构建context字符串时给每个片段加上[来源{source}第{page}页]这样的前缀在链的返回值里除了answer再返回一个source_documents列表。前端展示时答案下面列出引用来源用户点击可以跳转到原文。这个功能在内部知识库场景里特别重要——用户看到答案后往往需要去原文确认细节没有来源追溯信任度会打折扣。这里有个细节metadata里的来源路径要处理成用户可读的形式。如果直接返回/data/docs/internal/2024/manual_v3.pdf用户看不懂。可以在加载阶段就把路径映射成“产品手册2024版”这样的显示名存进metadata的display_source字段。检索返回时用显示名既保留了追溯能力又不暴露内部目录结构。6. 实测中踩过的坑与参数调优经验6.1 中文切分的标点陷阱与overlap的实际效果中文标点切分有个坑省略号……和破折号——在默认分隔符列表里没有导致包含这些标点的长句不会被切开。我遇到过一篇文档里大量使用破折号做插入语结果切出来的片段特别长就是因为切分器找不到分隔点。解决办法是在separators列表里补上这些标点或者干脆在预处理阶段把特殊标点替换成句号。chunk_overlap的实际效果也值得说。理论上overlap是为了防止关键句被切断但overlap设太大比如chunk_size的50%会导致大量重复内容进入向量库检索时召回一堆高度相似的片段浪费上下文窗口。我实测下来10%到15%的overlap在大多数场景下够用。如果发现检索结果里经常出现“半句话”再适当调大如果发现召回片段大量重复就调小。6.2 向量维度与检索速度的权衡Embedding模型的维度从384到1536不等。维度越高向量能表达的语义越丰富但存储和计算成本也越高。bge-large-zh是1024维bge-small-zh是512维。在文档量不大的情况下两者召回质量差距不明显但small版本的检索速度快一倍以上。我的建议是先用small版本跑通全流程如果发现召回质量不达标再换large版本对比。不要一上来就上最高维度除非你有明确的证据表明低维度不够用。FAISS的索引类型也影响速度。默认的IndexFlatL2是暴力检索精度最高但速度随数据量线性下降。数据量超过十万片段后可以考虑IndexIVFFlat它先聚类再检索速度快很多代价是可能漏掉一些结果。LangChain的FAISS封装默认用Flat要换索引类型需要手动操作底层。对于大多数内部工具十万片段以内Flat完全够用不用过早优化。6.3 检索数量k值的动态调整思路k值设多少没有标准答案。设太小k2可能漏掉关键信息设太大k10上下文里混入无关内容模型容易被干扰。我的做法是根据问题类型动态调整。事实型问题“X是什么”k3到4够用综合型问题“对比X和Y”k6到8探索型问题“关于X有哪些注意事项”k8到10。实现上可以做一个简单的意图分类或者让用户在前端选择“快速回答”和“深度回答”两种模式对应不同的k值。另一个技巧是先召回较多片段比如k10然后用Reranker精排只取前3到4个给LLM。这样既保证了召回率又控制了进入上下文的片段质量。6.4 文档更新后的索引一致性维护文档更新后如果只加新片段不删旧片段会出现“新旧版本同时被召回”的问题。用户问一个已经修改过的政策模型可能同时看到旧版和新版内容给出矛盾答案。所以更新流程必须是“先删后加”根据doc_id删除该文档的所有旧片段再插入新片段。FAISS的删除操作是vectorstore.delete(ids)需要你事先保存每个片段的ID。Chroma和Milvus支持按metadata过滤删除更方便。无论用哪种都要保证doc_id在切分时写进每个片段的metadata否则删除时无法定位。这个细节在Demo阶段容易被忽略到了生产环境文档频繁更新时就会变成大问题。7. 从检索到问答的完整链路复盘把上面这些环节串起来一个可用的RAG链路是这样的加载文档时保留来源和章节信息用递归切分器按中文标点切分并保留overlap用本地bge模型批量向量化后存入FAISS检索时用MMR保证多样性、用BM25补关键词、用Reranker精排拼装上下文时带上来源标记并用防幻觉Prompt约束模型返回答案时附带来源列表。这条链路里每个环节都有调优空间但不要一次性全上。我的建议是分阶段推进第一阶段用最简配置递归切分相似度检索Stuff跑通确认基本可用第二阶段加MMR和来源追溯解决答案不完整和不可追溯的问题第三阶段加BM25和Reranker提升召回质量第四阶段做增量更新和索引维护支撑长期运行。每加一个环节都要有明确的指标对比——召回率、答案准确率、延迟——否则就是在盲目堆复杂度。我在实际项目里最深的一个体会是检索质量的上限在切分阶段就决定了。切分没做好后面用再好的Embedding、再复杂的检索策略都是在垃圾里淘金。所以如果你刚开始做把最多的时间花在切分策略的调试上——拿一批真实问题看召回片段是否完整、是否切在语义边界上、metadata是否够用。这一步做扎实了后面的路会顺很多。