☰
RAG文本分块与高级索引:Splitter选型、父子块与层级索引实战
2026/10/5 5:15:42 网站建设 项目流程

做 RAG 检索的人,大概率都在文本分块这一步反复横跳过。块切大了,召回的内容经常“沾边但不精确”,看着相关,送到大模型嘴里却是一堆噪声;块切小了,又容易把完整语义拦腰斩断,一句话的前因后果分到了两个块里,检索出来的东西支离破碎。这一篇继续聊“文本分块实现与高级索引”系列的第五部分,重点讲三件事:四种 Splitter 怎么选、父子块怎么解决上下文丢失、层级索引怎么应对大规模文档。内容主要来自我在几个知识库项目里反复调参的真实经验,适合正在优化 RAG 检索质量,或者要处理长文档、多级知识库的开发者参考。

1. 四种 Splitter 的选型逻辑与参数把控

1.1 递归字符分割器:多级分隔符下的“保底方案”

日常项目里我最常用的还是 RecursiveCharacterTextSplitter,它的核心思路是维护一个有优先级的 separator 列表,先按最粗粒度去切,切完发现某个块还是超过 chunk_size,就退一级用更细的分隔符再切,直到切完或者分隔符列表用尽。

from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], length_function=len, )

注意 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_size=500, chunk_overlap=50, )

它和递归分割器最大的区别是:不会为了保留句子边界而逐级回退,分隔符只是把文本切成若干段,真正的输出块还是按字符长度合并出来的。这意味着只要 separator 设得不够密集,就一定会有句子被从中间切断。

那它还有什么用?我一般把它用在两类场景:一是文本结构特别规整的数据,比如接口返回的日志、固定模板导出的纯文本,分隔符位置固定,切起来很干净;二是作为预处理步骤,先用一个较大的 chunk_size 把超长原始文档切成“大段”,后面再交给递归或句子级 Splitter 做二次细分。直接拿它作为中文 RAG 的主分块器,我试过几次,效果都不理想,所以现在基本只当辅助工具用。

1.3 Token 分割器:对齐模型窗口的真正尺子

模型上下文窗口按 token 计费,不是按字符数计费,这个问题在中文场景里尤其关键。一个汉字大概折算 0.6 到 1.3 个 token,具体取决于编码器和词汇表覆盖。你以为 chunk_size=500 是 500 字,实际送去模型可能是 700 token,如果后面还有摘要重排、多轮对话,预算很容易爆。

TokenTextSplitter 就是专门解决这个对齐问题的:

import tiktoken from langchain_text_splitters import TokenTextSplitter enc = tiktoken.get_encoding("cl100k_base") text_splitter = TokenTextSplitter( chunk_size=300, chunk_overlap=50, encoding_name="cl100k_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_size=500): 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 父子块的落库结构与反查映射实现

落库结构有两种常见方式。方式 A:parent 表和 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_size=800, chunk_overlap=0, 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_size=200, chunk_overlap=20, 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_records

parent_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_results=20) 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_results=3 ) doc_ids = [d["doc_id"] for d in doc_hits["metadatas"][0]] # Step 2: 在候选文档内做子块检索 chunk_hits = chunk_collection.query( query_embeddings=[emb], n_results=10, where={"doc_id": {"$in": doc_ids}} ) # Step 3: 按 parent_id 分组取父块上下文,流程同 2.3

Step 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 页的合同集,纯单层子块检索的 Recall@5 大概在 50% 左右,加了摘要层之后直接提升到 80% 以上。提升来源很明确——检索范围从“全库捞”变成了“重点文档里捞”,噪声基数小了一个数量级。

4. 从检索质量反推分块策略:实测调参与坑点

4.1 用召回指标反推参数,别靠感觉

调分块参数最忌讳“感觉差不多就行”。你要有一个能重复跑的评测集。我的做法是准备 20 到 50 个 query,对每个 query 标注正确答案所在的原始段落位置(doc_id + parent_id),然后跑检索算 Recall@k。

def recall_at_k(results, ground_truths, k=5): 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_size(300/500/800/1200);再扫 overlap(0/10%/20%);然后切换单层到父子块结构;最后决定要不要加摘要层。每一步记录一组 Recall@5 和平均检索延迟,形成一张对照表,后续改动都能回溯。

4.2 中文文本分块的三个典型坑

坑一:默认 separators 不认中文句号。RecursiveCharacterTextSplitter 默认分隔符列表里没有。!?;,中文长段落只能靠长度硬切,结果就是句子被拦腰斩断。处理方式很简单,把中文句末标点加进 separators,放在换行符之后、空格之前。

坑二:机械照搬英文社区的 overlap 参数。英文一个 token 约 4 个字符,overlap=50 相当于 12 个单词左右;中文一个 chunk 如果只有 200 字,overlap=50 已经是四分之一了,相邻块重复率极高。我踩过这个坑,结果是检索结果里同一个段落反复出现,top_k 名额被严重浪费。中文场景我建议 overlap 控制在 chunk_size 的 10% 以内,如果全用句子级 Splitter,overlap 直接设 0 都行,靠句子边界天然衔接。

坑三:length_function 还在用 len 算字符。这在中文里尤其危险,因为一个汉字可能折算到 1 个以上 token。向量模型也有最大输入限制,很多模型只接受 512 token,超长块会被静默截断,截断点恰好落在句末之前,整段结论直接丢失。解决方法是把 length_function 换成 tiktoken 或本地模型的分词器,chunk_size 按 token 数设置,而不是字符数。

4.3 一个完整调优实例:从 42% 到 78%

有一个企业内部制度文档库项目,120 份文档,每份 3000 到 20000 字,中文为主,格式不统一。这个项目的调优过程比较典型。

基线方案是 CharacterTextSplitter,chunk_size=500,overlap=50,单层检索。评测集 Recall@5 只有 42%。问题定位很明确:文档结构杂乱,定长切分把合同条款和制度段落切得七零八落,命中结果常是条款的后半段,模型根本不知道前置条件。

第一阶段调整:换成 RecursiveCharacterTextSplitter,separators 加入中文句末标点,chunk_size=800,overlap=100,单层检索,Recall@5 提升到 57%。提升主要来自块边界更贴合自然段落,完整条款被更多保留。

第二阶段调整:改成父子块结构。父块按章节和段落切,chunk_size=800;子块按句子边界切,chunk_size=200,overlap=20。只对子块做 embedding,检索时按 parent_id 分组回灌父块,Recall@5 从 57% 升到 69%。这个阶段观察到的变化不仅是指标,LLM 回答的完整度明显改善,不像以前那样经常只答出半句话。

第三阶段调整:加文档摘要层。每篇文档生成两句摘要,先做摘要层 top3 文档召回,再在候选文档内做父子块检索,Recall@5 提升到 78%。这时候真正解决了“多文档相关但被互相挤占”的问题。

各阶段的数据可以看这张表:

方案配置Recall@5
基线CharacterTextSplitter 500/50 单层42%
阶段 1RecursiveCharacterTextSplitter 800/100 单层57%
阶段 2父子块 200/20 子块召回 + 父块回灌69%
阶段 3摘要层 + 父子块78%

这个项目最终上线的延时比基线多了 15% 左右,主要是摘要层一次查询加过滤后的块查询多出来的成本,但换来的检索质量提升非常值。如果对延迟极其敏感,可以把摘要层结果做成离线缓存,对高频 query 直接跳过摘要查询。

我个人的体会是,分块策略没有银弹,但有一个值得记牢的取舍原则:检索单元要小,回答上下文要大。先把“最小可回答单元”定义清楚,再来选 Splitter、定参数,最后用 Recall@k 说话。另外一个很实用的小技巧:把这套方案落地时,把 parent_id 设计成doc_id:区块序号,排查线上 badcase 时能一眼看出答案来自哪份文档的哪一节,效率提升不止一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询