Skip to content

Commit c9966a9

Browse files
fix: populate split_idx_start metadata in EmbeddingBasedDocumentSplitter (#11987)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent 4327c6d commit c9966a9

3 files changed

Lines changed: 80 additions & 2 deletions

File tree

haystack/components/preprocessors/embedding_based_document_splitter.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def run(self, documents: list[Document]) -> dict[str, list[Document]]:
178178
- `documents`: List of documents with the split texts. Each document includes:
179179
- A metadata field `source_id` to track the original document.
180180
- A metadata field `split_id` to track the split number.
181+
- A metadata field `split_idx_start` with the character offset of the chunk in the original document.
181182
- A metadata field `page_number` to track the original page number.
182183
- All other metadata copied from the original document.
183184
@@ -218,6 +219,7 @@ async def run_async(self, documents: list[Document]) -> dict[str, list[Document]
218219
- `documents`: List of documents with the split texts. Each document includes:
219220
- A metadata field `source_id` to track the original document.
220221
- A metadata field `split_id` to track the split number.
222+
- A metadata field `split_idx_start` with the character offset of the chunk in the original document.
221223
- A metadata field `page_number` to track the original page number.
222224
- All other metadata copied from the original document.
223225
@@ -489,12 +491,16 @@ def _create_documents_from_splits(splits: list[str], original_doc: Document) ->
489491
metadata = deepcopy(original_doc.meta)
490492
metadata["source_id"] = original_doc.id
491493

492-
# Calculate page numbers for each split
494+
# Track page number and character offset across splits.
495+
# Chunks are contiguous substrings of the original text (all joins are pure string concatenation),
496+
# so the character offset is simply accumulated by adding the length of each chunk.
493497
current_page = 1
498+
current_char_pos = 0
494499

495500
for i, split_text in enumerate(splits):
496501
split_meta = deepcopy(metadata)
497502
split_meta["split_id"] = i
503+
split_meta["split_idx_start"] = current_char_pos
498504

499505
# Calculate page number for this split
500506
# Count page breaks in the split itself
@@ -506,7 +512,8 @@ def _create_documents_from_splits(splits: list[str], original_doc: Document) ->
506512
doc = Document(content=split_text, meta=split_meta)
507513
documents.append(doc)
508514

509-
# Update page counter for next split
515+
# Advance position and page counter for the next split
516+
current_char_pos += len(split_text)
510517
current_page += page_breaks_in_split
511518

512519
return documents
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
fixes:
3+
- |
4+
``EmbeddingBasedDocumentSplitter`` now sets ``split_idx_start`` in the metadata of every
5+
output chunk, consistent with ``DocumentSplitter`` and ``RecursiveDocumentSplitter``.
6+
Previously the field was absent, causing a ``KeyError`` in any downstream code that
7+
reads character offsets from splitter output (e.g. highlighting, provenance tracking,
8+
overlap recovery).

test/components/preprocessors/test_embedding_based_document_splitter.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,69 @@ def test_create_documents_from_splits_with_consecutive_page_breaks(self):
266266
# Should be page 2, not 4, because consecutive page breaks at the end are adjusted
267267
assert documents[1].meta["page_number"] == 2
268268

269+
def test_create_documents_from_splits_split_idx_start(self):
270+
"""_create_documents_from_splits must set split_idx_start to the character offset of each chunk."""
271+
mock_embedder = Mock()
272+
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
273+
274+
text = "First chunk. Second chunk. Third chunk."
275+
splits = ["First chunk. ", "Second chunk. ", "Third chunk."]
276+
original_doc = Document(content=text)
277+
278+
documents = splitter._create_documents_from_splits(splits, original_doc)
279+
280+
assert len(documents) == 3
281+
assert documents[0].meta["split_idx_start"] == 0
282+
assert documents[1].meta["split_idx_start"] == len("First chunk. ")
283+
assert documents[2].meta["split_idx_start"] == len("First chunk. ") + len("Second chunk. ")
284+
# Cross-check: split_idx_start correctly points into the original text
285+
for doc in documents:
286+
start = doc.meta["split_idx_start"]
287+
assert text[start : start + len(doc.content)] == doc.content
288+
289+
def test_run_split_idx_start(self):
290+
"""run() must produce chunks with correct split_idx_start character offsets."""
291+
text = "The sky is blue. The grass is green. The sun is yellow."
292+
293+
# Mock embedder that returns two distinct embedding clusters so the splitter
294+
# finds at least one split point
295+
def mock_run(documents):
296+
from dataclasses import replace as dc_replace
297+
298+
embeddings = []
299+
for i, doc in enumerate(documents):
300+
# Two distinct embedding directions — triggers a split
301+
if i < len(documents) // 2:
302+
embeddings.append(dc_replace(doc, embedding=[1.0, 0.0, 0.0]))
303+
else:
304+
embeddings.append(dc_replace(doc, embedding=[0.0, 1.0, 0.0]))
305+
return {"documents": embeddings}
306+
307+
mock_embedder = Mock()
308+
mock_embedder.run = Mock(side_effect=mock_run)
309+
310+
splitter = EmbeddingBasedDocumentSplitter(
311+
document_embedder=mock_embedder, sentences_per_group=1, percentile=0.5, min_length=0, max_length=10000
312+
)
313+
splitter.warm_up()
314+
315+
result = splitter.run(documents=[Document(content=text)])
316+
chunks = result["documents"]
317+
318+
# All chunks must have split_idx_start
319+
for chunk in chunks:
320+
assert "split_idx_start" in chunk.meta
321+
322+
# split_idx_start must point to the correct position in the original text
323+
for chunk in chunks:
324+
start = chunk.meta["split_idx_start"]
325+
assert text[start : start + len(chunk.content)] == chunk.content
326+
327+
# Offsets must be strictly increasing (chunks are non-empty and contiguous)
328+
starts = [c.meta["split_idx_start"] for c in chunks]
329+
assert starts == sorted(starts)
330+
assert starts[0] == 0
331+
269332
def test_calculate_embeddings(self):
270333
mock_embedder = Mock()
271334

0 commit comments

Comments
 (0)