资讯动态

RAG文本分块与高级索引:Splitter选型、父子块与层级索引实战

发布时间:2026/10/5 5:15:53 来源:尧图企业网站定制
做 RAG 检索的人大概率都在文本分块这一步反复横跳过。块切大了召回的内容经常“沾边但不精确”看着相关送到大模型嘴里却是一堆噪声块切小了又容易把完整语义拦腰斩断一句话的前因后果分到了两个块里检索出来的东西支离破碎。这一篇继续聊“文本分块实现与高级索引”系列的第五部分重点讲三件事四种 Splitter 怎么选、父子块怎么解决上下文丢失、层级索引怎么应对大规模文档。内容主要来自我在几个知识库项目里反复调参的真实经验适合正在优化 RAG 检索质量或者要处理长文档、多级知识库的开发者参考。1. 四种 Splitter 的选型逻辑与参数把控1.1 递归字符分割器多级分隔符下的“保底方案”日常项目里我最常用的还是 RecursiveCharacterTextSplitter它的核心思路是维护一个有优先级的 separator 列表先按最粗粒度去切切完发现某个块还是超过 chunk_size就退一级用更细的分隔符再切直到切完或者分隔符列表用尽。from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ], length_functionlen, )注意 separator 的顺序很有讲究原则是“越粗的粒度越靠前”。默认列表是[\n\n, \n, , ]这套东西直接拿去切中文问题很明显中文段落内很少有英文空格长段落往往只能一路切到空字符串才被硬生生按长度截断句子完整性基本不保。我一般会把中文句末标点。插到空格之前这样切出来的块会优先保留整句。初看参数好像只有 chunk_size 和 chunk_overlap但真正拉开差距的是你对文本结构的理解。chunk_size 不是“物理等长”而是“语义等长”意思是尽量让每个块的语义完整度接近。我见过很多人把 chunk_size 调到 2000 甚至 4000理由是要喂更多上下文给模型结果向量被拉长之后query 与整段的余弦相似度被无关内容稀释召回精度明显下降。这块没有标准答案先用 300 到 800 起步配合评测集去调比拍脑袋靠谱得多。overlap 的作用是缓解切分边界的上下文截断但别贪多。一般取 chunk_size 的 10% 到 20%太多会造成相邻块大量重复检索时同一个信息点被召回两三次白白挤掉其他候选结果的名额。1.2 定长字符分割器最省心但不适合中文直接上CharacterTextSplitter 的逻辑很简单先按分隔符把文本切成若干段再按 chunk_size 做定长合并超了就切新块。from langchain_text_splitters import CharacterTextSplitter text_splitter CharacterTextSplitter( separator\n\n, chunk_size500, chunk_overlap50, )它和递归分割器最大的区别是不会为了保留句子边界而逐级回退分隔符只是把文本切成若干段真正的输出块还是按字符长度合并出来的。这意味着只要 separator 设得不够密集就一定会有句子被从中间切断。那它还有什么用我一般把它用在两类场景一是文本结构特别规整的数据比如接口返回的日志、固定模板导出的纯文本分隔符位置固定切起来很干净二是作为预处理步骤先用一个较大的 chunk_size 把超长原始文档切成“大段”后面再交给递归或句子级 Splitter 做二次细分。直接拿它作为中文 RAG 的主分块器我试过几次效果都不理想所以现在基本只当辅助工具用。1.3 Token 分割器对齐模型窗口的真正尺子模型上下文窗口按 token 计费不是按字符数计费这个问题在中文场景里尤其关键。一个汉字大概折算 0.6 到 1.3 个 token具体取决于编码器和词汇表覆盖。你以为 chunk_size500 是 500 字实际送去模型可能是 700 token如果后面还有摘要重排、多轮对话预算很容易爆。TokenTextSplitter 就是专门解决这个对齐问题的import tiktoken from langchain_text_splitters import TokenTextSplitter enc tiktoken.get_encoding(cl100k_base) text_splitter TokenTextSplitter( chunk_size300, chunk_overlap50, encoding_namecl100k_base, )但它有个不太好的特性按 token 位置硬切完全不感知句子和语义边界。所以我不建议单独拿它做所有文本的切分而是把它当成一道“兜底工序”——先用递归字符分割器按 500 字符粗切再用 TokenTextSplitter 把超限的块精切到 token 上限以内。这样既保住了大部分语义边界又严格保证了模型的 token 预算。如果你用的不是 OpenAI 模型记得换对应模型的分词器比如开源模型就走 Hugging Face 的 tokenizer。别想当然地拿len(text)去预估 token 数中英文混排时能偏差 30% 以上。1.4 句子级/语义边界分割器中文场景真正该重视的候选以句子为单位切块再按 chunk_size 把相邻句子打包是另一种很实用的策略。实现上可以借助 NLTK 的 SentenceSplitter也可以自己用正则做启发式切句import re def split_sentences(text): parts re.split(r(?[。])\s*, text) return [p for p in parts if p.strip()] def build_chunks(sentences, chunk_size500): chunks, cur [], for s in sentences: if cur and len(cur) len(s) chunk_size: chunks.append(cur) cur s else: cur s if cur: chunks.append(cur) return chunks这里有个小坑直接按。切句会误伤引号和括号内的内容比如“他说‘你好。’接着走了。”不应该把‘你好。’后面的引号丢掉。简单做法是把右引号并到切句正则的统计里或者先处理成统一引号再做切句。想要更鲁棒就上 spaCy 中文模型但依赖较重一般项目用启发式就够。进阶一点的语义分割器是根据相邻句子的 embedding 相似度来找语义断层相似度突降的地方作为切分边界。效果确实是四种里最好的但成本高在要预先对每个句子做向量化。我通常只在“父块”层用它——因为父块数量少embedding 成本有限而子块层数量大不适合逐个句子算相似度用句子级打包性价比更高。四种 Splitter 的核心差异可以汇总成一张表Splitter切分依据边界质量中文适配适用场景RecursiveCharacterTextSplitter多级分隔符递归好需自定义分隔符通用默认CharacterTextSplitter固定字符长度一般易切断句子规整文本/预处理TokenTextSplitterToken 数一般与模型对齐控制窗口/成本句子级/语义边界句子边界好需中文标点语义完整优先2. 父子块方案让检索结果自带上下文2.1 单层分块解决不了“上下文断裂”的本质原因回忆一个典型场景合同条款原文是“如发生如下情形甲方有权单方解除合同1. …2. …3. …”如果 1、2、3 分别被切到不同块你检索“甲方解除合同条件”时命中的可能只是“1. 乙方连续三个月未支付租金”这一小块LLM 根本不知道前面还有“甲方有权单方解除合同”这个大前提回答自然是断章取义。有人会说那把 chunk_size 调大不就行了吗问题是块越大向量表示里掺杂的无关内容越多query 和整段的相似度会被稀释召回精度反而不稳。这就是单层结构的固有矛盾你想要精确的检索单元就必然牺牲上下文完整性你想要完整上下文就得接受召回精度的下降。父子块方案把这两个需求拆开了子块负责召回精度父块负责上下文供给。向量化时只对子块做 embedding检索时命中子块再把对应的父块文本取出来送进 LLM。这样检索粒度可以很小回答上下文却可以很大。2.2 父子块的落库结构与反查映射实现落库结构有两种常见方式。方式 Aparent 表和 child 表分开存child 表带 parent_id 外键方式 B单集合里每条记录同时存子块文本和父块文本更贴合向量数据库的检索习惯。我一般用方式 B结构类似这样{ child_id: doc_a:0:2, parent_id: doc_a:0, doc_id: doc_a, child_text: 2. 乙方连续三个月未支付租金。, parent_text: 如发生如下情形甲方有权单方解除合同1. …2. 乙方连续三个月未支付租金3. … }生成这种结构时我会用两级 Splitter先按章节或段落切出父块再在每个父块内部按句子边界切出子块。from langchain_text_splitters import RecursiveCharacterTextSplitter def make_parent_child(doc_text, doc_id): parent_splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap0, separators[\n\n, \n, 。, , , ] ) parent_chunks parent_splitter.split_text(doc_text) child_records [] for idx, parent_text in enumerate(parent_chunks): parent_id f{doc_id}:{idx} child_splitter RecursiveCharacterTextSplitter( chunk_size200, chunk_overlap20, separators[。, , , \n, , ] ) for j, child_text in enumerate(child_splitter.split_text(parent_text)): child_records.append({ child_id: f{parent_id}:{j}, parent_id: parent_id, doc_id: doc_id, child_text: child_text, parent_text: parent_text, }) return child_recordsparent_id 的设计我一直推荐doc_id:区块序号这种语义化 ID排查线上 badcase 的时候看一眼就知道答案来自哪篇文档的哪一节不用再翻索引。子块之间的 overlap 我习惯压到最低20 个字符以内都行因为子块向量相似度高overlap 过大只会导致同一个内容被重复召回top_k 名额被白白浪费。这里还有一个存储层面的坑parent_id 字段一定要在向量数据库里建索引。检索时你拿到 20 个命中子块要按 parent_id 反查父块如果这个字段没有索引等于每次查询都做一遍全表扫描延迟直接翻倍。Chroma 的 metadata 等值筛选很方便Milvus 里则要给 scalar 字段单独建索引。需要明确的是父块不需要全部做 embedding默认只存文本就行。原因很简单检索阶段用不到父块向量除非你想在父块层做一次粗召回。这样索引成本基本等于单层子块方案不会比普通分块多花太多存储和计算费用。2.3 检索阶段如何使用父子块召回检索时最忌讳的做法是拿到 20 个命中子块后直接把这 20 个子块的文本一起塞给 LLM。子块间大量重复和割裂会让模型上下文变成一锅粥。正确做法是先按 parent_id 分组再决定取哪几个父块。流程大概是query 向量化后从子块集合召回 top_k比如 20按 parent_id 分组组内保留相似度最高子块的分数作为该父块的代表分按代表分排序取前 n 个父块比如 3 到 5 个最后把父块文本或截断版和 query 一起送进 LLM。hits collection.query(query_embeddings[emb], n_results20) groups {} for h in hits[metadatas][0]: pid h[parent_id] groups.setdefault(pid, []).append(h) ranked [] for pid, members in groups.items(): scores [m.get(distance, 0) for m in members] ranked.append((min(scores), pid, members)) ranked.sort() top_parents ranked[:3] contexts [] for _, pid, members in top_parents: contexts.append(members[0][parent_text])这里有个细节如果父块文本本身很长超过模型上下文窗口不要硬塞。一个折中方案是找出命中的子块在父块中的位置截取子块前后各 500 字作为增强上下文。信息完整性比父块全文略差但 token 成本低很多适合对延迟和成本敏感的生产环境。如果 query 同时命中同一个父块的多个子块说明这个父块就是核心答案区建议直接取整段父块如果命中块分散在不同父块则说明答案可能跨多个主题最好每个父块都取一部分。判断标准就是看分组后每个 parent 的命中子块数量。3. 层级索引从“一块一库”到“摘要-块-子块”三层检索3.1 层级索引解决的是哪一类问题父子块解决的是“单块上下文断裂”但还有一个问题它管不了当文档数量多了之后单层检索很容易把 top_k 名额集中分配给个别文档其他文档里的相关片段被挤出结果集。举个例子一个企业制度库有 120 份文档里面可能有 8 份都涉及“赔偿标准”但表述方式差异很大。单层检索按向量相似度排序返回的 top_10 很可能有 6 条都来自同一份最相似的文档剩下 4 条来自另外两份其他五份相关文档的片段全部被淹没。这种问题在父子块方案里依然存在因为子块检索和父块回灌都还是基于同一层全局索引。层级索引的思路是在块和子块之上加一个“视野层”先把大的搜索范围圈定到少数几篇文档再深入内部做精排。用生活化的比喻来说你找东西不应该在整个房间乱翻而是先判断它在哪个抽屉再打开抽屉精确找。这个“判断在哪个抽屉”就是文档摘要层。3.2 摘要索引与父子块的组合方式我目前比较成熟的落地方案是三层结构L0 摘要层每篇文档生成一条摘要两三句话足够做向量化metadata 带 doc_id。L1 父块层文档内部的章节或大块存储父块文本必要时才做向量化。L2 子块层父块内按句子边界切出的小块必须做向量化metadata 带 parent_id 和 doc_id。检索路径是先走摘要层召回候选文档再在这些文档范围内做子块检索最后按 parent_id 分组回灌父块上下文。# Step 1: 摘要层召回候选文档 doc_hits summary_collection.query( query_embeddings[emb], n_results3 ) doc_ids [d[doc_id] for d in doc_hits[metadatas][0]] # Step 2: 在候选文档内做子块检索 chunk_hits chunk_collection.query( query_embeddings[emb], n_results10, where{doc_id: {$in: doc_ids}} ) # Step 3: 按 parent_id 分组取父块上下文流程同 2.3Step 2 一定要带where{doc_id: {$in: doc_ids}}过滤否则摘要层只是白跑一遍检索范围根本没缩小。向量数据库的过滤扫描性能也要留意doc_id 字段同样要建索引。数据量到达百万级时最好把分区键设为 doc_id让查询只落到具体分区。摘要生成的成本是一次性的。一篇文档让大模型生成两三句话批量跑 1000 篇文档一天内能完成。检索阶段完全复用现有向量库不增加额外查询链路开销。我个人建议摘要别写太长两到三句话足够太长的摘要向量噪点更多反而会拉低文档级召回准确率。3.3 分层遍历策略与参数量级三层结构不是每次都要全走一遍。根据查询场景不同我有三种遍历模式模式 A先从摘要层取 top_k 文档再在候选文档内做块级检索。适合用户没有指定范围的开放问答比如“公司对离职赔偿怎么规定”。模式 B跳过摘要层直接带已知的 doc_id 过滤条件去做块级检索。适合用户已经在界面上选择了具体文档或栏目比如“只看《员工手册》”。模式 C混合模式。摘要层取 top3 文档在每个候选文档内各取 top3 块再在各块内取 top2 子块全部拉回来统一按相似度合并。适合答案跨多篇文档、需要广度覆盖的场景。参数上我积累的经验是摘要层 k 取 3 到 5块层 k 取 5 到 8子块层 k 取 8 到 10。太小会漏太大会把噪声带回来具体数值还是得用评测集扫。实际效果方面我给一个自己项目里的数据300 页的合同集纯单层子块检索的 Recall5 大概在 50% 左右加了摘要层之后直接提升到 80% 以上。提升来源很明确——检索范围从“全库捞”变成了“重点文档里捞”噪声基数小了一个数量级。4. 从检索质量反推分块策略实测调参与坑点4.1 用召回指标反推参数别靠感觉调分块参数最忌讳“感觉差不多就行”。你要有一个能重复跑的评测集。我的做法是准备 20 到 50 个 query对每个 query 标注正确答案所在的原始段落位置doc_id parent_id然后跑检索算 Recallk。def recall_at_k(results, ground_truths, k5): hit 0 for qid, gt_ids in ground_truths.items(): retrieved results[qid][:k] if any(_match(r, gt_ids) for r in retrieved): hit 1 return hit / len(ground_truths)_match 的判定我推荐用“检索到的块的 parent_id 与标注的 parent_id 重叠”作为命中标准而不是字符串精确匹配。这样能有效区分“检索质量”和“排序质量”调参时更有针对性。调参顺序我一般是固定 Splitter 类型扫 chunk_size300/500/800/1200再扫 overlap0/10%/20%然后切换单层到父子块结构最后决定要不要加摘要层。每一步记录一组 Recall5 和平均检索延迟形成一张对照表后续改动都能回溯。4.2 中文文本分块的三个典型坑坑一默认 separators 不认中文句号。RecursiveCharacterTextSplitter 默认分隔符列表里没有。中文长段落只能靠长度硬切结果就是句子被拦腰斩断。处理方式很简单把中文句末标点加进 separators放在换行符之后、空格之前。坑二机械照搬英文社区的 overlap 参数。英文一个 token 约 4 个字符overlap50 相当于 12 个单词左右中文一个 chunk 如果只有 200 字overlap50 已经是四分之一了相邻块重复率极高。我踩过这个坑结果是检索结果里同一个段落反复出现top_k 名额被严重浪费。中文场景我建议 overlap 控制在 chunk_size 的 10% 以内如果全用句子级 Splitteroverlap 直接设 0 都行靠句子边界天然衔接。坑三length_function 还在用 len 算字符。这在中文里尤其危险因为一个汉字可能折算到 1 个以上 token。向量模型也有最大输入限制很多模型只接受 512 token超长块会被静默截断截断点恰好落在句末之前整段结论直接丢失。解决方法是把 length_function 换成 tiktoken 或本地模型的分词器chunk_size 按 token 数设置而不是字符数。4.3 一个完整调优实例从 42% 到 78%有一个企业内部制度文档库项目120 份文档每份 3000 到 20000 字中文为主格式不统一。这个项目的调优过程比较典型。基线方案是 CharacterTextSplitterchunk_size500overlap50单层检索。评测集 Recall5 只有 42%。问题定位很明确文档结构杂乱定长切分把合同条款和制度段落切得七零八落命中结果常是条款的后半段模型根本不知道前置条件。第一阶段调整换成 RecursiveCharacterTextSplitterseparators 加入中文句末标点chunk_size800overlap100单层检索Recall5 提升到 57%。提升主要来自块边界更贴合自然段落完整条款被更多保留。第二阶段调整改成父子块结构。父块按章节和段落切chunk_size800子块按句子边界切chunk_size200overlap20。只对子块做 embedding检索时按 parent_id 分组回灌父块Recall5 从 57% 升到 69%。这个阶段观察到的变化不仅是指标LLM 回答的完整度明显改善不像以前那样经常只答出半句话。第三阶段调整加文档摘要层。每篇文档生成两句摘要先做摘要层 top3 文档召回再在候选文档内做父子块检索Recall5 提升到 78%。这时候真正解决了“多文档相关但被互相挤占”的问题。各阶段的数据可以看这张表方案配置Recall5基线CharacterTextSplitter 500/50 单层42%阶段 1RecursiveCharacterTextSplitter 800/100 单层57%阶段 2父子块 200/20 子块召回 父块回灌69%阶段 3摘要层 父子块78%这个项目最终上线的延时比基线多了 15% 左右主要是摘要层一次查询加过滤后的块查询多出来的成本但换来的检索质量提升非常值。如果对延迟极其敏感可以把摘要层结果做成离线缓存对高频 query 直接跳过摘要查询。我个人的体会是分块策略没有银弹但有一个值得记牢的取舍原则检索单元要小回答上下文要大。先把“最小可回答单元”定义清楚再来选 Splitter、定参数最后用 Recallk 说话。另外一个很实用的小技巧把这套方案落地时把 parent_id 设计成doc_id:区块序号排查线上 badcase 时能一眼看出答案来自哪份文档的哪一节效率提升不止一点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑