资讯详情 RAG文档切分优化:从LangChain到LangGraph的工程实践
📅 2026/10/23 5:44:42
RAG 系统上线到第三个月最容易被拎出来背锅的环节往往不是向量模型也不是生成模型而是文档切分。我经手过几个检索问答类项目排查到最后发现答案其实就在库里只是被切成了两半一半进了索引另一半躺在隔壁块里谁也没法单独回答用户的问题。这篇内容聊的就是这件事RAG 系统里的文档切分策略到底怎么优化为什么一开始用 LangChain 现成的切分器够用后来又在什么场景下必须换成 LangGraph 这种带状态的图编排方式来兜底。里面会讲清楚切分参数怎么推导、元数据怎么设计、切分质量怎么量化评估以及我从 LangChain 单向链路迁到 LangGraph 之后踩过的那些坑。不管你刚开始搭第一个 RAG Demo还是已经在维护一个有几千份文档的生产库应该都能从里面找到能直接抄的部分。1. 重新审视文档切分它是 RAG 链路里最不该糊弄的一环1.1 切分做不好后面所有环节都在给前面擦屁股很多人搭 RAG 的顺序是先选向量库再挑嵌入模型最后随手写个chunk_size1000, chunk_overlap200把文档切了导进去。这个顺序本身就把切分放到了最不重要的位置。但实际排查问题时你会发现检索效果差的案例里相当一部分根因在切分层要么块太大一篇文章的前言、正文、附录全塞进一个 1000 字的块里向量被稀释成一个什么都沾一点的模糊表示召回了但答不准要么块太小一个完整的因果句被拦腰截断前半句由于接口做了幂等处理进了一个块后半句所以重复提交不会产生脏数据进了下一个块检索时只命中前半个生成模型看到的就是一句没头没尾的话。这里有个很直观的类比把切分想象成把一本厚书拆成可检索的卡片。卡片太大你抽到一张就是十几页翻半天找不到答案卡片太小每张只有半句话你得抽五六张拼起来才勉强读懂。真正好用的卡片是那种脱离上下文也能自解释的粒度——一张卡片讲完一个完整意思附带它出自哪一章哪一节。从工程角度看切分策略直接决定了三件事检索单元的信息密度、上下文窗口的预算分配、答案可追溯性。第一件影响召回质量第二件影响能塞几个块进 prompt第三件影响你能不能给用户标出引用来源。这三件事没理顺换再贵的嵌入模型也是白搭。1.2 切分策略的三代演化路径我大致把见过的切分方案分成三代每一代都是为了解决上一代的明显缺陷。第一代是固定窗口切分。按字符数或 token 数硬切简单粗暴一行代码搞定。问题在于它完全不理解文本结构段落、标题、表格、代码块在它眼里都是一串字符切到哪算哪。这种方案只适合内容高度均质的语料比如日志或者规格统一的短文案。第二代是递归分隔符切分也就是 LangChain 里RecursiveCharacterTextSplitter那套思路。它维护一个分隔符优先级列表先按双换行切段落段落还超长就按单换行再超长就按句号、分号、空格逐级降级。这个思路解决的是尽量在语义边界断开的问题实用性强我到现在也认为它是绝大多数场景的合理基线。但它依然是无状态、单向、一次性的切完就完了切得好不好没人检查。第三代是结构感知加语义校验的切分。它先解析文档的结构树标题层级、表格区域、代码块区域、列表按结构单元做初切再对超长单元做语义相似度断点检测最后跑一轮质量校验把不合格的块打回去重切。这一代的核心变化不是算法更花哨而是切分从一次变换变成了一个带反馈的流程。LangGraph 在这里的价值就体现出来了——它天生适合表达这种有状态、有分支、有回流的流程。1.3 为什么最终选 LangGraph 来做流程编排先说结论不是 LangChain 的切分器不好用它的RecursiveCharacterTextSplitter和MarkdownHeaderTextSplitter我至今还在单独调用。真正的问题在于编排层。当你需要先按标题切超长的再按语义切切完检查有没有孤立短块有的话跟前一块合并合并完再检查一次这种带条件分支和循环的流程时用普通函数串起来会写成一大坨嵌套 if-else中间状态全在局部变量里出问题只能打断点单步调试长文档跑一次几分钟改个参数就得从头再来。LangGraph 的StateGraph把这套东西显式化了状态是一个明确的字典结构每个节点只负责读状态、写状态的一部分节点之间用条件边连接循环重试有明确的上限控制还自带检查点能力能断点续跑。这些特性放在切分这种处理单元多、容易局部出错、需要反复调参的场景里收益非常直接。我实际改完之后的感受是调试时间从每次改参数重跑整条链路变成了从出问题的那份文档的检查点重跑这个差别在处理几百份 PDF 的批处理任务时是决定性的。2. LangChain 切分器的能力边界与参数推导2.1 四类常用切分器的适用场景对照LangChain 生态里的切分器不少但真正高频用到的就那几个。我把它们按什么时候该用、什么时候别用整理成了一张表这张表是我自己在选型时反复对照的版本。切分器断点依据适合的语料明显短板CharacterTextSplitter单一分隔符格式高度统一的短文本分隔符选不对就直接超长几乎没有兜底能力RecursiveCharacterTextSplitter分隔符优先级列表通用正文、说明文档不理解标题层级块与章节的归属关系会丢MarkdownHeaderTextSplitterMarkdown 标题结构化程度高的技术文档遇到超长小节不拆遇到表格代码块处理粗糙TokenTextSplittertoken 计数需要精确控制 prompt 预算的场景需要额外依赖分词器断点常落在词中间语义切分按嵌入相似度找断点相邻句向量距离长段落、无明显小标题的叙述性内容计算开销大阈值敏感块数容易失控选型的判断逻辑其实很简单先看语料有没有显式结构再看结构够不够细。如果文档本身有完整的标题层级优先走结构切分因为标题是最强的语义边界信号成本几乎为零如果文档是一大坨没有小标题的叙述文本比如访谈记录、会议纪要那结构切分无从下手只能退到递归分隔符或者语义切分。我一般不会一上来就用语义切分因为它的计算成本是前两者的几十倍而收益在结构清晰的语料上并不明显。2.2 chunk_size 与 chunk_overlap 的推导过程这两个参数是最多人拍脑袋定的。1000/200这个组合流传太广以至于很多人以为它是某种标准答案。实际上它应该从你的模型上下文预算倒推出来。推导链条是这样的生成模型的上下文窗口是硬上限里面要装系统提示、对话历史、检索到的 K 个块、以及留给输出的空间。写成公式大致是单块 token 预算 (上下文上限 - 系统提示 - 历史对话 - 输出预留) / TopK × 安全系数举个具体例子。假设用的模型上下文是 8192 token系统提示加指令占了 800历史对话留 1000输出预留 1500那么留给检索内容的就只有 4892。如果 TopK 设成 5平均每块能分到约 978 token再乘一个 0.85 的安全系数防止个别块超长撑爆单块目标就是830 token 左右。这里有个中文场景特有的坑token 和汉字不是一比一。不同分词器对中文的切分粒度差别很大有的按字切有的会把常见词合并。稳妥的做法是拿你自己库里的真实文档跑一遍分词器统计汉字数 / token 数的比值我实测过的几个分词器这个比值落在 1.2 到 1.7 之间。假设你的比值是 1.5那 830 token 大约对应550 个汉字。这个数字才是你该填进chunk_size的值注意要按字符配置框架内部会按字符数处理。chunk_overlap的作用是防止边界信息丢失。取chunk_size的 10% 到 15% 是常见做法也就是上面这个例子里 55 到 82 个字符。但 overlap 有个隐性代价很多人没意识到它会让重复内容在向量库里产生多个近似块。当你 TopK 设得比较小、overlap 又设得比较大时很可能出现检索回来的 5 个块里有 3 个是同一段话的错位副本等于白白浪费了上下文预算。我踩过这个坑后来把 overlap 从 200 降到 60召回块的有效信息量肉眼可见地提升了。提示overlap 不要超过 chunk_size 的 20%。超过这个比例重复块挤占 TopK 名额的概率会显著上升收益远小于代价。2.3 元数据设计让每个块自带坐标系只存page_content和source两个字段的块在生产环境里是不够用的。我的经验是每个块至少要带上一组能回答我从哪来、我在哪、我属于谁的元数据。这套字段设计好之后后面做引用标注、做增量更新、做问题定位都会轻松很多。chunk_meta { doc_id: doc_20240612_001, # 文档唯一标识 doc_hash: sha256:9f3a..., # 原文哈希用于判断是否需要重切 source_path: handbook/deploy.md, # 来源路径 section_path: 3. 部署 3.2 配置项, # 标题路径最有价值的字段 heading_level: 2, # 所属标题层级 block_index: 17, # 在本文档内的顺序号 char_start: 4821, # 在原文中的起始偏移 char_end: 5372, # 结束偏移用于前端高亮 content_type: text, # text / table / code / list splitter_version: v3.2, # 切分策略版本方便回溯 token_count: 812, # 该块的实际 token 数 }这里面我最看重两个字段section_path和splitter_version。section_path是标题路径把它拼在块内容前面一起做嵌入能显著提升块的自解释性——一个块里写设置为 true 即可单看这句话完全不知道在说什么但前面加上3. 部署 3.2 配置项 开启审计日志这个路径语义立刻就完整了。这个技巧叫上下文注入或者上下文前缀成本极低但效果稳定。splitter_version则是为了版本管理。切分参数是会变的改了参数重切之后向量库里会同时存在新旧两批块。带上版本号你就能在排查问题时快速区分这是老策略切出来的块还是新策略切出来的也能在发现新策略效果更差时快速回滚。2.4 纯 LangChain 方案在生产环境撞到的三个硬伤用 LangChain 切分器跑 Demo 完全没问题但真到了几千份文档、需要持续维护的场景三个问题会陆续暴露出来。第一个硬伤是单向、无校验。整条链路是读文件 → 切 → 写库中间没有任何质量检查。你没法在流程里插一句这个块只有 12 个字是不是应该合并到前一块也没法说这个块超过 1500 token 了得再切一刀。想加这些逻辑只能切完之后再跑一个后处理循环而那个循环一旦要改策略又得从头跑。第二个硬伤是策略组合困难。真实文档往往是混合结构一部分章节有清晰标题一部分是放飞自我的长段落还有表格和代码块。你很难用一个切分器通吃。想组合就得自己写胶水代码判断这一段走结构切分、那一段走递归切分写完发现代码里到处是特判维护成本很高。第三个硬伤是中间状态不可见。切分过程里会产生很多有价值的中间产物文档解析出的结构树、每个结构单元的 token 分布、被判定为异常的短块、合并记录。这些东西在纯链式调用里全是局部变量跑完就没了。等到线上反馈某个文档的问答总是答不对你想复现都难因为没法把当时那份文档的处理中间态捞出来看。3. 用 LangGraph 重构切分链路状态、节点与回流3.1 为什么切分需要一个有状态的图而不是一条链链式和图式的本质区别在于能不能回头。链式是一根筋往下走每一步的输出就是下一步的输入没有分支也没有循环。而切分这件事天然需要回头切出来的块不合格要回去重切重切还不合格要降级到保底策略保底策略处理完还要再过一遍校验。LangGraph 的StateGraph正好提供了这三样东西显式状态一个贯穿全程的字典所有节点共享读写、条件边根据状态决定下一步走哪个节点、循环配合计数器实现有限次重试。把切分流程画成这张图之后每个环节的职责变得非常清晰而且因为状态是显式的你在任何一个节点停下来都能看到当时的完整快照。还有一个实际收益是断点续跑。LangGraph 的检查点机制会把每个节点执行后的状态存下来一份 200 页的 PDF 跑到第 3 个节点挂了修完 bug 可以从那一步接着跑不用从头解析。批处理场景下这个特性省下来的时间相当可观。3.2 状态结构设计把中间产物全部显式化状态结构的设计原则是凡是后续节点需要读的、或者将来排查问题想看的都放进状态里。我用的结构大致是这样from typing import TypedDict, List, Dict, Any, Optional class SplitState(TypedDict): # 输入 doc_id: str raw_text: str doc_meta: Dict[str, Any] # 解析产物 structure_tree: Optional[Dict[str, Any]] # 标题层级树 blocks: List[Dict[str, Any]] # 结构单元列表 # 切分产物 chunks: List[Dict[str, Any]] # 最终块列表 rejected: List[Dict[str, Any]] # 校验不通过的块 # 流程控制 retry_count: int max_retry: int strategy: str # struct / semantic / fallback log: List[str] # 每步的处理记录注意retry_count和max_retry这两个字段它们是循环的安全阀。没有这个上限条件边很容易写成死循环尤其是语义切分阈值设得不合适的时候——切出来的块永远过小永远触发合并合并完又过大永远触发重切来回震荡。我一开始就吃过这个亏跑了一份文档卡了十几分钟没出来后来加了硬上限才稳住。log字段看起来可有可无实际上是我调试时用得最多的。每个节点往里追加一行处理了 N 个单元产出了 M 个块其中 K 个被判定异常出问题时扫一眼日志就能定位到是哪一步开始跑偏的。3.3 节点拆分解析、结构切分、语义合并、质量校验节点拆分的粒度需要拿捏。拆太细节点之间传递的东西太多图会变得臃肿拆太粗又回到了链式调用的老路上。我最后落到的方案是四个节点加一个校验分支。节点一解析parse。负责把原始文本转成结构单元列表。这一步要处理 Markdown 标题、列表项、代码块围栏、表格边界。输出是一个扁平的blocks列表每个 block 带type、level、content、char_start、char_end。这一步的价值在于把结构信息提取出来后面所有切分决策都基于它。节点二结构切分split_by_structure。按标题层级把blocks聚合成初步的块。同一小节内的连续正文块合并成一个块遇到新标题就断。表格和代码块单独成块不跟正文混。这一步产出的块已经覆盖了大部分场景剩下的问题就是超长和过短。节点三语义合并semantic_merge。处理两类边界情况。一类是超长块需要按句子级语义相似度找二次断点另一类是过短块比如只有几十个字的过渡句要判断它跟前后哪一块语义更近合并进去。语义断点的实现方式是用嵌入模型算相邻句子的向量距离距离超过阈值的那个位置就是断点。节点四质量校验validate。跑一组规则检查产出rejected列表。规则包括token 数上下限、是否以标点结尾、是否包含孤立的标题行、表格块是否被截断、代码块围栏是否配对。校验节点的返回值直接决定条件边走向全通过就去索引有问题的块根据retry_count决定是回流重切还是降级到保底策略。3.4 条件边与重切回路让不合格的块自动返工条件边的写法不复杂关键是分支要穷尽。我一开始只写了通过和重试两个分支结果遇到一类特别顽固的文档——某个章节只有一行标题下面跟着一张大表格无论怎么切都过不了校验——直接卡死在重试循环里。后来补了第三个分支fallback把这类块标记为强制通过并打上人工复核标签流程才跑通。def route_after_validate(state: SplitState) - str: if not state[rejected]: return index if state[retry_count] state[max_retry]: return fallback return retry这个设计里有个细节值得说重试不是把整篇文档重切而是只针对rejected里的块重切。这一点在状态设计上体现为重切节点接收的是被拒块列表产出的新块直接追加回chunks。这样一份 200 页文档里只有 3 个块不合格时重试的开销可以忽略不计。如果每次都全文重跑长文档的处理时间会翻好几倍。max_retry我一般设成 2。设 1 的话遇到需要两轮收敛的情况第一轮切完还是长需要再切一次就不够用设 3 以上收益递减而且可能掩盖真实的问题。3.5 图的组装与运行骨架把节点和边连起来之后的代码大致长这样。这段代码我做了精简去掉了业务侧的具体实现保留了骨架结构。from langgraph.graph import StateGraph, END def build_split_graph(): g StateGraph(SplitState) g.add_node(parse, parse_node) g.add_node(split_by_structure, split_by_structure_node) g.add_node(semantic_merge, semantic_merge_node) g.add_node(validate, validate_node) g.add_node(retry_split, retry_split_node) g.add_node(fallback, fallback_node) g.add_node(index, index_node) g.set_entry_point(parse) g.add_edge(parse, split_by_structure) g.add_edge(split_by_structure, semantic_merge) g.add_edge(semantic_merge, validate) g.add_conditional_edges( validate, route_after_validate, { index: index, retry: retry_split, fallback: fallback, }, ) g.add_edge(retry_split, validate) # 回流再校验 g.add_edge(fallback, index) g.add_edge(index, END) return g.compile(checkpointercheckpointer)retry_split → validate这条边就是回路。checkpointer是可选的但强烈建议加上尤其是在批处理任务里能省下大量重跑时间。还有一点是关于幂等性的。重切节点必须是幂等的同一个输入跑两次产出的块要完全一致。因为回路可能触发多次如果重切逻辑里带了随机性比如用了带随机种子的采样最终结果的稳定性就没法保证。我的做法是把所有涉及的阈值和随机种子都写进配置跟着splitter_version一起固化。4. 切分质量怎么量化评估集、指标与对照实验4.1 搭一个最小可用的切分评估集切分好不好光靠肉眼看几个块是不够的。你需要一个能反复跑的评估集。我的做法是从真实文档里挑 50 到 100 组问答对每组都要标注两样东西答案所在的原文片段也就是标准答案的字符偏移范围以及回答这个问题所必需的最小信息单元。构造方式有三种按成本从低到高排第一种是从现有的工单、客服记录、FAQ 里直接扒。这些问题都是真实用户问的覆盖面好成本几乎为零缺点是答案质量参差不齐需要人工过一遍。第二种是拿大模型从文档里反向生成问题。给一段原文让它生成三个这段文字能回答的问题再人工筛掉太简单或者太牵强的。这种方式产量高但要注意筛掉那种答案就是原文第一句话的送分题它们对评估没有区分度。第三种是人工精标最准但最慢。我一般只对最核心的那几十份文档做精标其余用前两种方式铺量。评估集里有个必须注意的点要包含一定比例的跨段问题。也就是答案分散在两个不相邻段落里的问题。这类问题最能区分切分策略的优劣——切得好两块都能召回切得差答案被拆散或者被合并进两个大块里稀释掉。如果评估集全是答案集中在一句话里的简单问题你会发现所有切分策略的得分都差不多评估就失去了意义。4.2 四个可落地的评估指标指标不需要多四个足够把问题定位清楚。**命中率Hit RateK**是最基础的TopK 个召回块里至少有一个覆盖了标准答案片段的算命中。这个指标衡量的是找不找得到一般目标定在 0.9 以上。**平均倒数排名MRR**衡量的是排得前不前。正确答案所在块的排名取倒数再对所有问题求平均。这个指标对 TopK 大小敏感同一个切分策略TopK 从 3 调到 10MRR 会明显下降所以对比实验里 TopK 必须固定。**上下文精度Context Precision**衡量的是召回的内容有多少是有用的。计算方式是召回块中与答案相关的字符数占召回总字符数的比例。这个指标最能反映块大小是否合适——块太大精度掉得厉害块太小精度高但命中率会掉。两个指标要一起看。答案完整率是唯一需要人工或者模型评审的指标。给定召回内容让生成模型回答然后判断答案是否完整覆盖了标准答案的要点。这个指标最贴近用户体验但成本最高我一般只在一个策略调优定型的时候跑一次。4.3 调参前后的对照数据下面这组数据来自我做过的一轮调优语料是一批技术文档和产品手册的混合体评估集 80 条TopK 固定为 5。对比的是三个版本纯递归分隔符切分、结构切分、结构切分加语义合并和校验回流。指标纯递归切分结构切分结构语义校验命中率50.810.890.94MRR0.620.710.78上下文精度0.440.580.67答案完整率0.660.750.83平均块 token 数812640585块总数482056105380单文档平均处理耗时1.2s1.9s3.4s几个值得注意的现象。块总数在结构切分这一步涨得最多因为按标题切会把原本连在一起的内容断开块变多、变小了。但这一步的收益也很明显上下文精度从 0.44 跳到 0.58说明块变小之后噪声确实少了。加上语义合并和校验之后块总数反而略微回落因为短块被合并掉了。耗时从 1.2 秒涨到 3.4 秒接近三倍主要开销在语义合并那一步的向量计算。这个代价在离线批处理场景可以接受但如果你的场景要求实时切分比如用户上传文档立刻入库就得掂量一下。我的折中方案是常规文档走结构切分加轻量校验只有被标记为高风险的文档长度超过阈值、或者历史上出过问题的类型才走完整的语义合并流程。还有一点命中率的提升幅度远小于上下文精度和答案完整率。这说明切分优化的主要价值不在于让正确答案出现而在于让正确答案周围没有那么多废话。这个认知很重要它意味着如果你的系统瓶颈是根本检索不到那该优化的是嵌入模型和检索策略如果瓶颈是检索到了但答不准那切分才是该动的地方。5. 实操问题与排查速查表5.1 几个反复出现的典型问题表格被切碎是最常见的一个。表格在纯文本里是以管道符或者制表符分隔的行递归切分器不认识表格边界很容易从中间切一刀结果前半张表进了块 A后半张表进了块 B两边都没法用。解决方式是在解析阶段就把连续的表格行识别成一个整体单元标记content_typetable切分时禁止在表格内部断点。如果表格本身超长宁可整块保留让它超一点 token也不要切断因为半个表格的信息价值几乎为零。代码块围栏不配对也很烦人。Markdown 里的代码块用三个反引号包裹如果切分点正好落在围栏中间就会出现一个块里有开头没结尾的情况。这种块喂给生成模型它很容易把后面所有内容都当成代码。解决方法是在解析阶段记录每个代码块的起止偏移切分时避开围栏位置。孤立标题行是另一个高频问题。按标题切分时如果某个标题下面直接跟着一个子标题中间没有正文就会产出一个只有一行标题的块。这种块的向量表示几乎没有信息量但会污染索引。校验规则里加一条块内容去掉标题后少于 N 个字符则标记为异常交给合并节点处理。跨页段落断连在处理 PDF 时特别常见。一个段落在第 3 页末尾开始第 4 页开头继续中间夹着页眉页脚和页码。如果不做预处理这两半会被当成两个独立段落。我的处理方式是在解析阶段先做页眉页脚识别和去除再做跨页段落拼接——判断依据是上一页最后一行是否以句末标点结尾如果不是就和下一页第一行拼接。嵌入模型的静默截断这个问题最隐蔽。大多数嵌入模型都有最大输入长度超出的部分会被直接裁掉而且不报错。如果你的块是 800 token模型上限是 512那后 288 token 的内容对向量毫无贡献但你在检索时完全感知不到。排查方式很简单跑一遍全库统计看有多少块超过模型上限这个比例应该为 0。我给校验节点加了一条规则超限的块强制回流重切。5.2 问题速查表现象大概率原因排查动作处理方式答案明明在库里却检索不到答案被拆散在两个块里抽查 10 条失败案例看答案原文的偏移范围跨了几个块减小 overlap 之外的断点侵入启用语义合并召回了但答案不完整块太大关键信息被稀释看召回块的平均 token 数和上下文精度缩小 chunk_size增加结构切分TopK 里出现重复内容overlap 过大统计召回块的字符重叠率overlap 降到 chunk_size 的 10% 到 15%表格类问题几乎全错表格被切断检查含表格的块看行数是否完整表格单独成块禁止内部断点代码类问题答非所问代码围栏被切断检查块内反引号是否成对解析阶段记录围栏偏移切分避开索引里有一堆极短块孤立标题、页码、页眉残留统计块长度分布看小于 50 字的占比预处理去页眉页脚短块合并换个嵌入模型后效果反而变差块长度超过新模型上限被截断统计超限块比例按新模型上限重新推导 chunk_size重切之后新旧块混在一起没有版本管理查元数据里是否有 splitter_version补版本字段按版本清理旧数据6. 踩过这些坑之后我固化成习惯的几条做法第一条是永远先看数据再定参数。拿到一批新文档先跑个脚本统计长度分布、标题密度、表格占比、代码块占比再决定用哪套切分策略。我见过太多人上来就套 1000/200结果语料全是短问答每个块里塞了七八条不相干的内容。第二条是给每个块加标题路径前缀。这个动作的成本几乎为零但对块的自解释性提升非常明显。尤其是那些依赖上下文才能理解的指代词——该参数上述配置此接口加上标题路径之后嵌入向量就不再是悬空的了。我在几个项目里对比过加前缀之后上下文精度普遍能涨 5 到 10 个百分点。第三条是索引小块喂大块。也就是父子块策略入库时用小块做 embedding保证检索精度命中之后不直接把小块给生成模型而是根据元数据里的section_path找到它所属的父块把父块内容喂进去。这样检索和生成各取所需不用在 chunk_size 上做痛苦的折中。LangGraph 的图结构做这件事很自然在index节点里多存一份父块映射就行。第四条是把切分参数当配置管不当代码管。chunk_size、overlap、语义阈值、最小块长度这些全部提到配置文件里跟splitter_version绑定。改了参数就升版本号重新切分时按版本清理旧块。这样做的直接好处是某天发现效果下降时你能快速定位到是不是上周改了哪个参数。最后一条也是我觉得最有价值的给切分流程留一个人工复核队列。不管校验规则写得多细总会有那么一小撮块过不了又降级不了。与其让它们悄悄溜进索引污染检索结果不如显式地扔进一个待复核列表定期人工看一眼。我现在的做法是fallback节点产出的块全部打上标记不进主索引单独存一个集合。跑了几个月下来这个队列里积累的样本成了我优化切分规则最宝贵的输入——每一条都对应着一个真实的、现有规则覆盖不到的场景。