乐于分享
好东西不私藏

第05篇-知识库文档分块的学问

第05篇-知识库文档分块的学问

纯手搓 AI Agent(五)—— 文档分块的学问:太大 LLM 吃不下,太小又没营养

上一篇我们把 RAG 的全链路先过了一遍:文档上传进来,抽文本、切分、向量化、存 ES,查询时再检索、重排、回填,最后把结果塞给 LLM 回答。

这条链路里最容易被低估的一步,就是分块(Chunking)

很多人做 RAG,第一反应是先挑模型、先调 Embedding、先看向量库。结果跑起来发现回答总是不对味:明明文档里有答案,就是搜不准;或者搜到了,但返回给 LLM 的内容东一榔头西一棒子,最后答得半对半错。

坑往往不在模型,在分块。

文档块切太大,检索命中了但噪音太多;切太小,检索看起来很准,给 LLM 的上下文又不够。说白了,RAG 的第一刀没切好,后面基本都在给前面的错误擦屁股。

为什么分块这一步决定了 RAG 下限

先把问题掰开说。

1. LLM 的上下文窗口再大,也不是无底洞

很多人一看现在有 128K 上下文的模型,就容易飘:那我是不是整篇文档直接塞进去就行了?

理论上能塞,实际上没必要。因为上下文不是越长越好,而是越相关越好

一篇几万字的排障文档里,真正和问题相关的可能就三五段。你把整篇全塞进去,相当于把关键答案埋进一堆无关背景里,LLM 不一定能稳定抓住重点。不是模型吃不下,是吃进去以后消化得不够准。

2. Embedding 也有自己的舒适区

Embedding 模型不是黑洞,不是说文字越长、信息越多,算出来的向量就越准。

文本太长,多个主题会混在一个向量里,语义中心被摊薄;文本太短,又容易只剩下几个关键词,信息量不够。最后的结果就是:该近的不够近,不该近的反而混进来了。

所以分块本质上是在做一件事:给 Embedding 和 LLM 同时准备一份它们都能吃得舒服的输入

3. 检索命中的精度,直接取决于粒度

这个很好理解。

假设一块内容里同时讲了「HDFS 扩容」「副本数配置」「故障恢复」。用户搜的是「副本数配置」,这一整块可能会被命中,但返回给 LLM 的内容里有三分之二都是噪音。

反过来,如果你把内容切得过碎,确实精准了,但 LLM 拿到的只有一句半句,前后因果关系断了,照样容易答偏。

所以分块从来不是越小越好,也不是越大越好,而是一个很现实的 trade-off。

第一层:按 Token 精确切,先解决“别切爆了”

在 xbo-kbase 里,最基础的做法是 TokenChunker

它不是按字符数切,也不是按行数切,而是按 Token 数量切。原因很直接:模型限制的是 Token,不是字数。

为什么不能按字符数切

中文、英文、标点、代码、URL,在 tokenizer 眼里完全不是一回事。

有的中文字会拆成多个 Token,有的英文单词可能一个 Token 就装下了。你按 500 个字符切,看起来长度差不多,实际 Token 数可能已经差出一大截。

代码里用的是 GPT-3.5/4 同款的 CL100K_BASE 编码器:

private static finalEncodingRegistry REGISTRY = Encodings.newDefaultEncodingRegistry();private static finalEncoding ENCODING = REGISTRY.getEncoding(EncodingType.CL100K_BASE);

这个设计有两个细节挺实用:

第一,全局只初始化一次,避免每次分块都去加载 tokenizer 的词表;第二,直接和主流大模型的 token 口径保持一致,后续估算 chunk 大小更稳。

动态 Chunk Size,不搞一刀切

一开始很多人会写死一个值,比如每块 512 Token。能跑,但不一定聪明。

问题在于:短文本和长文本根本不是一个物种。短文本本来就几百 Token,你硬切,纯属没事找事;长文本如果还按 512 固定切,又会切出过多碎片,后续召回和存储压力都上来。

TokenChunker 里做了一个动态计算:

private intcalculateDynamicChunkSize(int tokenCount) {if (tokenCount <= 512) {return Math.max(MIN_CHUNK_SIZE, tokenCount);    } else if (tokenCount <= 2000) {return Math.min(512, tokenCount);    } else {int size = 500 + tokenCount / 100;return Math.min(MAX_CHUNK_SIZE, size);    }}

它的思路很朴素,但很对路:

  1. 短文本尽量少切,能保完整就保完整
  2. 中等文本维持在 512 左右,兼顾语义和检索粒度
  3. 长文本适当放大块尺寸,减少碎片数量,但又不超过上限

对应的参数也写得比较克制:MIN_CHUNK_SIZE = 256MAX_CHUNK_SIZE = 1024。这就意味着它不会因为文本过短而切出一堆太碎的小块,也不会因为文本过长而把块撑得离谱。

Overlap 不是可选项,是保险丝

只切块不重叠,理论上也能跑,但很容易在边界上翻车。

最典型的场景就是:一句关键结论的上半句在块 A,参数解释在块 B。检索命中了 A,但 A 里信息不完整;命中了 B,B 又缺上文。最后你会觉得“明明文档里有,怎么答不全”。

解决方法就是 overlap,也就是相邻块之间保留一段重叠内容。

private intcalculateDynamicOverlap(int chunkSize) {int overlap = (int) (chunkSize * 0.15);return Math.max(MIN_OVERLAP, Math.min(MAX_OVERLAP, overlap));}

这里取的是 15%,并且设了下限 20、上限 205。这个比例挺像工程上的经验值:

  • 太小,等于没重叠,边界信息还是会断
  • 太大,重复内容太多,存储和召回都浪费

所以 overlap 的本质,是拿一点点冗余,换跨块语义不断裂。

第二层:只按 Token 还不够,得尽量沿着语义边界切

递归切分不是为了炫技,是为了尽量别把语义切断

TokenChunker 解决的是长度控制问题,但还没解决语义完整性问题。

因为它本质上还是“按大小切”。就算 chunk size 算得再合理,也可能正好把一句话劈成两半,把一个列表拆在中间,甚至把一段代码逻辑从中腰斩断。

这时候就该 RecursiveTextSplitter 上场了。

递归切分的核心思想:先粗后细,能不断层就不断层

这个类最值钱的地方,不是“递归”这两个字,而是它背后的策略:

先尝试在大的语义边界上切,切不动,再逐级降级到更细的边界。

代码里的分隔符优先级是这样排的:

private static finalString[] SEPARATORS = {“\n\n”“\n”“。”“!”“?”“.”“!”“?”“;”“;”“,”“,”” “};

翻译成人话就是:

  1. 先按段落切
  2. 段落切不下来,再按行切
  3. 再不行,就按句号、问号、感叹号切
  4. 还不行,再退到分号、逗号、空格
  5. 实在没法优雅切,最后再硬切

这套顺序非常关键。因为段落边界天然比逗号边界更有语义完整性。优先在更大的结构上切,检索块读起来更像“人话”,而不是 tokenizer 的残片。

递归逻辑为什么比固定规则靠谱

来看核心流程:

private voidrecursiveSplit(String text, int maxTokens, int separatorIndex, List<String> result) {if (countTokens(text) <= maxTokens) {        result.add(text.trim());return;    }if (separatorIndex >= SEPARATORS.length) {        forceChunkByTokens(text, maxTokens, result);return;    }String separator = SEPARATORS[separatorIndex];String[] parts = text.split(escapeRegex(separator), -1);if (parts.length <= 1) {        recursiveSplit(text, maxTokens, separatorIndex + 1, result);return;    }// 先尽量合并,超限再递归进入下一级分隔符}

这里有三个关键点:

  1. 足够小就停,不做多余切分
  2. 当前分隔符切不动就降级,继续尝试更细的边界
  3. 就算切开了,也不是简单把 parts 全扔出去,而是先合并成尽量接近上限、又不超限的块

这个“先分再合”的过程很重要。因为真实文本不是工整的砖块,段落长度长短不一。你不能只会切,还得会拼,不然一个 20 Token 的残段、一个 30 Token 的残段、一个 40 Token 的残段,最后会搞出一堆信息密度很差的碎片。

最后一层兜底:再不行就硬切

现实世界的文本不总是那么配合。

比如超长 URL、没有标点的大段日志、挤成一坨的 OCR 结果、超长代码块,都可能让上面的语义切分策略失效。这时候如果还坚持“必须优雅”,只会卡死。

所以 RecursiveTextSplitter 最后留了一个兜底:forceChunkByTokens()

意思很明确:优雅不了就别装,先保证数据不丢、流程能跑。

这就是工程代码和 demo 代码的区别。demo 追求漂亮,生产代码先保证不炸。

第三层:小块检索准,大块上下文足——那就别二选一

做到前面两层,其实已经比很多 RAG 实现强不少了。但真跑到生产里,还会遇到一个更棘手的问题:

你到底是应该给检索更小的块,还是应该给 LLM 更大的上下文?

这问题本身就是个陷阱,因为答案往往是:两者都要

只用小块的问题:检索很准,回答容易短路

如果块很小,比如 300~500 Token,召回通常会很准。因为每块只讲一个局部点,语义聚焦。

但问题是,LLM 最终看到的也是这一小块。它可能知道某个配置项的定义,却不知道前面的适用条件;知道一个报错片段,却不知道后面怎么处理。

结果就是它答得像对,但不完整。

只用大块的问题:上下文完整,命中太模糊

如果块很大,比如 2000 Token 甚至更长,返回给 LLM 的信息会更完整,前因后果都在。

但检索阶段会变钝。因为一个大块里往往混了多个主题,向量表达是“平均语义”,最后谁都沾一点,谁都不够准。

这就是经典矛盾:小块适合找,大块适合答

Parent-Child 的思路:让找和答各干各的

Parent-Child 分层分块:child 负责命中,parent 负责回填上下文

RecursiveTextSplitter 里给的解法,就是 Parent-Child 分层分块。

public ParentChildResult splitWithParentChild(String text,int parentMaxTokens, int childMaxTokens, int childOverlapTokens) {List<String> parents = split(text, parentMaxTokens, 0);List<String> children = new ArrayList<>();List<Integer> childToParentIndex = new ArrayList<>();for (int parentIdx = 0; parentIdx < parents.size(); parentIdx++) {List<String> childChunks = split(parents.get(parentIdx), childMaxTokens, childOverlapTokens);for (String child : childChunks) {            children.add(child);            childToParentIndex.add(parentIdx);        }    }returnnew ParentChildResult(parents, children, childToParentIndex);}

这个设计说复杂也不复杂,本质就是两层:

  • Parent 块:大块,保留完整上下文,给 LLM 看
  • Child 块:小块,粒度更细,拿去做检索

最关键的是 childToParentIndex 这个映射。它记录了每个 child 属于哪个 parent。这样一来:

  1. 检索阶段只对 child 做匹配,保证精度
  2. 命中 child 之后,根据映射找到 parent
  3. 最终返回给 LLM 的,不是那一小块,而是对应的大块上下文

这一下就把“检索精准”和“回答完整”两件事分开处理了。

Parent-Child 在入库编排里是怎么落地的

说完原理,再看真正落库时怎么接进去。

在 EsKnowledgeBuildService 里,先做了一个分流判断:

private static final int PARENT_CHILD_TOKEN_THRESHOLD = 1000;public voidbuildText(DataDto dataDto, String text) throwsException {int tokenCount = recursiveTextSplitter.countTokens(text);if (tokenCount > PARENT_CHILD_TOKEN_THRESHOLD) {        buildTextWithParentChild(dataDto, text);    } else {        tokenChunker.chunkTextAuto(dataDto, text);    }}

也就是说,不是所有文档都上 Parent-Child。

这点很重要。因为 Parent-Child 虽然效果好,但复杂度也更高。短文本本身信息就集中,没必要为了炫技强行套两层结构。只有超过 1000 Token 的长文本,才值得上分层方案。

为什么是 1000 Token 阈值

这个值不是理论真理,更像一个工程上的经验分界线。

小于这个长度,文本通常还没长到“一个块里塞进多个大主题”的程度,普通切分已经够用;超过这个长度,长文档内部的主题扩散就会开始明显,Parent-Child 的收益会更大。

说白了,这个阈值不是学术论文里推出来的,是跑出来的。

真正写 ES 时只给 Child 做 Embedding

继续看 buildTextWithParentChild()

RecursiveTextSplitter.ParentChildResult result =        recursiveTextSplitter.splitWithParentChild(text, 200050075);List<String> children = result.getChildren();List<String> parents = result.getParents();List<Integer> childToParentIndex = result.getChildToParentIndex();List<float[]> embeddings = embeddingClient.getBatchEmbedding(children);

参数也很有代表性:

  • Parent = 2000 tokens
  • Child = 500 tokens
  • Child overlap = 75 tokens

这组参数的思路很清楚:

  • 2000 Token 的 parent,足够给 LLM 提供一段相对完整的上下文
  • 500 Token 的 child,检索粒度不会太粗
  • 75 Token overlap,跨块连接信息不至于断掉

更关键的是:只对 child 做 embedding

这是一个非常实在的优化。因为 parent 不参与检索,只是回填给 LLM 看。如果 parent 也做 embedding,一方面多花钱,另一方面也没实际收益。

ES 里同时存 child 和 parentContent

真正写库时,结构是这样的:

for (int i = 0; i < children.size(); i++) {    data.put(“content”, children.get(i));    data.put(“parentContent”, parents.get(childToParentIndex.get(i)));    data.put(“chunkType”“child”);    data.put(“vector”, embeddings.get(i));}

这里的设计很关键:

  • content
     存 child 文本,供 BM25 和向量检索使用
  • parentContent
     存 parent 文本,供命中后回填给 LLM
  • chunkType
     标记这是一条 child 记录

也就是说,ES 里每一条被检索的记录,其实都背着一份更完整的“上级上下文”。这样在召回命中时,不需要再二次查库组装,直接就能把 parentContent 取出来用。

这种设计非常适合线上路径,因为它减少了检索后的拼装成本。

别只盯着切分,文本提取质量会决定你后面能不能切得漂亮

分块讲到这里,再补一个经常被忽略的点:不是所有原始文档一开始就是干净文本

如果上游抽出来的文本已经稀烂,那后面切分策略再高级,效果也会打折。

UniversalTextExtractor 负责的就是这件事。

小文件直接抽,大文件走流式

publicStringextractText(File file) throwsIOException {if (file.length() < STREAM_THRESHOLD) {return extractTextDirect(file);    } else {StringBuilder sb = new StringBuilder();        extractTextStream(file, sb::append);return sb.toString();    }}

这个分支不复杂,但很有必要。因为不同文件大小,对内存占用和处理方式完全不一样。文档一大,直接一次性全读进来,轻则慢,重则顶内存。

OCR 和 PDF 配置不是点缀,是质量底座

UniversalTextExtractor 里用了 Apache Tika 做统一解析,同时给 OCR 和 PDF 做了专门配置:

TesseractOCRConfig ocrConfig = new TesseractOCRConfig();ocrConfig.setLanguage(“chi_sim+eng”);context.set(TesseractOCRConfig.class, ocrConfig);PDFParserConfig pdfConfig = new PDFParserConfig();pdfConfig.setSortByPosition(true);pdfConfig.setExtractInlineImages(true);pdfConfig.setEnableAutoSpace(true);pdfConfig.setSuppressDuplicateOverlappingText(true);

这些配置看着像细枝末节,其实很影响后面的 chunk 质量。

比如 PDF 如果不按位置排序,抽出来的文字顺序可能是乱的;不自动补空格,英文和数字可能粘成一团;OCR 语言没配对,中文英文混排文件就容易识别得一塌糊涂。

而分块是建立在“文本基本可读”的前提上的。你前面抽出来一锅粥,后面怎么切都不可能太优雅。

三种策略怎么选,不要迷信唯一正确答案

看到这里,基本可以把这套分块设计总结成三层选择:

策略
核心实现
适用场景
优点
代价
Token 分块
TokenChunker
短文本、简单场景
精确控长,成本低
可能破坏语义边界
递归语义分块
RecursiveTextSplitter.split()
通用文档分块
尽量沿语义边界切,块更自然
逻辑更复杂
Parent-Child 分层
splitWithParentChild()
长文本、生产级 RAG
小块检索准,大块回答全
存储结构和编排更复杂
三种分块策略对比

真要给经验结论,我会这么说:

  1. 短文本先别上重型方案,普通 Token/递归切分就够了
  2. 通用知识文档优先递归语义分块,比死切稳定得多
  3. 长文档、规章制度、手册类内容,尽量上 Parent-Child,收益非常明显

别一上来就追求“最先进方案”,先看你的文档长度、内容结构、检索目标是不是配得上它。

小结

RAG 里,分块不是准备动作,而是决定检索质量的起跑线。

这篇文章拆了三层思路:

  • TokenChunker 解决的是长度边界问题:按 Token 精确切,动态 chunk size + overlap
  • RecursiveTextSplitter 解决的是语义完整性问题:尽量沿着段落、句子这些自然边界切
  • Parent-Child 解决的是检索和回答目标冲突的问题:小块负责找,大块负责答

如果要浓缩成一句话,就是:

RAG 的分块,本质不是把文档切开,而是把“适合检索的表示”和“适合回答的上下文”同时准备好。

这件事做对了,后面的向量检索、重排、去重,才能真正建立在靠谱的输入上。做不对,后面调再多参数,也常常是在给错误的切法打补丁。

下一篇我们继续往下拆,聊聊向量检索最核心的底层问题:Embedding 到底是什么,余弦相似度到底在算什么,为什么“苹果手机”和“iPhone”在向量空间里会靠得很近。


纯手搓 AI Agent 系列 | 第 5 篇 / 共 25 篇

#纯手搓AIAgent #RAG #文档分块 #Chunking #向量检索 #AI #Java #后端开发 #知识库 #大模型 #智能问答 

     觉得有用?扫码关注「昕悦技术栈」持续输出实战干货

长按识别二维码,关注我!