三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【Bug已解决】MarkdownHeaderTextSplitter splits nested custom headers into separate chunks when strip_hea…

【Bug已解决】MarkdownHeaderTextSplitter splits nested custom headers into separate chunks when strip_hea…

【Bug已解决】MarkdownHeaderTextSplitter splits nested custom headers into separate chunks when strip_headers=False 解决方案

一、现象长什么样

用 LangChain 的MarkdownHeaderTextSplitter按 Markdown 标题切分文档,当设置strip_headers=False(即希望保留标题文本在块里),且文档里有嵌套的自定义标题(多级标题、或非标准标题写法)时,切分结果不对:

# 期望:每个块都带着它之上的完整标题层级,内容不丢失 [ {"content": "# H1\n## H2\n正文..."}, ... ] # 实际:嵌套/自定义标题被单独切成一个空块,正文被拆到别的块 [ {"content": "## H2"}, {"content": "正文..."}, ... ] # H2 与正文分离

具体表现:

  • 只在strip_headers=False时出现;strip_headers=True(标题不保留)时反而“正常”(因为标题被丢掉了,问题被掩盖)。
  • 文档里有多级嵌套标题(如#/##/###连续,或同一级出现多次)或自定义标题标记时,某些标题被切成一个“只有标题、没有正文”的孤立块,而正文去了下一个块。
  • 下游 RAG 检索时,这些“只有标题的空块”会污染索引,或正文块丢失了上下文标题。
  • 现象表明:splitter 在“保留标题”模式下,对嵌套标题的归属判断错了——标题没被正确“挂到”后续正文上。

关键特征:strip_headers=False时,嵌套/自定义标题被错误地切成独立块,与正文脱节,导致块内容结构破坏。

二、背景

MarkdownHeaderTextSplitter的作用是按 Markdown 标题层级把长文档切成块,常用于 RAG 的文档预处理:让每个块携带它所属的标题路径(比如“第一章 > 第二节 > 正文”),这样检索时块带有结构上下文。

它有两个关键行为:

  • 按标题切分:遇到一个标题,就结束当前块、开始新块。
  • strip_headersTrue时,标题文本不进入块内容(只作为元数据);False时,标题文本要保留在块内容里(便于直接阅读/拼接)。

正确的“保留标题”行为应该是:当前块要包含“从当前层级到正文”的所有标题 + 正文。例如遇到## H2后是一段正文,块应该是# H1\n## H2\n正文(H1 是更早的上级,应随 H2 一起带下来,因为它们是这块内容的上下文)。

bug 出在:splitter 在处理嵌套标题(连续的多个同级/上级标题)或自定义标题模式时,把“标题行本身”当成了一个独立的切分点,于是生成了“只有标题、没有正文”的块,而真正的正文被推到了下一个块。本质是对“标题行是否自带正文、标题之间如何合并”的判断有缺陷。

三、根因

根因是MarkdownHeaderTextSplitterstrip_headers=False模式下,把每个标题行都当成独立切分边界,没有把连续/嵌套标题与紧随的正文归并到同一个块,导致标题被切成孤立空块

  1. 标题行即切分点:splitter 遇到标题就flush当前累积内容成块。当strip_headers=False时,标题文本要进块,但如果紧接着又是一个标题(嵌套),当前块就只有“上一个标题”、没有正文,于是产出空/标题块。
  2. 未合并嵌套标题:连续的# H1/## H2应该合并成“H1+H2+正文”一块,但实现把它们逐个 flush,H2 单独成块、正文又单独成块。
  3. 自定义标题模式处理错:用户自定义了标题识别正则(比如把某些行当标题),这些自定义标题在strip_headers=False下同样被错误切分。
  4. strip_headers=True掩盖问题:标题不进块时,孤立标题块只是“空块被丢弃”,看似正常,于是 bug 只在False时暴露。

一句话:strip_headers=False时,splitter 把标题行当作独立边界 flush,没有把嵌套标题与正文归并,导致标题被切成孤立块、与正文脱节。

四、最小可运行复现

下面用 Python 模拟“标题行 flush”vs“标题+正文归并”的切分机理:

import re from typing import List, Dict def split_buggy(text: str, headers_to_split: List[str]) -> List[Dict]: """错误:每个标题行都 flush 成独立块。""" chunks = [] buf = "" for line in text.splitlines(): if re.match(r"^#{1,6} ", line): if buf.strip(): chunks.append({"content": buf.strip()}) buf = line + "\n" # 标题行另起,下一行正文又分开 else: buf += line + "\n" if buf.strip(): chunks.append({"content": buf.strip()}) return chunks def split_fixed(text: str, headers_to_split: List[str]) -> List[Dict]: """修复:标题行累积进当前块,遇新标题才 flush,标题+正文归并。""" chunks = [] buf = "" for line in text.splitlines(): if re.match(r"^#{1,6} ", line): # 标题总是归并进当前块;只有“已有正文”才先 flush 上一块 if buf.strip() and "\n" in buf.strip(): # 已有完整块才 flush,避免孤立标题块 chunks.append({"content": buf.strip()}) buf = "" buf += line + "\n" else: buf += line + "\n" if buf.strip(): chunks.append({"content": buf.strip()}) return chunks doc = "# H1\n## H2\n正文内容\n## H3\n更多正文" print("buggy:", [c["content"] for c in split_buggy(doc, ["#", "##"])]) # ['# H1', '## H2\n正文内容', '## H3', '更多正文'] <- 标题孤立 print("fixed:", [c["content"] for c in split_fixed(doc, ["#", "##"])]) # ['# H1\n## H2\n正文内容', '## H3\n更多正文'] <- 归并正确

buggy# H1切成孤立块,fixed把标题与正文正确归并——正是需要修的逻辑。

五、解决方案(第一层:最小直接修复)

最小修复是strip_headers=False时,标题行累积进当前块、仅当块已有正文时才在该标题处 flush,避免产生只含标题的孤立块

# markdown_header_splitter.py(修复片段) def split_text(self, text: str) -> List[Document]: chunks = [] buf = [] for line in text.splitlines(): if self._is_header(line): # 仅当已累积正文才 flush 上一块,避免孤立标题块 if buf and any(not self._is_header(l) for l in buf): chunks.append(self._make_doc(buf)) buf = [] buf.append(line) # 标题归并进当前块 else: buf.append(line) if buf: chunks.append(self._make_doc(buf)) return chunks

这一层让strip_headers=False下,嵌套标题与正文正确归并到同一块,不再出现孤立标题块。

六、解决方案(第二层:结构性改进)

把“Markdown 标题切分如何归并标题与正文(尤其 strip_headers=False)”收口成唯一的配置对象LangChainMdHeaderSplitPolicy,splitter 读它:

from dataclasses import dataclass from typing import Tuple @dataclass(frozen=True) class LangChainMdHeaderSplitPolicy: """MarkdownHeaderTextSplitter 切分归并的单一事实来源。""" # strip_headers=False 时,标题必须归并进块,不产生孤立标题块 merge_headers_into_chunk: bool = True # 仅当块已含正文时才在该标题处 flush flush_only_if_has_body: bool = True # 嵌套标题(连续多级)合并到同一块,不被逐个切分 merge_nested_headers: bool = True # 自定义标题模式同样适用上述规则 apply_to_custom_header_patterns: bool = True # 代码评审卡点 forbidden_patterns: Tuple[str, ...] = ( "flush on every header line", "header-only chunk allowed when strip_headers=False", ) def should_flush(self, buf: list) -> bool: if not self.merge_headers_into_chunk: return True # 只有块里已有非标题(正文)行,才在该标题处 flush return self.flush_only_if_has_body and any( not self._is_header(l) for l in buf) def describe(self) -> str: return "strip_headers=False 时标题归并进块、不产生孤立标题块" POLICY = LangChainMdHeaderSplitPolicy() def plan_md_flush(buf: list, policy: LangChainMdHeaderSplitPolicy = POLICY) -> bool: return policy.should_flush(buf)

所有 Markdown 标题切分都读POLICY,归并语义被固化,嵌套/自定义标题在strip_headers=False下不再被切成孤立块。

七、解决方案(第三层:断言 / CI 守护)

把“标题归并、不产生孤立块、嵌套合并”做成断言。下面用 pytest 守护:

import pytest def test_no_isolated_header_chunk(policy): # 块若只含标题行,不应被 flush 成孤立块 assert policy.merge_headers_into_chunk is True assert "header-only chunk allowed when strip_headers=False" \ in policy.forbidden_patterns def test_flush_only_with_body(policy): assert policy.flush_only_if_has_body is True # 只有标题的 buf 不应 flush assert policy.should_flush(["# H1", "## H2"]) is False # 含正文的 buf 应在新标题处 flush assert policy.should_flush(["# H1", "正文"]) is True def test_merge_nested(policy): assert policy.merge_nested_headers is True def test_custom_patterns(policy): assert policy.apply_to_custom_header_patterns is True def test_forbid_flush_every_header(policy): assert "flush on every header line" in policy.forbidden_patterns

这五组断言锁住:(1) 无孤立标题块;(2) 仅含正文才 flush;(3) 嵌套合并;(4) 自定义模式适用;(5) 禁止逐标题 flush。CI 跑通即代表strip_headers=False切分结构正确。

八、排查清单

遇到MarkdownHeaderTextSplitterstrip_headers=False下块结构错:

  1. 看是否孤立标题块:块里只有标题没正文 → 标题被独立切分(本题)。
  2. 确认 strip_headers=FalseTrue时问题被掩盖。
  3. 查 flush 逻辑:是不是每个标题行都触发 flush,没归并正文。
  4. 改归并:标题累积进块,仅块含正文时在该标题处 flush。
  5. 统一到LangChainMdHeaderSplitPolicy:CI 断言禁止孤立标题块。
  6. 覆盖嵌套/自定义标题:多级标题和自定义模式同样归并。
  7. 端到端:切分后每块都含完整标题层级 + 正文,无空块。

九、小结

MarkdownHeaderTextSplitter splits nested custom headers into separate chunks when strip_headers=False的根因是:MarkdownHeaderTextSplitterstrip_headers=False(保留标题文本)模式下,把每个标题行都当作独立切分边界 flush 成块,没有把连续的嵌套标题与紧随的正文归并到同一块,于是产生“只有标题、没有正文”的孤立块,正文被推到下一个块,块结构被破坏;strip_headers=True时标题不进块,问题被掩盖。

最小修复是让标题行累积进当前块、仅当块已含正文时才在该标题处 flush,避免孤立标题块;结构性改进是用唯一的LangChainMdHeaderSplitPolicy固化归并语义;CI 用五组断言守护“标题归并、无孤立块、嵌套合并”。记住:保留标题的切分器,标题是块的上下文前缀而不是独立内容,必须和正文归并,否则 RAG 索引会被空块污染。

← 返回列表