引子
一个食品安全国家标准文档,多的几百页,包含前言、范围、术语、规范性引用、正文条款、附录、表格。
用户问:"二氧化硫在果脯中的最大残留量是多少?"
知识库需要从这份文档中精准找到那个数字。
把整篇文档塞进 prompt — 很大概率会超过上下文长度限制; 在全文里做关键词搜索 — 可能会返回几十页无关内容。
因此通常需要对文档进行分块(Chunk):把文档切成一张张"卡片",每张卡片包含一个完整的知识点,然后对这些卡片建立索引,检索时只取出最相关的几张。
这个思路简单直接。但"怎么切"是个问题。这篇文章记录的就是我在处理Chunk的过程,以及每次演进背后的思考。
为什么要分块
问题:整篇文档塞不进 prompt
最朴素的想法是:通过关键词检索,获取相关文档,把整篇标准文档直接丢给 LLM,让它找答案。但是这种做法的问题很明显。
其一是,一份标准文档少则6-7页,多则几十页,尤其是涉及多份文档时,Token 消耗将远超预期。即使模型能装下,大量无关内容会稀释相关信息的权重,回答质量下降。这不仅是技术限制,更是成本与质量的考量。
其二是,"二氧化硫"可能出现在十几个条款中,但只有一个是"果脯中的最大残留量"。关键词搜索无法理解语义,返回的结果噪声太大。
分块是两者的折中:把长文档切成大小适中的片段,每个片段包含一个完整的知识点,然后对这些片段做语义检索。检索时只取出最相关的 5-10 个片段,喂给 LLM 生成答案。
这个思路的核心假设是:一个分块 = 一个完整的知识点。如果分块边界恰好是知识点边界,检索质量就会好。
最朴素的方案:fixed-size 切分
最简单的分块方式:假设每 512 个 token 切一刀。
# 按固定长度切分文档tokens = encode(document)for i in range(0, len(tokens), chunk_size):chunks.append(tokens[i : i + chunk_size])
实现简单,但问题严重。切在句子中间,上一句讲"二氧化硫",下一句讲"铅",两个半截拼在一起,语义完全断裂。检索到这个 chunk,LLM 也很难从中提取有用信息。
fixed-size 切分假设文档是"均匀的文本流",但真实文档不是。而真实的标准文档是由标题、段落、表格和附录等逻辑单元构成的。这些结构本身就是天然的分块边界。
11 种类型:当时的假设与后来的发现
基于这一认知,我们放弃了盲目的等长切分,转而采用“结构感知”的分块策略。我们根据文档的结构特征,将内容划分为 11 种特定的类型:
title | |
foreword | |
scope | |
normative_reference | |
term | |
clause | |
table | |
appendix | |
ocr_text | |
front_matter | |
unknown |
设计意图是:不同类型对应不同的检索策略。搜"术语定义"时只搜 term 类型,搜"条款"时只搜 clause 类型。类型越细,检索越精准。
实际使用中,用户搜索时几乎不会按类型过滤。问"二氧化硫的限量是多少",答案可能在 clause 中,也可能在 table 中,也可能在 scope 中。按类型过滤反而会漏掉正确答案。
更根本的问题是:这些类型的边界在实际文档中是模糊的。一份标准的"范围"章节到底算 scope 还是 clause?"术语和定义"中的每个术语算 term 还是 clause?不同解析器的输出不一致,导致同一篇文档多次分块可能产生不同的类型标注。
11 种类型变成了维护负担,而不是检索优势。这种过度设计让我们意识到:我们陷入了‘分类陷阱’。实际上,我们不需要复杂的标签,只需要准确捕捉文档的‘天然骨架’。
heading 分块:一个章节 = 一个 chunk
翻看任何一份食品安全国家标准(GB 标准),它的结构高度统一:
前言1 范围2 规范性引用文件3 术语和定义4 技术要求4.1 原料要求4.2 感官要求4.3 理化指标4.4 污染物限量4.5 微生物限量4.6 食品添加剂4.7 营养指标5 检验方法6 标志标签附录A(规范性)xxx附录B(资料性)xxx
每个章节标题(H2/H3)就是一个天然的分块边界。章节划分高度规范——"范围"讲适用对象,"术语和定义"讲概念,"技术要求"讲指标,"检验方法"讲操作。每个章节内部高度内聚(讲的是同一件事),章节之间低耦合(改一个章节不影响另一个)。
用编程的术语说,heading 分块的本质是按文档的自然模块边界切分。每个 chunk 就像一个函数——职责单一、边界清晰、可以独立理解。这恰好是好的软件设计追求的"高内聚低耦合"。
所以对于标准文档,heading 分块不是"最聪明"的方案,但是"最稳"的方案——它利用了文档本身的结构,而不是试图用算法去理解语义。
分割后的每个 section 生成一个 chunk:
{”seq”: 2,”chunkKey”: ”section-1”,”type”: ”h_section”,”sectionNo”: ”4.4”,”sectionTitle”: ”污染物限量”,”content”: ”4.4 污染物限量\n产品中污染物限量应符合表2的规定...\n表2 污染物限量指标...”,”tokenCount”: 1200}
front_matter:文档级摘要
除了 h_section 章节块,每个文档还会生成一个 front_matter chunk。它的内容不只是标准编号、标题、发布日期这些静态元数据——更重要的是,它承载了文档级的语义摘要。
这个设计借鉴了 skill 文件的结构。每个 skill 开头都有一段 frontmatter + 简介:
---name: brainstormingdescription: ”在任何创造性工作之前必须使用...”---# 将想法转化为设计通过自然对话将想法转化为完整的设计和规范...
读完这段 introduction,你就知道这个 skill 是干什么的、什么时候该用它。不需要读完整个文件。
front_matter chunk 对文档做了同样的事。原始 frontmatter 是结构化 YAML:
standard_code: ”GB 2760-2024”title: ”食品安全国家标准 食品添加剂使用标准”publish_date: ”2024-02-29”scope: ”本标准规定了食品添加剂的使用原则...”
这本身信息量有限。但通过 LLM 对整篇文档进行总结后,frontmatter 可以扩展为包含文档核心内容的摘要:
standard_code: ”GB 1886.40-2025”title: ”食品安全国家标准 食品添加剂 L-苹果酸”summary: ”本标准规定了食品添加剂L-苹果酸的要求、试验方法、检验规则及标志、包装、运输、贮存和保质期。适用于以富马酸或富马酸盐为原料,经酶工程法制得的食品添加剂L-苹果酸;或以淀粉质或糖类为原料,经发酵法制得的食品添加剂L-苹果酸。”keywords:- GB 1886.40-2025- 食品添加剂- L-苹果酸- 食品安全国家标准
这个摘要被索引后,用户问"食品添加剂使用标准讲了什么"时,即使没有命中任何具体章节 chunk,front_matter 也能被检索到,把文档级的问题导向正确的文档。
front_matter 的价值在于:它弥补了 heading 分块的检索盲区。heading 分块擅长回答具体问题("二氧化硫限量是多少"),但对概括性问题("这份标准讲了什么")检索效果差——因为答案分散在多个章节中,没有哪个单独的 chunk 能完整回答。front_matter 作为文档级摘要,让这类问题有对应的 chunk 可以命中,提高了检索召回率——就像 skill 的 introduction 让你不用读完全文就知道它是什么。
分块后的 chunk 样例:
chunk 0 (front_matter):内容 = 标准元信息 + LLM 生成的文档摘要用途 = 文档级检索、概括性问答chunk 1 (h_section):sectionNo = ”4.4”sectionTitle = ”污染物限量”内容 = 该章节的完整文本用途 = 具体条款检索、精确问答chunk 2 (h_section):sectionNo = ”5”sectionTitle = ”检验方法”内容 = 该章节的完整文本用途 = 检验方法检索
最终方案:h_section+front matter
# 类型归一化:所有非 front_matter 统一为 h_sectionfunction normalize_chunk_type(type):if type == ”front_matter”:return ”front_matter”else:return ”h_section”
只保留 front_matter(前置元数据)和 h_section(heading 章节块)。所有历史类型(title、foreword、scope、clause、table...)全部标记为 @Deprecated,新分块不再产生这些类型。DB 中的历史数据通过 normalizeChunkType() 归一化为 h_section。
搜索默认也只返回这两种类型:
# 默认搜索类型:只返回 front_matter 和 h_sectionfunction default_searchable_chunk_types():return [”front_matter”, ”h_section”]
非标准文档怎么办:语义分块(研究中)
heading 分块有一个隐含假设:文档有清晰的章节结构。标准文档满足这个假设,但不是所有文档都满足。
比如一篇食品安全风险评估报告,可能是一个连续的长段落,没有明确的 H2/H3 标题;一份检测报告,结构可能是"样品信息 → 检测结果 → 结论",但每个部分内部又是大段自由文本。
对于这类文档,heading 分块要么切不出来(没有 heading),要么切得不合理(heading 太粗或太细)。
目前的思路是语义分块:用 embedding 相似度检测语义边界——相邻句子的向量距离突然变大,说明话题切换了,那里就是分块边界。或者用 LLM 直接判断"从哪里开始讲另一件事了"。
这块还在研究中。当前的做法是:能用 heading 分块的先用 heading,不能用的暂时作为整块处理,等语义分块方案成熟后再切换。
夜雨聆风