Skip to content

feat!: align page_number metadata across splitters and fix drift in overlapping splits - #13006

Merged
bogdankostic merged 4 commits into
mainfrom
recursive_doc_splitter_page_number
Oct 1, 2026
Merged

bogdankostic merged 4 commits into
mainfrom
recursive_doc_splitter_page_number

Conversation

@bogdankostic

Copy link
Copy Markdown
Contributor

Related Issues

Proposed Changes:

Problem. The splitters disagreed on what page_number means:

  • RecursiveDocumentSplitter reported the page a chunk ends on, the other two the page it starts on
  • DocumentSplitter, MarkdownHeaderSplitter and EmbeddingBasedDocumentSplitter reported the previous page for a chunk opening with form feeds, although all of its text is on the next one.

Fix. One convention, defined in the new _page_numbers.py: page_number is the page of the chunk's first non-page-break character. Exception: a chunk of only page breaks is an empty page and keeps its start page — split_by="page" emits one per blank page.

RecursiveDocumentSplitter no longer keeps a running page counter; the page comes from the chunk's offset, which split_idx_start already tracks. Being stateless, this also removes the drift in #12909 — a break inside an overlapping tail was counted once per chunk it appeared in, pushing pages past the document's page count.

Docstrings, docs-website pages and an upgrade release note updated: page_number is user-visible and stored values need re-indexing.

How did you test it?

Seven new unit tests:

  • test_run_page_number_is_the_page_the_chunk_starts_on — the issue's case, asserting RecursiveDocumentSplitter matches DocumentSplitter chunk- and page-for-page
  • test_run_page_number_does_not_drift_with_overlap — the fix: count page breaks in overlapping regions once in RecursiveDocumentSplitter #12909 reproduction
  • test_add_page_number_to_metadata_for_blank_pages — split_by="page" over "Hello\f\fWorld" and "\fHello"
  • test_page_number_of_split_starting_with_page_break / ..._multi_character_page_break — leading breaks in MarkdownHeaderSplitter, the latter with page_break_character="<PAGE>"
  • test_page_number_of_secondary_splits_under_a_chunk_starting_with_a_page_break — header chunk opening with a break, then split again
  • test_create_documents_from_splits_leading_page_breaks — EmbeddingBasedDocumentSplitter, previously uncovered

Five existing assertions changed.

Notes for the reviewer

Checklist

  • I have read the contributors guidelines and the code of conduct.
  • I have updated the related issue with new insights and changes.
  • I have added unit tests and updated the docstrings.
  • I've used one of the conventional commit types for my PR title: fix:, feat:, build:, chore:, ci:, docs:, style:, refactor:, perf:, test: and added ! in case the PR includes breaking changes.
  • I have documented my code.
  • I have added a release note file, following the contributors guidelines.
  • I have run pre-commit hooks and fixed any issue.

@bogdankostic
bogdankostic requested a review from a team as a code owner September 28, 2026 15:41
@bogdankostic
bogdankostic requested review from sjrl and removed request for a team September 28, 2026 15:41
@vercel

vercel Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
haystack-docs Ignored Ignored Preview Oct 1, 2026 1:10pm UTC

Request Review

@github-actions github-actions Bot added topic:tests type:documentation Improvements on the docs labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/components/preprocessors
  _page_numbers.py 29
  document_splitter.py
  embedding_based_document_splitter.py
  markdown_header_splitter.py 295-296
  recursive_splitter.py 208, 249, 264, 299
Project Total  

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

# The page the chunk's first non-page-break character is on: breaks before the chunk, plus the
# ones it opens with. Derived from the chunk's absolute offset rather than a running counter, so
# a break repeated in an overlapping tail cannot be counted twice.
new_doc.meta["page_number"] = 1 + content.count("\f", 0, current_position) + _leading_page_breaks(chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe page_number now inherits an existing drift in split_idx_start when split_unit="word" and split_overlap > 0. _create_chunk_starting_with_overlap joins the overlap and the chunk with a " " that isn't in the source text, but current_position still advances over it. So split_idx_start moves one character further ahead with every chunk, and the chunks pass page breaks they haven't reached yet. It keeps growing whenever the chunk plus overlap fits within split_length (when it doesn't, the chunk gets trimmed and the offset stays close).

from haystack import Document
from haystack.components.preprocessors import RecursiveDocumentSplitter

# 8 pages of one 3-word sentence each: "p1a p1b p1c.\fp2a p2b p2c.\f..."
text = "\f".join(f"p{p}a p{p}b p{p}c." for p in range(1, 9))

# each chunk is one 3-word sentence; with the 1-word overlap it is exactly split_length words
splitter = RecursiveDocumentSplitter(split_length=4, split_overlap=1, split_unit="word", separators=["."])
for chunk in splitter.run(documents=[Document(content=text)])["documents"]:
    start = chunk.meta["split_idx_start"]
    first_word = chunk.content.split()[0]
    print(
        f"page_number={chunk.meta['page_number']} split_idx_start={start:3} actual_start={text.index(first_word):3} "
        f"content={chunk.content!r}"
    )

On this branch the last two chunks start on pages 6 and 7 but are reported on 7 and 8:

page_number=1 split_idx_start=  0 actual_start=  0 content='p1a p1b p1c.'
page_number=1 split_idx_start=  8 actual_start=  8 content='p1c. \x0cp2a p2b p2c.'
page_number=2 split_idx_start= 22 actual_start= 21 content='p2c. \x0cp3a p3b p3c.'
page_number=3 split_idx_start= 36 actual_start= 34 content='p3c. \x0cp4a p4b p4c.'
page_number=4 split_idx_start= 50 actual_start= 47 content='p4c. \x0cp5a p5b p5c.'
page_number=5 split_idx_start= 64 actual_start= 60 content='p5c. \x0cp6a p6b p6c.'
page_number=7 split_idx_start= 78 actual_start= 73 content='p6c. \x0cp7a p7b p7c.'
page_number=8 split_idx_start= 92 actual_start= 86 content='p7c. \x0cp8a p8b p8c.'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 47aeb37.
Word-overlap chunks are now cut out of the source text instead of being rebuilt with " " joins, so split_idx_start and page_number match the actual start in your example.
While at it, I also fixed two other places where the chunks didn't add up to the source text: the whitespace after the last sentence got dropped, and with keep_white_spaces=False sentences within a chunk were glued together.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks!

Comment thread haystack/components/preprocessors/recursive_splitter.py Outdated
Comment thread test/components/preprocessors/test_recursive_splitter.py Outdated
bogdankostic and others added 2 commits September 30, 2026 10:04
Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com>

@sjrl sjrl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good!

@bogdankostic
bogdankostic merged commit 472da27 into main Oct 1, 2026
26 checks passed
@bogdankostic
bogdankostic deleted the recursive_doc_splitter_page_number branch October 1, 2026 13:36
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.

page_number in RecursiveDocumentSplitter points to the page the chunk ends on

2 participants