ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

【智能体开发】用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:int200)-list[str]:按固定字符数切分文本。return[text[i:ichunk_size]foriinrange(0,len(text),chunk_size)]如果希望切分点更自然一些可以优先在段落边界双换行处断开但整体仍然受字符数上限约束。这里我们实现一个“段落感知的固定长度切分”把文档按空行拆成段落再把段落逐个累加进当前块直到加入下一个段落会超过限制为止。deffixed_length_chunk_by_paragraph(text:str,max_chars:int300)-list[str]:段落感知的固定长度切分不拆散段落但总长受 max_chars 限制。paragraphs[p.strip()forpintext.split(\n\n)ifp.strip()]chunks[]currentforparainparagraphs:ifcurrentandlen(current)len(para)2max_chars:chunks.append(current)currentparaelse:currentcurrent\n\nparaifcurrentelseparaifcurrent:chunks.append(current)returnchunks这个函数的行为是只要一个段落自己能塞进max_chars它就不会被拆开。但如果某个段落本身就超过了max_chars——比如一段很长的代码或一个巨大的表格——它仍然会被原样放进一个“超长块”里。这是有意为之与其把代码从中间截断不如让一个块超限后续检索阶段再处理。实现二按标题切分按标题切分的核心逻辑是逐行扫描维护一个“标题栈”header stack。遇到新标题时弹出栈中所有层级大于等于当前标题的条目然后把当前标题压入栈。栈中剩余的标题路径就是当前内容所属的完整上下文。importredefheading_aware_chunk(text:str)-list[dict]:按 Markdown 标题切分返回带标题路径的块列表。linestext.split(\n)chunks[]header_stack[]# 元素为 (level, title)current_lines[]defflush():ifcurrent_lines:title_path .join(tfor_,tinheader_stack)ifheader_stackelsechunks.append({title_path:title_path,content:\n.join(current_lines).strip(),level:header_stack[-1][0]ifheader_stackelse0,})current_lines.clear()header_rere.compile(r^(#{1,6})\s(.)$)in_code_blockFalseforlineinlines:# 跟踪代码块围栏避免把代码中的 # 误判为标题strippedline.strip()ifstripped.startswith()orstripped.startswith(~~~):in_code_blocknotin_code_block current_lines.append(line)continueifin_code_block:current_lines.append(line)continuemheader_re.match(line)ifm:flush()levellen(m.group(1))titlem.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,encodingutf-8)asf:docf.read()print(*50)print(固定长度切分max_chars300)print(*50)fixed_chunksfixed_length_chunk_by_paragraph(doc,max_chars300)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_chunksheading_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_chars300。这是“宁可超长不拆语义单元”的取舍。按标题切分这个超长段落会被原样放进“设备配对”对应的块中因为它的边界是标题而不是长度。所以“设备配对”块会显著长于其他块。判定方法检查两个结果中是否存在长度异常突出的块。如果固定长度切分把超长段落拆成了多个块说明实现中用了纯字符切分而非段落感知切分或者按标题切分把超长段落丢掉了说明flush()有条件遗漏则不合格。这个场景揭示了一个重要事实按标题切分不控制块的长度上限。如果某个标题下内容极多块会非常大如果某个标题下只有一句话块会非常小。这正是“按标题切分”和“固定长度切分”互补的地方。何时该用哪种一个可操作的判断标准基于上面的比较可以得出一个简单的判断规则如果文档有清晰的标题层级且每个标题下的内容量适中既不是一行字也不是上千字按标题切分是更好的起点。它保留了结构信息检索时可以通过title_path进行上下文增强——用户搜“设备离线”即使正文里没有“设备”二字标题路径也能帮助召回。如果文档没有标题结构纯段落文本或者标题层级混乱标题下内容极不均衡固定长度切分更可控。至少你能保证块的长度在预期范围内不会因为某个标题下塞了整章内容而产生超大块。一个实用的折中方案先用按标题切分然后检查每个块的长度。如果某个块超过阈值比如 500 字符对块内内容再用段落感知的固定长度切分做二次拆分同时把原来的title_path作为元数据保留下来。这样既保留了结构上下文又控制了单块长度。常见故障排查按标题切分把代码块里的#当成了标题。检查in_code_block的切换逻辑是否覆盖了所有围栏变体、~~~以及嵌套情况。上面提供的实现只在遇到行首的围栏标记时切换状态对大多数 Markdown 是够用的。固定长度切分产生了大量微小块。如果文档中有很多短段落比如列表项之间有空行段落感知切分会把每个列表项当成独立段落来累加。此时可以把max_chars设大一些或者在切分前先把连续的单行列表项合并成一个段落。按标题切分后第一个块没有标题路径。如果文档开头在第一个标题之前有前言文字比如文档的引言heading_stack为空title_path就是空字符串。这是正确的行为——那些前言确实不属于任何标题节。可以在输出中给它们一个占位路径如[文档开头]便于下游处理。验证状态本文的代码在 Python 3.10 环境下运行通过使用标准库re无第三方依赖。运行结果确认了以下行为按标题切分正确识别了 7 个标题并输出了对应的title_path固定长度切分在max_chars300时未拆散任何段落边界测试中空内容标题被跳过超长段落两种方法均未丢失内容。未在线核验的部分本文未引用任何需要版本核验的第三方框架 API实现逻辑独立于 LangChain 等工具。如果你在实际项目中结合 LangChain 的MarkdownHeaderTextSplitter使用请以你安装的 langchain-text-splitters 版本的官方文档为准。
返回列表