Home Projects Blog Resume Contact 中文
Back to list
2026年7月5日 1,662 words 4 min read

03-结构化 Chunking:让 chunk 保留标题、段落和步骤边界

上一篇讲文档入库和 metadata。那一步解决的是“知识进入系统之前先有身份”:文档有 docid,版本有 documentversionid,chunk 有 chunkid,后续检索、引用、删除和回滚才有东西可以追踪。

#Chunking#NexaRAG#语义边界#RAG

03-结构化 Chunking:让 chunk 保留标题、段落和步骤边界

上一篇讲文档入库和 metadata。那一步解决的是“知识进入系统之前先有身份”:文档有 doc_id,版本有 document_version_id,chunk 有 chunk_id,后续检索、引用、删除和回滚才有东西可以追踪。

有身份之后,还要让证据真正好用。

RAG 真正召回和回答时,模型看到的是 chunk。chunk 切得太碎时,标题和正文会分离,步骤会被拆断,QA 结构会被打散,即使 metadata 很完整,检索质量也会波动。尤其是客服知识库里常见的参数表、故障排查步骤、售后政策、问答段落,本来就依赖上下文结构。只按固定长度切文本,很容易把语义边界切坏。

这篇的核心判断是:

chunk 是 RAG 证据链里的最小证据单元。 它应该尽量保留标题、段落、列表、步骤和 QA 边界。

固定长度切分的问题

最简单的 chunking 是按字符数或 token 数切。这个方式的优点是稳定、便宜、容易实现,但它缺少对文档结构的理解。

比如一篇故障排查文档可能长这样:

## 无法充电

可能原因:
1. 充电器功率不匹配
2. 数据线损坏
3. 手机接口进灰

处理步骤:
1. 更换原装充电器
2. 清理接口
3. 重启设备后再次测试

如果只按长度切,可能会出现几种问题:

  • 标题在前一个 chunk,步骤在后一个 chunk。
  • “可能原因”和“处理步骤”被混在一起。
  • 列表只保留一半。
  • chunk 里缺少 title_path,后面引用时看不出它来自哪个章节。
  • 评测时 chunk_id 会漂移,稍微改一点切分参数,Golden Set 就对不上。

所以 NexaRAG 这一阶段的重点,是把 chunking 从“字符切分”升级成结构感知切分

当前代码入口

结构化 chunking 的主入口在 rag/_chunking/builder.py

def build_structured_chunks(
    source_doc: SourceDocument,
    chunking_config: ChunkingConfig | None = None,
) -> ChunkingResult:
    chunking_config = chunking_config or ChunkingConfig.from_settings()
    blocks = parse_structured_blocks(source_doc)
    ...

这个函数会先调用 parse_structured_blocks(source_doc)。也就是说,NexaRAG 先把文档解析成结构化 block,再把 block 组合成 chunk。

相关代码主要分在这几个文件里:

  • rag/_chunking/parser.py:从文档文本解析 heading、弱标题和普通段落。
  • rag/_chunking/classifiers.py:判断 block 是 paragraph、qa、list 还是 steps。
  • rag/_chunking/blocks.py:定义 StructuredBlockChunkingConfigChunkingResult
  • rag/_chunking/splitter.py:处理 block 文本拼接和超长 block 切分。
  • rag/_chunking/builder.py:把 block 组合成最终 KnowledgeChunk

这几个文件合起来,才是当前正文应该讲的 chunking 主路径。旧文件名只作为历史背景,正文围绕当前主路径展开。

第一步:把文档解析成 block

parse_structured_blocks() 会按行扫描文档。它识别两类标题:

第一类是标准 Markdown 标题:

def parse_markdown_heading(line: str) -> tuple[int, str] | None:
    match = re.match(r"^(#{1,6})\s+(.+)$", line)

第二类是弱标题,比如:

一、售后政策
1. 故障排查
【核心参数】
注意事项:

对应逻辑在 parse_weak_heading()

patterns = [
    r"^[一二三四五六七八九十]+[、..]\s*(.+)$",
    r"^\d+[、..]\s*(.+)$",
    r"^(.+)$",
    r"^(.+[::])$",
]

这一步很实际。很多中文知识库通常并非严格按 Markdown 写,尤其是客服文档、产品说明、售后条款,经常用“1.”、“一、”、“【】”、“冒号标题”来表达结构。如果只识别 #,大量章节信息会丢掉。

解析时还会维护一个 headings 字典,用来构造当前 block 的 title_path。这意味着后续 chunk 同时知道自己来自哪篇文档,以及自己在文档中的章节路径。

第二步:给 block 分类

有了 block 以后,还要判断它是什么类型。rag/_chunking/classifiers.py 里有一个很小但很关键的函数:

def classify_block(text: str, title_path: list[str]) -> str:
    if looks_like_qa(text):
        return "qa"
    if lines and all(is_list_line(line) for line in lines):
        if any(is_numbered_line(line) for line in lines) and any(
            key in joined for key in ["step", "steps", "troubleshoot", "故障", "排查", "步骤", "解决方法"]
        ):
            return "steps"
        return "list"
    return "paragraph"

这里先用规则把常见结构识别出来:

  • qa:问答段落。
  • list:普通列表。
  • steps:故障排查、操作步骤。
  • paragraph:普通段落。

这对 RAG 很有帮助。因为不同 block 类型在检索和展示时的含义不一样。步骤类内容适合整体保留,QA 类内容适合问答匹配,列表类内容如果被拆断,答案就容易漏项。

第三步:按标题路径和长度组合 chunk

build_structured_chunks() 组合 chunk 时,有两个关键条件。

第一,如果遇到 heading,只刷新当前 group,让 heading 本身进入后续 chunk 的 title_path

if block.block_type == "heading":
    flush_group()
    continue

标题的价值主要在于成为后续正文 chunk 的 title_path

第二,如果下一个 block 和当前 group 的 title_path 不同,或者组合后超过最大长度,就刷新 group:

if group and (
    block.title_path != group[-1].title_path
    or len(next_text) > chunking_config.max_chunk_chars
):
    flush_group()

这就避免了一个常见问题:不同章节的内容被硬塞进同一个 chunk。对于 RAG 来说,这比单纯控制长度更重要。一个 chunk 最好有相对单一的语义归属,这样检索命中以后,模型看到的证据会更清楚。

单个 block 本身太长时,就用 split_long_text() 拆开,并把 split_reason 标成 block_too_large。这让系统在追求结构完整性的同时守住长度预算。

chunk_id 和 chunk_strategy_version

最终 chunk 在 build_chunk_from_blocks() 里生成:

chunk_id = f"{source_doc.doc_id}::chunk::{chunk_index:04d}"

这个 ID 看起来朴素,但对评测和引用很重要。Golden Set 里经常会写 expected_chunk_ids。当 chunk_id 每次入库都随机变化时,评测稳定性会受影响。NexaRAG 当前用 doc_id + chunk_index 生成稳定 ID,在同一份文档和同一套切分策略下,chunk 可以被追踪。

同时,chunk 还会带上 chunk_strategy_version

STRUCTURED_CHUNK_STRATEGY = "structured_markdown_v1"

这也是一个小但重要的字段。后面如果切分策略升级,比如从 structured_markdown_v1structured_markdown_v2,评测报告和 metadata 里能看出来这批 chunk 是哪一代策略生成的。

chunk metadata 里保留了什么

最终 KnowledgeChunk 会带上这些结构信息:

title_path
block_type
block_types
heading_level
section_index
block_start_index
block_end_index
chunk_char_count
split_reason
chunk_strategy_version

这些字段会继续进入 rag/_metadata/builder.pybuild_chunk_metadata(),再写入 Chroma 和 local fallback。

它们的作用可以分成几类:

字段作用
title_path让证据知道自己来自哪个章节
block_type / block_types区分段落、列表、步骤、QA
section_index帮助定位文档中的章节顺序
block_start_index / block_end_index记录 chunk 覆盖哪些 block
split_reason解释切分原因
chunk_strategy_version支持评测和策略迭代

后面进入 ContextBuilder 时,title_path 会被格式化到 evidence block 里。也就是说,结构化 chunking 的收益会从入库阶段一路传到最终 prompt 和引用展示。

怎么验收这一步

结构化 chunking 适合配套测试来验收,至少要覆盖几类情况:

  • Markdown 标题能进入 title_path
  • 弱标题能被识别。
  • QA 段落能被标成 qa
  • 故障排查步骤能被标成 steps
  • 超长 block 会被拆分,并留下 split_reason
  • chunk_strategy_version 会进入 chunk metadata。

NexaRAG 里对应测试主要在:

  • tests/test_chunking.py
  • tests/test_structured_ingestion.py

这些测试的价值在于先把当前支持的结构边界锁住。后面再加 PDF、Word、Excel 或更复杂表格时,Markdown/TXT 链路可以继续保持稳定。

小结

结构化 chunking 这一篇解决的是证据粒度的问题。

上一篇给文档和 chunk 建立身份,这一篇让 chunk 尽量保留语义边界。NexaRAG 当前的实现先把文档解析成 StructuredBlock,再按 title_path 和长度预算组合成 KnowledgeChunk,并把 block_typetitle_pathsection_indexchunk_strategy_version 等信息写入 metadata。

这一步做完以后,证据不仅有身份,还有结构。下一篇就可以继续看:这些结构化证据怎样通过 Query Rewrite、BM25、向量检索、RRF 和 filter 进入候选池。

参考材料:

  • NexaRAG rag/_chunking/builder.py
  • NexaRAG rag/_chunking/parser.py
  • NexaRAG rag/_chunking/classifiers.py
  • NexaRAG rag/_chunking/blocks.py
  • NexaRAG rag/_chunking/splitter.py
  • NexaRAG rag/_schemas/models.py
  • NexaRAG tests/test_chunking.py
  • NexaRAG tests/test_structured_ingestion.py
  • 原 RAG 系列 03-Chunking复盘-切分策略语义完整性和overlap取舍.md