Skip to content

fix: MarkdownHeaderSplitter silently drops a trailing header with no body text - #12064

Merged
julian-risch merged 3 commits into
deepset-ai:mainfrom
lntutor:fix/markdown-splitter-trailing-header
Jul 29, 2026
Merged

fix: MarkdownHeaderSplitter silently drops a trailing header with no body text#12064
julian-risch merged 3 commits into
deepset-ai:mainfrom
lntutor:fix/markdown-splitter-trailing-header

Conversation

@lntutor

@lntutor lntutor commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What's broken

With keep_headers=True (the default), MarkdownHeaderSplitter buffers a header whose only content is whitespace into pending_headers and prepends it to the next content chunk. But a header (or run of headers) at the end of the document has no next chunk, so the buffer is never flushed and the header text is dropped:

text = "# Header 1\nContent 1.\n# Header 2\n"
docs = MarkdownHeaderSplitter().run(documents=[Document(content=text)])["documents"]
"".join(d.content for d in docs)   # '# Header 1\nContent 1.\n'  -- '# Header 2' is gone

This breaks the reconstruction invariant the existing tests assert (test_basic_split, test_keep_headers_preserves_parent_headers_for_first_child both check "".join(doc.content) == original), and it's inconsistent: a middle empty header is preserved (prepended to the next chunk), a trailing one is dropped.

Fix

Track the start offset / text / level of buffered trailing headers and, before returning, flush them as a final chunk sliced from the original text (so byte-level reconstruction and downstream page counting stay exact) — mirroring how a middle empty header is already preserved.

Distinct from #11920 (content dropped before an embedded header/code-fence).

Tests

Adds test_trailing_header_without_content_is_not_dropped. Full test/components/preprocessors/test_markdown_header_splitter.py passes (35). Release note included.

With keep_headers=True (the default), a header whose only content is
whitespace is buffered into pending_headers to be prepended to the next
content chunk. A header (or run of headers) at the very end of the
document has no following chunk, so the buffer was never flushed and the
header text was dropped -- breaking the reconstruction invariant that
the split contents rejoin to the original, and inconsistent with a
middle empty header, which is preserved.

Flush any trailing buffered headers as a final chunk, sliced from the
original text so reconstruction stays exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6RtoHuxrDqTUo9Mw9h4Cv
@lntutor
lntutor requested a review from a team as a code owner July 19, 2026 13:13
@lntutor
lntutor requested review from julian-risch and removed request for a team July 19, 2026 13:13
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

@lntutor is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant

CLAassistant commented Jul 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@HaystackBot

Copy link
Copy Markdown
Contributor

Hi @lntutor, thanks a lot for your contribution! 🙏

We noticed that the Contributor License Agreement (CLA) check (license/cla) hasn't passed yet, so we've temporarily moved this PR to draft and paused the review assignment.

To get your PR reviewed, please sign the CLA via the link in the license/cla check below (or in the CLA bot comment). As soon as the check turns green, this PR will automatically be marked ready for review again and a reviewer will be re-assigned.

@HaystackBot
HaystackBot removed the request for review from julian-risch July 19, 2026 14:40
@HaystackBot HaystackBot added the cla-pending PR is in draft until the contributor signs the CLA label Jul 19, 2026
@HaystackBot
HaystackBot marked this pull request as draft July 19, 2026 14:40
@HaystackBot

Copy link
Copy Markdown
Contributor

Hi @lntutor, just a friendly reminder: this PR is still in draft because the Contributor License Agreement (CLA) hasn't been signed yet. We'd love to review your contribution! Please sign the CLA via the link in the license/cla check, and this PR will automatically be marked ready for review.

@lntutor
lntutor marked this pull request as ready for review July 26, 2026 10:02
@HaystackBot
HaystackBot requested a review from julian-risch July 26, 2026 10:22
@HaystackBot HaystackBot removed the cla-pending PR is in draft until the contributor signs the CLA label Jul 26, 2026
@HaystackBot

Copy link
Copy Markdown
Contributor

Thanks for signing the CLA, @lntutor! 🎉 This PR is now ready for review again and the reviewer has been re-assigned.

…eader metadata

Follow-up to the trailing-header fix in this PR, addressing two coupled review
findings.

The trailing-header flush already sliced the original text, but the pre-existing
path for a *middle* header with no body still rebuilt content by joining buffered
header lines with "\n". That normalized whitespace, so blank lines between such a
header and the next contentful one were collapsed and reconstruction stayed
lossy:

    "# H1\n\n\n# H2\nContent.\n"  ->  "# H1\n# H2\nContent.\n"

Both paths now slice from the original text, so the two agree on fidelity and
keep_headers=True reconstruction is byte-exact in every buffered-header case.
Because content is no longer rebuilt from header_prefix/header_text, the
pending_headers list is only ever read as a boolean -- "pending_start is not
None" already carries that signal -- so it and both header_line constructions
are removed. Net less state in the loop than before.

That in turn makes it safe to strip the captured header text, which the regex
^(#{1,6}) (.+)$ had always let leak into metadata ("# Header 1   " gave
header == "Header 1   ", and a trailing "#   " gave header == "  "). Stripping
now only affects header / parent_headers; chunk content keeps the header line's
original whitespace. Done before this change it would have silently mutated
emitted content.

Adds three regression tests, each failing before this commit. Also applies the
release-note double-backtick convention that pre-commit enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@julian-risch julian-risch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lntutor Thanks for opening this PR! I made some small changes and the PR is ready to be merged now:

  • The middle-empty-header path still rebuilt content by joining header lines with "\n", which collapsed blank lines. Both paths slice the original text now
  • Stripped whitespaces from the captured header text
  • Added regression tests and adjusted release notes

@github-actions github-actions Bot added the type:documentation Improvements on the docs label Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/components/preprocessors
  markdown_header_splitter.py
Project Total  

This report was generated by python-coverage-comment-action

@julian-risch
julian-risch merged commit b53e614 into deepset-ai:main Jul 29, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

topic:tests type:documentation Improvements on the docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants