news 2026/10/23 5:44:18

RAG文档切分优化:从LangChain到LangGraph的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RAG文档切分优化:从LangChain到LangGraph的工程实践

RAG 系统上线到第三个月,最容易被拎出来背锅的环节往往不是向量模型,也不是生成模型,而是文档切分。我经手过几个检索问答类项目,排查到最后发现答案其实就在库里,只是被切成了两半,一半进了索引,另一半躺在隔壁块里,谁也没法单独回答用户的问题。这篇内容聊的就是这件事:RAG 系统里的文档切分策略到底怎么优化,为什么一开始用 LangChain 现成的切分器够用,后来又在什么场景下必须换成 LangGraph 这种带状态的图编排方式来兜底。里面会讲清楚切分参数怎么推导、元数据怎么设计、切分质量怎么量化评估,以及我从 LangChain 单向链路迁到 LangGraph 之后踩过的那些坑。不管你刚开始搭第一个 RAG Demo,还是已经在维护一个有几千份文档的生产库,应该都能从里面找到能直接抄的部分。

1. 重新审视文档切分:它是 RAG 链路里最不该糊弄的一环

1.1 切分做不好,后面所有环节都在给前面擦屁股

很多人搭 RAG 的顺序是:先选向量库,再挑嵌入模型,最后随手写个chunk_size=1000, chunk_overlap=200把文档切了导进去。这个顺序本身就把切分放到了最不重要的位置。但实际排查问题时你会发现,检索效果差的案例里,相当一部分根因在切分层:要么块太大,一篇文章的前言、正文、附录全塞进一个 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(checkpointer=checkpointer)

retry_split → validate这条边就是回路。checkpointer是可选的,但强烈建议加上,尤其是在批处理任务里,能省下大量重跑时间。

还有一点是关于幂等性的。重切节点必须是幂等的:同一个输入跑两次,产出的块要完全一致。因为回路可能触发多次,如果重切逻辑里带了随机性(比如用了带随机种子的采样),最终结果的稳定性就没法保证。我的做法是把所有涉及的阈值和随机种子都写进配置,跟着splitter_version一起固化。

4. 切分质量怎么量化:评估集、指标与对照实验

4.1 搭一个最小可用的切分评估集

切分好不好,光靠肉眼看几个块是不够的。你需要一个能反复跑的评估集。我的做法是从真实文档里挑 50 到 100 组问答对,每组都要标注两样东西:答案所在的原文片段(也就是标准答案的字符偏移范围),以及回答这个问题所必需的最小信息单元。

构造方式有三种,按成本从低到高排:

第一种是从现有的工单、客服记录、FAQ 里直接扒。这些问题都是真实用户问的,覆盖面好,成本几乎为零,缺点是答案质量参差不齐,需要人工过一遍。

第二种是拿大模型从文档里反向生成问题。给一段原文,让它生成三个"这段文字能回答的问题",再人工筛掉太简单或者太牵强的。这种方式产量高,但要注意筛掉那种"答案就是原文第一句话"的送分题,它们对评估没有区分度。

第三种是人工精标,最准但最慢。我一般只对最核心的那几十份文档做精标,其余用前两种方式铺量。

评估集里有个必须注意的点:要包含一定比例的"跨段问题"。也就是答案分散在两个不相邻段落里的问题。这类问题最能区分切分策略的优劣——切得好,两块都能召回;切得差,答案被拆散或者被合并进两个大块里稀释掉。如果评估集全是"答案集中在一句话里"的简单问题,你会发现所有切分策略的得分都差不多,评估就失去了意义。

4.2 四个可落地的评估指标

指标不需要多,四个足够把问题定位清楚。

**命中率(Hit Rate@K)**是最基础的:TopK 个召回块里,至少有一个覆盖了标准答案片段的,算命中。这个指标衡量的是"找不找得到",一般目标定在 0.9 以上。

**平均倒数排名(MRR)**衡量的是"排得前不前"。正确答案所在块的排名取倒数,再对所有问题求平均。这个指标对 TopK 大小敏感,同一个切分策略,TopK 从 3 调到 10,MRR 会明显下降,所以对比实验里 TopK 必须固定。

**上下文精度(Context Precision)**衡量的是"召回的内容有多少是有用的"。计算方式是召回块中与答案相关的字符数占召回总字符数的比例。这个指标最能反映块大小是否合适——块太大,精度掉得厉害;块太小,精度高但命中率会掉。两个指标要一起看。

答案完整率是唯一需要人工或者模型评审的指标。给定召回内容,让生成模型回答,然后判断答案是否完整覆盖了标准答案的要点。这个指标最贴近用户体验,但成本最高,我一般只在一个策略调优定型的时候跑一次。

4.3 调参前后的对照数据

下面这组数据来自我做过的一轮调优,语料是一批技术文档和产品手册的混合体,评估集 80 条,TopK 固定为 5。对比的是三个版本:纯递归分隔符切分、结构切分、结构切分加语义合并和校验回流。

指标纯递归切分结构切分结构+语义+校验
命中率@50.810.890.94
MRR0.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_type="table",切分时禁止在表格内部断点。如果表格本身超长,宁可整块保留让它超一点 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节点产出的块全部打上标记,不进主索引,单独存一个集合。跑了几个月下来,这个队列里积累的样本成了我优化切分规则最宝贵的输入——每一条都对应着一个真实的、现有规则覆盖不到的场景。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 5:43:18

Docker容器化部署入门与排错:镜像、数据卷、网络、编排实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:41:49

Linux TCP编程中errno与连接状态的5个硬核陷阱

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:41:09

Kubernetes Pod启动从90秒优化到8秒:本地Harbor私有镜像仓库加速实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:40:36

ESP32-S3量产交付:Windows一键烧录包完整方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:40:09

DDU显卡驱动彻底清理指南:原理、实操与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:40:03

汽车ECU BootLoader升级实战:PBL/SBL架构与UDS刷写全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华