ARTICLE DETAIL

资讯详情

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

RAG从原型到生产:分块、混合检索与上下文组装的工程实践

RAG从原型到生产:分块、混合检索与上下文组装的工程实践 1. 从“能跑通”到“能上线”RAG最后一公里到底卡在哪做过RAG项目的人大概都有过这种体验Demo阶段效果惊艳老板看完拍板说“就这个方向”结果一上真实数据回答就开始胡言乱语。你调了Embedding模型、换了更大的LLM、加了更多提示词效果还是忽好忽坏。问题往往不在模型本身而是卡在了从“检索到”到“用得好”这最后一公里上。我前后参与过几个企业知识库的RAG落地项目从最初用LangChain搭原型到后来自己写检索管线踩的坑基本都集中在三个地方分块策略决定了检索的召回质量混合检索决定了排序的鲁棒性上下文组装决定了LLM最终能不能用对信息。这三件事听起来都不复杂但每一个都有大量细节可以让你翻车。这篇文章不打算讲RAG的基础概念网上已经够多了。我想聊的是那些文档里不会写、只有真正跑过生产数据才会遇到的问题为什么你的分块在PDF上表现很好到了Markdown就崩了为什么BM25和向量检索的分数融合总是调不好为什么明明检索到了正确段落LLM还是答错了这些问题的答案往往藏在一些很具体的工程细节里。适合读这篇内容的人已经搭过至少一个RAG原型、准备往生产环境推进的开发者正在被检索质量问题困扰、想系统排查的工程师或者单纯想了解RAG工程化到底难在哪里的技术负责人。我会尽量把每个决策背后的“为什么”讲清楚而不是只给一堆参数让你抄。2. 分块策略不是切得越碎越好也不是越大越全2.1 固定长度分块为什么在真实文档上总是翻车刚开始做RAG的时候最直觉的做法就是按固定字符数切块比如每500个字符一块重叠50个字符。这个方法在结构规整的文本上勉强能用但真实文档远比想象中复杂。我遇到过一个典型场景一份产品技术手册里面有大量的参数表格和嵌套列表。按固定长度切分后一个完整的参数表被切成了三块第一块只有表头第二块是中间几行数据第三块是剩余数据和表尾。用户问“XX型号的额定功率是多少”检索系统召回了第二块里面只有一堆数字没有表头说明这些数字对应什么参数。LLM拿到这块内容要么答错要么直接说“无法确定”。这个问题的本质是固定长度分块假设了语义均匀分布但真实文档的语义密度是不均匀的。表格、代码块、列表这些结构化内容的语义完整性依赖于它们作为一个整体存在一旦被切断信息就残缺了。另一个常见问题是段落边界被破坏。中文文档尤其明显很多技术文档一个段落就是好几百字按固定长度切分后一个完整的论述被拦腰截断前半段在块A后半段在块B。检索时如果只召回了块ALLM看到的就是一个没有结论的半截论述。注意如果你的文档里有大量表格、代码块或嵌套列表固定长度分块基本不可用。这不是调参能解决的问题需要换分块逻辑。2.2 递归分块与语义分块的适用边界递归分块是目前比较主流的方案LangChain的RecursiveCharacterTextSplitter就是典型代表。它的思路是按优先级依次尝试分隔符先按段落分段落太长再按句子分句子还太长再按字符分。这样能在保持语义完整性的前提下控制块大小。但递归分块也有它的局限。我实测下来它在结构清晰的Markdown文档上表现很好因为Markdown本身有明确的标题层级和段落分隔。但在PDF转出来的文本上效果就打折扣了因为PDF转文本经常丢失段落结构所有内容挤成一团递归分块的分隔符根本找不到合适的切点。语义分块是另一条路思路是先计算相邻句子的Embedding相似度在相似度骤降的地方切分。这个方法理论上更贴近语义边界但实际用起来有两个问题一是计算成本高需要对每个句子做Embedding二是阈值不好定不同文档的相似度分布差异很大一个文档调好的阈值换到另一个文档就失效了。我的经验是如果文档结构规整Markdown、HTML、Word优先用递归分块配合文档结构信息做增强如果文档结构混乱扫描件OCR结果、PDF转文本语义分块值得一试但要做好调参的心理准备。2.3 按文档结构分块被低估的实用方案聊一个我觉得被低估的方案按文档结构分块。很多文档本身就有明确的层级结构比如技术手册的章节-小节-段落法律合同的条款-子条款学术论文的摘要-引言-方法-实验。这些结构信息本身就是最好的分块依据。具体怎么做以Markdown为例你可以解析出标题层级把每个最小标题单元下的内容作为一个块。如果某个单元太长再在单元内部用递归分块做二次切分。这样切出来的块天然带有层级上下文你可以在块的元数据里记录它所属的章节路径检索时一并返回给LLM。我做过一个对比测试同一份技术文档分别用固定长度、递归分块、结构分块三种方式处理然后用相同的检索问题和LLM做问答。结构分块的准确率比固定长度高了将近30个百分点比递归分块也高了15个点左右。原因很简单结构分块保留了文档本身的语义边界而文档作者在写的时候就是按这个边界来组织信息的。当然结构分块的前提是你能解析出文档结构。Markdown和HTML天然支持Word需要额外解析PDF就比较麻烦了。如果文档结构解析成本太高退而求其次用递归分块也是可以接受的。2.4 分块粒度与检索粒度的解耦设计这里有一个容易被忽略的设计点分块粒度和检索粒度不一定要一致。什么意思你切块的时候可能切得比较小比如每个块只有200字这样检索时匹配更精准。但LLM回答问题可能需要更大的上下文200字不够。这时候你可以在检索到小块后自动扩展召回它周围的块或者召回它所属的父块。这个思路叫“小检索、大上下文”。具体实现方式有几种一种是建立块之间的父子关系检索到子块后返回父块另一种是检索到小块后按位置索引向前后各扩展N个块还有一种是维护一个滑动窗口检索命中后返回窗口内的所有块。我比较推荐父子块方案因为它更可控。你可以在入库时就把父子关系建好检索时直接按关系取数据不需要运行时计算。LangChain的ParentDocumentRetriever就是干这个的不过它的默认实现比较简单实际用的时候可能需要自己扩展。粒度选择上我的经验值是中文技术文档子块200-400字父块800-1500字。子块用于检索匹配父块用于LLM上下文。这个范围不是绝对的需要根据你的文档特点和LLM的上下文窗口来调整。如果LLM上下文窗口很大比如32K以上父块可以适当放大如果窗口有限就要控制父块大小避免挤占其他检索结果的上下文空间。3. 混合检索BM25和向量检索到底怎么配合3.1 为什么纯向量检索在企业知识库场景下不够用向量检索的优势是语义匹配用户问“如何申请年假”它能召回“休假流程说明”这种字面不重叠但语义相关的文档。这个能力在开放域问答里很有价值但在企业知识库场景下纯向量检索有几个硬伤。第一个硬伤是专有名词和型号匹配。企业文档里大量出现产品型号、内部系统名称、项目代号这类专有名词。用户问“X200设备的固件升级步骤”向量检索可能会召回“设备维护指南”这种语义相关但完全不是用户要的文档因为Embedding模型在训练时没见过“X200”这个型号它只能根据“设备”“升级”这些通用词做匹配。第二个硬伤是精确匹配需求。有些查询就是需要精确匹配比如用户输入一个错误码“ERR-4032”他就是要找这个错误码对应的解决方案。向量检索可能会召回一堆语义相关的错误处理文档但就是漏掉了那个精确包含“ERR-4032”的文档。第三个硬伤是短查询效果差。用户输入“年假”两个字向量检索很难从这两个字里提取足够的语义信息来做准确匹配。而BM25这种基于词频的检索方法对短查询反而更鲁棒。所以混合检索不是锦上添花而是企业知识库场景下的刚需。BM25负责精确匹配和专有名词召回向量检索负责语义泛化两者互补。3.2 BM25在中文场景下的分词陷阱BM25本身是个很成熟的算法但在中文场景下它的效果高度依赖分词质量。我见过不少项目直接用jieba默认分词就上了结果BM25的召回质量惨不忍睹。问题出在专业术语的分词上。比如“知识图谱”这个词jieba默认会切成“知识”和“图谱”两个词。用户搜索“知识图谱构建方法”时BM25会分别匹配“知识”和“图谱”可能召回一堆包含“知识管理”和“图像图谱”的无关文档。而如果“知识图谱”作为一个整体被分词匹配精度会高很多。解决办法是维护一个自定义词典把领域内的专有名词、产品型号、技术术语都加进去。这个工作看起来笨但效果立竿见影。我一般会从几个来源收集词典一是文档中出现频率高但分词效果差的词二是业务方提供的术语表三是通过新词发现算法自动挖掘。另一个坑是停用词处理。中文里“的”“了”“是”这些停用词在BM25里应该被过滤掉但有些停用词在特定场景下是有意义的。比如“非”字在“非正常流程”里它是关键语义过滤掉就变成“正常流程”了意思完全相反。所以停用词表也需要根据领域特点来定制不能直接用通用停用词表。提示BM25的分词质量对最终效果影响极大建议在项目初期就投入时间做词典建设和分词调优。这部分工作看起来不性感但回报很高。3.3 分数融合RRF不是万能药混合检索的核心问题是怎么把BM25的分数和向量检索的分数融合成一个排序。常见的方法有几种融合方法原理优点缺点加权求和对两路分数加权后相加实现简单需要归一化权重难调RRF按排名倒数融合无需归一化鲁棒丢失分数信息交叉编码器重排用模型对候选集重排精度高计算成本高RRFReciprocal Rank Fusion是很多人推荐的方案因为它不需要归一化直接按排名融合看起来很美。但我实际用下来RRF在候选集质量差异大的时候会出问题。举个例子BM25召回了10个结果其中第1名是真正相关的文档但第2到第10名都是勉强沾边的。向量检索也召回了10个结果第1名不相关但第2到第5名都是相关的。RRF融合后BM25的第1名和向量检索的第1名权重相同但向量检索的第1名其实不相关这就拉低了整体排序质量。我的做法是先用RRF做粗排取Top 50左右然后用一个轻量级的交叉编码器做精排。交叉编码器不需要太大一个小模型比如6层Transformer就能显著提升排序质量。如果计算资源有限至少也要用一个基于规则的重排比如对同时被两路检索命中的文档加权。3.4 三路混合检索的工程实现与性能权衡有些场景下两路混合还不够。比如你有一个知识图谱里面存了实体关系这时候可以加第三路基于图谱的检索。用户问“A产品和B产品有什么关系”图谱检索可以直接返回两个实体之间的路径这是BM25和向量检索都做不到的。三路混合的工程实现比两路复杂不少。首先是延迟问题三路检索并行执行的话总延迟取决于最慢的那一路。如果图谱查询涉及多跳遍历延迟可能会很高。我的做法是给每路检索设超时超时的路直接返回空结果不阻塞整体流程。其次是融合策略。三路融合用RRF也可以但权重需要调整。一般来说向量检索和BM25的权重可以设为1:1图谱检索的权重设低一些比如0.5因为图谱检索的召回率通常较低但精度高。如果图谱检索命中了那结果通常很相关所以可以在融合后给图谱命中的文档额外加权。性能方面我实测下来在10万级文档规模下两路混合检索的P99延迟可以控制在200ms以内三路混合大概在300-400ms。如果再加重排模型延迟会增加到500ms以上。这个延迟对于在线问答场景是可以接受的但如果要做实时交互就需要考虑缓存或者异步处理了。4. 上下文组装检索对了LLM为什么还是答错4.1 上下文窗口不是越大越好很多人觉得LLM上下文窗口越大越好恨不得把检索到的所有内容都塞进去。但实际上上下文窗口越大LLM的注意力越容易分散。我做过一个测试同一个问题分别给LLM喂3个相关段落和10个相关段落其中7个是弱相关前者的回答准确率明显高于后者。原因在于当上下文里混入大量弱相关信息时LLM会被干扰可能会把弱相关信息当成主要依据来回答。这就是所谓的“迷失在中间”现象LLM对上下文开头和结尾的信息注意力较高对中间部分的信息注意力较低。如果你把最相关的文档放在中间LLM反而可能忽略它。所以上下文组装的核心原则是精准优先宁缺毋滥。检索阶段可以多召回一些候选但组装上下文时要严格筛选只保留最相关的几个段落。具体保留几个取决于段落长度和LLM窗口大小一般3-5个段落是比较安全的范围。4.2 上下文排序把最重要的放在开头和结尾基于“迷失在中间”现象上下文排序就很重要了。我的做法是把最相关的段落放在最前面次相关的放在最后面中间放一些补充信息。具体实现上检索结果本身就有排序分数你可以按分数排序后把Top 1放在开头Top 2放在结尾Top 3到Top 5放在中间。如果段落数量更多可以按“高-低-中”的模式交替排列。另一个技巧是给每个段落加上来源标注。比如在段落前面加上“来自《XX文档》第X节”这样LLM在回答时可以参考来源信息也方便用户追溯。这个标注不需要太长一句话就够了但效果很明显。4.3 上下文压缩用LLM自己来筛选关键信息如果检索到的段落比较多但又不想直接丢弃可以用上下文压缩的方式。思路是让LLM自己从每个段落里提取与问题相关的关键句然后把提取出来的关键句组装成上下文。这个方法的好处是能大幅压缩上下文长度同时保留关键信息。坏处是增加了一次LLM调用延迟会上升。而且如果LLM提取关键句时出错可能会丢失重要信息。我的折中方案是对Top 3段落不做压缩直接使用原文对Top 4到Top 10段落做压缩只保留关键句。这样既保证了核心信息的完整性又控制了上下文总长度。压缩的实现可以用一个小模型来做不一定非要用大模型。比如用一个7B参数的模型做关键句提取效果已经够用了而且速度快很多。4.4 多轮对话中的上下文管理多轮对话场景下上下文管理更复杂。你不仅要管理检索到的文档上下文还要管理对话历史上下文。两者加起来很容易超出LLM窗口。我的做法是分层管理对话历史只保留最近3轮更早的对话做摘要压缩。摘要可以用LLM生成也可以用规则提取关键实体和意图。检索文档上下文则按当前轮的问题重新检索不复用上一轮的检索结果因为用户的问题可能已经变了。另一个坑是指代消解。用户第二轮问“它的参数是多少”这个“它”指的是上一轮提到的某个产品。如果直接把这个问题拿去检索检索系统不知道“它”是什么召回质量会很差。所以需要在检索前做指代消解把“它”替换成具体的产品名称。这个工作可以用LLM来做也可以用规则实体识别来做。注意多轮对话的上下文管理是RAG系统里最容易出问题的地方。建议在项目初期就把多轮场景考虑进去不要等到单轮跑通了再补否则架构可能要大改。5. 踩坑复盘那些让我熬夜排查的典型问题5.1 检索到了正确文档但LLM说“没有相关信息”这个问题我遇到过好几次每次原因都不一样。第一次是因为上下文里包含了太多无关内容LLM被干扰了没注意到真正相关的段落。解决办法是减少上下文里的段落数量只保留最相关的3个。第二次是因为段落被截断了。检索到的段落是完整的但在组装上下文时因为总长度超限被截断了一部分恰好把关键信息截掉了。解决办法是组装上下文时按段落粒度截断不要在一个段落中间截断。第三次是因为LLM的提示词有问题。提示词里写了“如果上下文中没有相关信息请回答不知道”LLM过于保守明明有相关信息也回答不知道。解决办法是调整提示词改成“请基于上下文回答如果上下文信息不足请说明哪些信息缺失”。5.2 BM25分数异常高但结果不相关BM25有一个经典问题长文档天然占优势。因为BM25的分数计算里有一个文档长度归一化因子但归一化不完美导致长文档的分数容易偏高。我遇到过一个案例一个很长的FAQ文档里面包含了几乎所有常见问题的关键词所以不管用户问什么这个文档都能被BM25召回而且分数很高。但实际上它只是泛泛地提到了这些关键词并没有给出具体答案。解决办法有几个一是对文档长度做更激进的惩罚二是用BM25BM25的改进版三是把长文档切分成更小的块。我一般推荐第三种因为切块后每个块的语义更集中BM25的匹配精度会高很多。5.3 向量检索的“语义漂移”问题向量检索有时候会召回一些“看起来语义相关但实际不相关”的文档。比如用户问“如何重置密码”向量检索召回了“如何修改密码”这两个在语义空间里很接近但操作步骤完全不同。这个问题本质上是Embedding模型的局限。通用Embedding模型很难区分这种细粒度的语义差异。解决办法有几种一是用领域数据微调Embedding模型二是用交叉编码器做精排三是在检索后加一层规则过滤。我比较推荐微调Embedding模型虽然成本高一些但效果提升最明显。微调数据可以用历史问答对也可以用LLM自动生成。一般几千条数据就能看到明显效果。5.4 上下文组装时的“信息冲突”处理当检索到的多个段落包含冲突信息时LLM会怎么处理答案是不确定。有时候它会选择其中一个有时候它会试图调和矛盾有时候它会直接说“信息不一致”。这种情况在文档版本更新后特别常见旧版文档和新版文档同时被检索到里面的参数或流程不一样。解决办法是在入库时给每个块打上版本标签和时间戳检索时优先召回最新版本的块。如果必须保留旧版本就在上下文里明确标注版本信息让LLM知道哪个是最新的。另一个做法是在检索阶段就做去重和冲突检测。如果两个块来自同一文档的不同版本只保留最新版本。这个逻辑可以在检索后处理阶段实现不需要改动检索本身。6. 从原型到生产一些实用的工程建议6.1 建立检索质量的评估体系没有评估体系调优就是盲人摸象。我建议在项目初期就建立一套评估集包含至少100个真实用户问题每个问题标注好正确答案和对应的文档段落。然后定义几个核心指标召回率相关文档是否被召回、准确率召回文档中有多少是相关的、MRR相关文档的平均排名。有了评估集每次调整分块策略、检索参数或融合算法后都可以快速验证效果。我一般会维护一个A/B测试框架新方案先在评估集上跑一遍指标有提升再上线上灰度。6.2 日志与可观测性建设RAG系统的调试难度很大因为中间环节多出了问题很难定位是哪个环节的锅。所以日志和可观测性建设很重要。我一般会记录这些信息用户原始查询、改写后的查询、每路检索的召回结果和分数、融合后的排序、最终组装的上下文、LLM的原始输出。这些信息串起来就能完整复现一次问答的全过程。出问题时直接看日志就能定位到是检索没召回、还是排序错了、还是LLM理解错了。如果条件允许可以做一个可视化的调试面板把这些信息展示出来。我们团队内部做过一个简单的Web界面输入查询后能看到整个管线的执行过程排查效率提升了很多。6.3 渐进式优化先跑通再调优最后说一个心态上的建议不要试图一次性把所有环节都调到最优。RAG系统涉及分块、检索、融合、重排、上下文组装、LLM生成等多个环节每个环节都有优化空间但你不可能同时优化所有环节。我的做法是先用最简方案跑通全流程确保端到端能工作。然后建立评估体系找到当前最大的瓶颈环节集中优化那一个环节。优化完后再评估找下一个瓶颈。这样迭代几轮效果就能达到可用水平。具体到优化顺序我一般建议先优化分块策略影响最大再优化检索融合影响次之然后优化上下文组装影响再次最后考虑微调模型成本最高。这个顺序不是绝对的但大体上遵循“先工程后模型”的原则因为工程优化的性价比通常更高。在实际项目中我见过太多团队一上来就想着微调模型结果花了几周时间调模型效果提升还不如把分块策略改一下。所以建议先把工程层面的东西做扎实再考虑模型层面的优化。
返回列表