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_headersFalse 解决方案一、现象长什么样用 LangChain 的MarkdownHeaderTextSplitter按 Markdown 标题切分文档当设置strip_headersFalse即希望保留标题文本在块里且文档里有嵌套的自定义标题多级标题、或非标准标题写法时切分结果不对# 期望每个块都带着它之上的完整标题层级内容不丢失 [ {content: # H1\n## H2\n正文...}, ... ] # 实际嵌套/自定义标题被单独切成一个空块正文被拆到别的块 [ {content: ## H2}, {content: 正文...}, ... ] # H2 与正文分离具体表现只在strip_headersFalse时出现strip_headersTrue标题不保留时反而“正常”因为标题被丢掉了问题被掩盖。文档里有多级嵌套标题如#/##/###连续或同一级出现多次或自定义标题标记时某些标题被切成一个“只有标题、没有正文”的孤立块而正文去了下一个块。下游 RAG 检索时这些“只有标题的空块”会污染索引或正文块丢失了上下文标题。现象表明splitter 在“保留标题”模式下对嵌套标题的归属判断错了——标题没被正确“挂到”后续正文上。关键特征strip_headersFalse时嵌套/自定义标题被错误地切成独立块与正文脱节导致块内容结构破坏。二、背景MarkdownHeaderTextSplitter的作用是按 Markdown 标题层级把长文档切成块常用于 RAG 的文档预处理让每个块携带它所属的标题路径比如“第一章 第二节 正文”这样检索时块带有结构上下文。它有两个关键行为按标题切分遇到一个标题就结束当前块、开始新块。strip_headersTrue时标题文本不进入块内容只作为元数据False时标题文本要保留在块内容里便于直接阅读/拼接。正确的“保留标题”行为应该是当前块要包含“从当前层级到正文”的所有标题 正文。例如遇到## H2后是一段正文块应该是# H1\n## H2\n正文H1 是更早的上级应随 H2 一起带下来因为它们是这块内容的上下文。bug 出在splitter 在处理嵌套标题连续的多个同级/上级标题或自定义标题模式时把“标题行本身”当成了一个独立的切分点于是生成了“只有标题、没有正文”的块而真正的正文被推到了下一个块。本质是对“标题行是否自带正文、标题之间如何合并”的判断有缺陷。三、根因根因是MarkdownHeaderTextSplitter在strip_headersFalse模式下把每个标题行都当成独立切分边界没有把连续/嵌套标题与紧随的正文归并到同一个块导致标题被切成孤立空块标题行即切分点splitter 遇到标题就flush当前累积内容成块。当strip_headersFalse时标题文本要进块但如果紧接着又是一个标题嵌套当前块就只有“上一个标题”、没有正文于是产出空/标题块。未合并嵌套标题连续的# H1/## H2应该合并成“H1H2正文”一块但实现把它们逐个 flushH2 单独成块、正文又单独成块。自定义标题模式处理错用户自定义了标题识别正则比如把某些行当标题这些自定义标题在strip_headersFalse下同样被错误切分。strip_headersTrue掩盖问题标题不进块时孤立标题块只是“空块被丢弃”看似正常于是 bug 只在False时暴露。一句话strip_headersFalse时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_headersFalse时标题行累积进当前块、仅当块已有正文时才在该标题处 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_headersFalse下嵌套标题与正文正确归并到同一块不再出现孤立标题块。六、解决方案第二层结构性改进把“Markdown 标题切分如何归并标题与正文尤其 strip_headersFalse”收口成唯一的配置对象LangChainMdHeaderSplitPolicysplitter 读它from dataclasses import dataclass from typing import Tuple dataclass(frozenTrue) class LangChainMdHeaderSplitPolicy: MarkdownHeaderTextSplitter 切分归并的单一事实来源。 # strip_headersFalse 时标题必须归并进块不产生孤立标题块 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_headersFalse, ) 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_headersFalse 时标题归并进块、不产生孤立标题块 POLICY LangChainMdHeaderSplitPolicy() def plan_md_flush(buf: list, policy: LangChainMdHeaderSplitPolicy POLICY) - bool: return policy.should_flush(buf)所有 Markdown 标题切分都读POLICY归并语义被固化嵌套/自定义标题在strip_headersFalse下不再被切成孤立块。七、解决方案第三层断言 / 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_headersFalse \ 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_headersFalse切分结构正确。八、排查清单遇到MarkdownHeaderTextSplitter在strip_headersFalse下块结构错看是否孤立标题块块里只有标题没正文 → 标题被独立切分本题。确认 strip_headersFalseTrue时问题被掩盖。查 flush 逻辑是不是每个标题行都触发 flush没归并正文。改归并标题累积进块仅块含正文时在该标题处 flush。统一到LangChainMdHeaderSplitPolicyCI 断言禁止孤立标题块。覆盖嵌套/自定义标题多级标题和自定义模式同样归并。端到端切分后每块都含完整标题层级 正文无空块。九、小结MarkdownHeaderTextSplitter splits nested custom headers into separate chunks when strip_headersFalse的根因是MarkdownHeaderTextSplitter在strip_headersFalse保留标题文本模式下把每个标题行都当作独立切分边界 flush 成块没有把连续的嵌套标题与紧随的正文归并到同一块于是产生“只有标题、没有正文”的孤立块正文被推到下一个块块结构被破坏strip_headersTrue时标题不进块问题被掩盖。最小修复是让标题行累积进当前块、仅当块已含正文时才在该标题处 flush避免孤立标题块结构性改进是用唯一的LangChainMdHeaderSplitPolicy固化归并语义CI 用五组断言守护“标题归并、无孤立块、嵌套合并”。记住保留标题的切分器标题是块的上下文前缀而不是独立内容必须和正文归并否则 RAG 索引会被空块污染。
返回列表