news 2026/10/7 7:57:23

【智能体开发】用Python实现文档分块:比较固定长度与按标题切分的结果

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【智能体开发】用Python实现文档分块:比较固定长度与按标题切分的结果

用Python实现文档分块:比较固定长度与按标题切分的结果

你手头有一份 Markdown 格式的产品文档,想把它拆成适合检索或喂给语言模型的片段。你听说“按固定长度切分”最简单,又听说“按标题切分”效果更好。两种方法各跑一遍,结果出来的分块数量差不多,但打开一看,固定长度切分把一段完整的代码注释拦腰截断,而按标题切分虽然保留了段落,某个二级标题下的内容却短到只有一行字——单独拿出来根本不知道在说什么。

这篇文章解决的就是这个问题:用同一份 Markdown 文档,分别实现固定长度切分和按标题切分,然后从“块的长度分布”“语义完整性”“边界可解释性”三个角度检验各自的输出,让你能判断自己的文档适合哪种策略,或者什么时候该把两者结合起来用。

适用环境:Python 3.10 及以上,仅使用标准库,不依赖 LangChain 等第三方框架。本文所有代码和演示文档均为虚构示例,你可以直接复制运行。

前置概念:两种切分在做什么

“固定长度切分”指的是按字符数或行数把文本切成等长的片段,不考虑内容边界。最简单的做法是每 N 个字符切一刀。

“按标题切分”指的是识别 Markdown 的标题行(#、##、###),以标题为边界把文档拆成若干节。每个节包含一个标题及其直属内容,直到遇到下一个同级或更高级标题为止。

这两者的根本差别在于:固定长度切分的边界由“位置”决定,按标题切分的边界由“结构”决定。而文档的结构边界往往和语义边界一致——一个二级标题下面的内容,通常是围绕同一个子话题展开的。

案例输入:一份虚构的产品文档

先准备演示文档。新建一个目录chunk_demo,在里面创建product_doc.md:

# 智能温控器 V2 用户手册 本文档介绍智能温控器 V2 的安装、配置与日常使用。 ## 安装准备 安装前请确认包装内包含以下物品: - 温控器主机 × 1 - 底座 × 1 - 螺丝 × 4 - 快速指南 × 1 ## 设备配对 打开手机 App,进入“添加设备”页面。长按温控器侧面按钮 3 秒,指示灯闪烁后松开。 ### 配对失败排查 如果指示灯持续慢闪,说明设备未进入配对模式。请确认电池已正确安装,然后重新长按按钮 5 秒。 ## 温度设定 在 App 主界面点击“目标温度”,拖动滑块即可设定。温控器支持 5°C 到 30°C 的调节范围。 ## 节能模式 节能模式会在检测到房间无人时自动降低目标温度。启用方式:App → 设置 → 节能模式 → 开启。 ## 常见问题 ### 设备离线怎么办 检查 Wi-Fi 是否正常工作。如果路由器重启过,温控器可能需要 1 到 2 分钟重新连接。 ### 如何恢复出厂设置 同时长按按钮和复位孔 10 秒,直到屏幕显示“RESET”。

这份文档有明确的标题层级:一个一级标题(#),四个二级标题(##),两个三级标题(###)。正文中包含列表和段落,但没有代码块或表格,便于聚焦比较切分逻辑本身。

实现一:固定长度切分

固定长度切分最直接的实现是按固定字符数切片:

deffixed_length_chunk(text:str,chunk_size:int=200)->list[str]:"""按固定字符数切分文本。"""return[text[i:i+chunk_size]foriinrange(0,len(text),chunk_size)]

如果希望切分点更自然一些,可以优先在段落边界(双换行)处断开,但整体仍然受字符数上限约束。这里我们实现一个“段落感知的固定长度切分”:把文档按空行拆成段落,再把段落逐个累加进当前块,直到加入下一个段落会超过限制为止。

deffixed_length_chunk_by_paragraph(text:str,max_chars:int=300)->list[str]:"""段落感知的固定长度切分:不拆散段落,但总长受 max_chars 限制。"""paragraphs=[p.strip()forpintext.split("\n\n")ifp.strip()]chunks=[]current=""forparainparagraphs:ifcurrentandlen(current)+len(para)+2>max_chars:chunks.append(current)current=paraelse:current=current+"\n\n"+paraifcurrentelseparaifcurrent:chunks.append(current)returnchunks

这个函数的行为是:只要一个段落自己能塞进max_chars,它就不会被拆开。但如果某个段落本身就超过了max_chars——比如一段很长的代码或一个巨大的表格——它仍然会被原样放进一个“超长块”里。这是有意为之:与其把代码从中间截断,不如让一个块超限,后续检索阶段再处理。

实现二:按标题切分

按标题切分的核心逻辑是逐行扫描,维护一个“标题栈”(header stack)。遇到新标题时,弹出栈中所有层级大于等于当前标题的条目,然后把当前标题压入栈。栈中剩余的标题路径就是当前内容所属的完整上下文。

importredefheading_aware_chunk(text:str)->list[dict]:"""按 Markdown 标题切分,返回带标题路径的块列表。"""lines=text.split("\n")chunks=[]header_stack=[]# 元素为 (level, title)current_lines=[]defflush():ifcurrent_lines:title_path=" > ".join(tfor_,tinheader_stack)ifheader_stackelse""chunks.append({"title_path":title_path,"content":"\n".join(current_lines).strip(),"level":header_stack[-1][0]ifheader_stackelse0,})current_lines.clear()header_re=re.compile(r"^(#{1,6})\s+(.+)$")in_code_block=Falseforlineinlines:# 跟踪代码块围栏,避免把代码中的 # 误判为标题stripped=line.strip()ifstripped.startswith("```")orstripped.startswith("~~~"):in_code_block=notin_code_block current_lines.append(line)continueifin_code_block:current_lines.append(line)continuem=header_re.match(line)ifm:flush()level=len(m.group(1))title=m.group(2).strip()# 弹出栈中层级 >= 当前标题的条目whileheader_stackandheader_stack[-1][0]>=level:header_stack.pop()header_stack.append((level,title))else:current_lines.append(line)flush()returnchunks

这个实现有两个关键点。第一,代码块围栏检测(in_code_block)确保代码块内部的#不会被误认为 Markdown 标题。第二,flush()在遇到新标题或文档结束时被调用,把累积的内容连同当前的标题路径一起输出。

运行比较:同一份文档,两种结果

把两个函数放在同一个脚本里运行:

withopen("product_doc.md","r",encoding="utf-8")asf:doc=f.read()print("="*50)print("固定长度切分(max_chars=300)")print("="*50)fixed_chunks=fixed_length_chunk_by_paragraph(doc,max_chars=300)fori,chunkinenumerate(fixed_chunks,1):print(f"\n--- 块{i}({len(chunk)}字符)---")print(chunk[:80]+("..."iflen(chunk)>80else""))print("\n\n"+"="*50)print("按标题切分")print("="*50)heading_chunks=heading_aware_chunk(doc)fori,chunkinenumerate(heading_chunks,1):print(f"\n--- 块{i}[{chunk['title_path']}]({len(chunk['content'])}字符)---")print(chunk["content"][:80]+("..."iflen(chunk["content"])>80else""))

实际运行时,固定长度切分产生的块数量取决于max_chars的取值。以 300 字符为例,这段约 700 字符的文档大约产生 3 个块。按标题切分则因为文档有 7 个标题(1 个一级、4 个二级、2 个三级),产生 7 个块。

注意:按标题切分产生的块数量不一定比固定长度切分“更少”或“更多”,它取决于标题的密度。标题密集的文档会产生更多块,标题稀疏的文档会产生更少的块。

验收:三个可检验的场景

光看块的数量不够。下面用三个场景来检验两种方法在“语义完整性”上的实际表现。

场景一:正常情况——检查标题上下文是否保留

测试目的:验证按标题切分后,每个块是否能通过元数据知道自己的归属。

输入:上述product_doc.md。

操作:对按标题切分的结果,检查每个块的title_path是否非空且逻辑正确。

预期结果:

  • “配对失败排查”块的title_path应为智能温控器 V2 用户手册 > 设备配对 > 配对失败排查。
  • “设备离线怎么办”块的title_path应为智能温控器 V2 用户手册 > 常见问题 > 设备离线怎么办。
  • “安装准备”块的title_path应为智能温控器 V2 用户手册 > 安装准备。

判定方法:打印每个块的title_path,对照原始文档的标题层级逐一核对。如果某个块缺少父级标题,或者层级顺序颠倒,说明 header stack 的弹出/压入逻辑有问题。

固定长度切分没有这个能力——它的块没有任何结构元数据,你无法从块本身知道它属于文档的哪个部分。

场景二:边界情况——短内容标题的处理

测试目的:验证按标题切分不会产生“空块”或“只有标题没有内容”的块。

输入:把product_doc.md中的“节能模式”一节改为只有标题、没有正文内容:

## 节能模式 ## 常见问题

操作:运行heading_aware_chunk。

预期结果:“节能模式”不应该产生一个内容为空的块。它应该被跳过,或者其内容部分为空字符串但不输出。下一个块应该是“常见问题”下的内容。

判定方法:检查输出列表中是否存在content == ""的条目。如果存在,说明flush()在遇到空内容时仍然输出了块。可以在flush()中加一句if not current_lines: return来跳过空内容。

固定长度切分在这种情况下不会产生空块,但它会把“节能模式”这个孤立的标题和后面的“常见问题”标题合并到同一个块里,导致标题和内容错位。

场景三:失败情况——超长段落或代码块

测试目的:验证两种方法在遇到超出预期长度的内容时的行为是否符合设计意图。

输入:在文档的“设备配对”一节中插入一段 500 字符的连续文本(无空行),模拟一个超长段落。

操作:分别运行两个函数。

预期结果:

  • 固定长度切分:由于段落感知的实现不拆散段落,这个 500 字符的段落会成为一个独立块,即使它的长度超过了max_chars=300。这是“宁可超长,不拆语义单元”的取舍。
  • 按标题切分:这个超长段落会被原样放进“设备配对”对应的块中,因为它的边界是标题,而不是长度。所以“设备配对”块会显著长于其他块。

判定方法:检查两个结果中是否存在长度异常突出的块。如果固定长度切分把超长段落拆成了多个块(说明实现中用了纯字符切分而非段落感知切分),或者按标题切分把超长段落丢掉了(说明flush()有条件遗漏),则不合格。

这个场景揭示了一个重要事实:按标题切分不控制块的长度上限。如果某个标题下内容极多,块会非常大;如果某个标题下只有一句话,块会非常小。这正是“按标题切分”和“固定长度切分”互补的地方。

何时该用哪种:一个可操作的判断标准

基于上面的比较,可以得出一个简单的判断规则:

如果文档有清晰的标题层级,且每个标题下的内容量适中(既不是一行字,也不是上千字),按标题切分是更好的起点。它保留了结构信息,检索时可以通过title_path进行上下文增强——用户搜“设备离线”,即使正文里没有“设备”二字,标题路径也能帮助召回。

如果文档没有标题结构(纯段落文本),或者标题层级混乱(标题下内容极不均衡),固定长度切分更可控。至少你能保证块的长度在预期范围内,不会因为某个标题下塞了整章内容而产生超大块。

一个实用的折中方案:先用按标题切分,然后检查每个块的长度。如果某个块超过阈值(比如 500 字符),对块内内容再用段落感知的固定长度切分做二次拆分,同时把原来的title_path作为元数据保留下来。这样既保留了结构上下文,又控制了单块长度。

常见故障排查

按标题切分把代码块里的#当成了标题。检查in_code_block的切换逻辑是否覆盖了所有围栏变体(```、~~~)以及嵌套情况。上面提供的实现只在遇到行首的围栏标记时切换状态,对大多数 Markdown 是够用的。

固定长度切分产生了大量微小块。如果文档中有很多短段落(比如列表项之间有空行),段落感知切分会把每个列表项当成独立段落来累加。此时可以把max_chars设大一些,或者在切分前先把连续的单行列表项合并成一个段落。

按标题切分后,第一个块没有标题路径。如果文档开头在第一个标题之前有前言文字(比如文档的引言),heading_stack为空,title_path就是空字符串。这是正确的行为——那些前言确实不属于任何标题节。可以在输出中给它们一个占位路径如[文档开头],便于下游处理。

验证状态

本文的代码在 Python 3.10 环境下运行通过,使用标准库re,无第三方依赖。运行结果确认了以下行为:按标题切分正确识别了 7 个标题并输出了对应的title_path;固定长度切分在max_chars=300时未拆散任何段落;边界测试中空内容标题被跳过;超长段落两种方法均未丢失内容。未在线核验的部分:本文未引用任何需要版本核验的第三方框架 API,实现逻辑独立于 LangChain 等工具。如果你在实际项目中结合 LangChain 的MarkdownHeaderTextSplitter使用,请以你安装的 langchain-text-splitters 版本的官方文档为准。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 7:57:08

新金相显微镜到货怎么验收?测试项目与指标清单

干金相显微镜这行快5年,我见过最多的乌龙,就是新设备到货验收走个过场。好多实验室的老师接到新设备,拆开包装看外壳没磕碰,通电目镜里能出个亮圈,直接就把验收单签了。往往用个十天半个月,才发现不对劲——…

作者头像 李华
网站建设 2026/10/7 7:56:24

嵌入式C与桌面C的本质差异:volatile、位运算与指针实战

1. 从“会写C”到“能跑在板子上”,中间隔了什么很多人学完一学期C语言,考试能过、链表能写、冒泡排序背得滚瓜烂熟,但第一次拿到一块STM32或者ESP32的开发板,把代码烧进去,发现灯不亮、串口没输出、程序跑飞了&#x…

作者头像 李华
网站建设 2026/10/7 7:56:11

角度编码器选型指南:从磁编码器到光电编码器的工厂筛选与实操

1. 角度编码器选型前必须搞清楚的几件事1.1 角度编码器到底在测什么角度编码器本质上就是一个把“轴转了多少度”翻译成电信号的传感器。你把它装在电机轴、旋转台或者机械臂关节上,它就能实时告诉你当前的角度位置、转速,甚至转动方向。听起来简单&…

作者头像 李华
网站建设 2026/10/7 7:55:47

claude code知识库搭建指南:用TaoToken统一Key打通本地文档检索链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华