Skip to content

Commit b53e614

Browse files
lntutorclaudejulian-risch
authored
fix: MarkdownHeaderSplitter silently drops a trailing header with no body text (#12064)
Co-authored-by: Loi Nguyen <1948922+lntutor@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julian Risch <julian.risch@deepset.ai>
1 parent e6dd8ca commit b53e614

3 files changed

Lines changed: 77 additions & 11 deletions

File tree

haystack/components/preprocessors/markdown_header_splitter.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,15 @@ def _split_text_by_markdown_headers(self, text: str, doc_id: str) -> list[dict]:
147147
# process headers and build chunks
148148
chunks: list[dict] = []
149149
header_stack: list[str | None] = [None] * 6
150-
pending_headers: list[str] = [] # store empty headers to prepend to next content
150+
pending_start: int | None = None # start offset in text of the first buffered empty header
151+
pending_header_text: str | None = None # text of the last buffered empty header
152+
pending_level = 0 # level of the last buffered empty header
151153
has_content = False # flag to track if any header has content
152154

153155
for i, match in enumerate(matches):
154156
# extract header info
155157
header_prefix = match.group(1)
156-
header_text = match.group(2)
158+
header_text = match.group(2).strip() # keep surrounding whitespace out of the metadata
157159
level = len(header_prefix)
158160

159161
# get content
@@ -169,8 +171,12 @@ def _split_text_by_markdown_headers(self, text: str, doc_id: str) -> list[dict]:
169171
# skip splits w/o content
170172
if not content.strip(): # this strip is needed to avoid counting whitespace as content
171173
if self.keep_headers:
172-
header_line = f"{header_prefix} {header_text}"
173-
pending_headers.append(header_line)
174+
# buffer this header so it gets prepended to the next contentful chunk; only the
175+
# offset of the first header in the run is needed since the chunk is sliced from text
176+
if pending_start is None:
177+
pending_start = match.start()
178+
pending_header_text = header_text
179+
pending_level = level
174180
continue
175181

176182
has_content = True # at least one header has content
@@ -183,16 +189,13 @@ def _split_text_by_markdown_headers(self, text: str, doc_id: str) -> list[dict]:
183189
)
184190

185191
if self.keep_headers:
186-
header_line = f"{header_prefix} {header_text}"
187-
# add pending & current header to content
188-
chunk_content = ""
189-
if pending_headers:
190-
chunk_content += "\n".join(pending_headers) + "\n"
191-
chunk_content += f"{header_line}{content}"
192+
# Slice from the first buffered empty header (if any) so the buffered header lines and
193+
# the whitespace between them are preserved byte-exactly.
194+
chunk_content = text[(pending_start if pending_start is not None else match.start()) : end]
192195
chunks.append(
193196
{"content": chunk_content, "meta": {"header": header_text, "parent_headers": parent_headers}}
194197
)
195-
pending_headers = [] # reset pending headers
198+
pending_start = None # reset buffered headers
196199
else:
197200
chunks.append({"content": content, "meta": {"header": header_text, "parent_headers": parent_headers}})
198201

@@ -203,6 +206,18 @@ def _split_text_by_markdown_headers(self, text: str, doc_id: str) -> list[dict]:
203206
)
204207
return [{"content": text, "meta": {}}]
205208

209+
# Flush any trailing headers that had no body text of their own. A header at the very end of the
210+
# document (or a run of such headers) never gets a following content chunk to be prepended to, so
211+
# without this it would be silently dropped. Slicing the original text keeps reconstruction exact.
212+
if pending_start is not None:
213+
parent_headers = [h for h in header_stack[: pending_level - 1] if h is not None]
214+
chunks.append(
215+
{
216+
"content": text[pending_start:],
217+
"meta": {"header": pending_header_text, "parent_headers": parent_headers},
218+
}
219+
)
220+
206221
return chunks
207222

208223
def _apply_secondary_splitting(self, documents: list[Document]) -> list[Document]:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``MarkdownHeaderSplitter`` silently dropping a trailing header that has
5+
no body text. With ``keep_headers=True`` (the default), a header at the end of
6+
the document whose only content is whitespace was buffered to prepend to the
7+
next chunk, but with no following chunk it was never emitted, so the split
8+
documents no longer reconstructed the original text. Such trailing headers
9+
are now emitted as a final chunk.
10+
- |
11+
Fixed ``MarkdownHeaderSplitter`` collapsing blank lines that follow a header
12+
with no body text. With ``keep_headers=True``, such headers were re-joined with
13+
a single newline when prepended to the next chunk, so the split documents did
14+
not reconstruct the original text. Chunk content is now sliced from the
15+
original text and is byte-exact.
16+
- |
17+
Fixed ``MarkdownHeaderSplitter`` including surrounding whitespace in the
18+
``header`` and ``parent_headers`` metadata fields. The header text is now
19+
stripped; chunk content still keeps the header line's original whitespace.

test/components/preprocessors/test_markdown_header_splitter.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,3 +838,35 @@ def test_page_break_handling_with_multiple_headers(sample_text_with_page_breaks)
838838
# reconstruct original
839839
reconstructed_text = "".join(doc.content for doc in split_docs)
840840
assert reconstructed_text == sample_text_with_page_breaks
841+
842+
843+
def test_trailing_header_without_content_is_not_dropped():
844+
text = "# Header 1\nContent 1.\n# Header 2\n"
845+
docs = MarkdownHeaderSplitter().run(documents=[Document(content=text)])["documents"]
846+
assert "".join(doc.content for doc in docs) == text
847+
assert docs[-1].content == "# Header 2\n"
848+
assert docs[-1].meta["header"] == "Header 2"
849+
assert docs[-1].meta["parent_headers"] == []
850+
851+
852+
def test_middle_header_without_content_preserves_blank_lines():
853+
# buffered headers are sliced from the original text, so blank lines between a header with no
854+
# body and the next contentful header are preserved instead of collapsed to a single newline
855+
text = "# Header 1\n\n\n# Header 2\nContent.\n"
856+
docs = MarkdownHeaderSplitter().run(documents=[Document(content=text)])["documents"]
857+
assert "".join(doc.content for doc in docs) == text
858+
859+
860+
def test_header_metadata_is_stripped_but_content_is_byte_exact():
861+
text = "# Header 1 \nContent.\n"
862+
docs = MarkdownHeaderSplitter().run(documents=[Document(content=text)])["documents"]
863+
assert docs[0].meta["header"] == "Header 1"
864+
# the chunk content keeps the header line's original trailing whitespace
865+
assert "".join(doc.content for doc in docs) == text
866+
867+
868+
def test_whitespace_only_trailing_header_has_empty_header_metadata():
869+
text = "# Header 1\nContent.\n# \n"
870+
docs = MarkdownHeaderSplitter().run(documents=[Document(content=text)])["documents"]
871+
assert "".join(doc.content for doc in docs) == text
872+
assert docs[-1].meta["header"] == ""

0 commit comments

Comments
 (0)