Skip to content

Commit dbf972d

Browse files
committed
fix: set source_id on RecursiveDocumentSplitter chunks
RecursiveDocumentSplitter wrote only `parent_id` on the chunks it produced. Every other splitter in the library writes `source_id`, and that is the key the rest of Haystack reads to tie a chunk back to the document it came from. The practical effect is that its output does not compose with SentenceWindowRetriever, which reads `source_id` by default and raises when it is missing. The same pipeline works with DocumentSplitter and fails with RecursiveDocumentSplitter: DocumentSplitter -> OK, 3 context documents RecursiveDocumentSplitter -> ValueError: The retrieved documents must have 'source_id' in their metadata. Chunks now carry `source_id` as well as `parent_id`, which keeps its previous value so callers already reading it are unaffected. While here, the class docstring example claimed a meta key named `original_id`, which the code has never written. Its output block also did not match what the example produces: it was generated with a different `split_length` than the one shown, and reported `_split_overlap` as `[]` where a run with `split_overlap=0` gives `None`. The example now reproduces the output it claims.
1 parent 757b3e2 commit dbf972d

3 files changed

Lines changed: 54 additions & 5 deletions

File tree

haystack/components/preprocessors/recursive_splitter.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class RecursiveDocumentSplitter:
3838
from haystack import Document
3939
from haystack.components.preprocessors import RecursiveDocumentSplitter
4040
41-
chunker = RecursiveDocumentSplitter(split_length=260, split_overlap=0, separators=["\\n\\n", "\\n", ".", " "])
41+
chunker = RecursiveDocumentSplitter(split_length=15, split_overlap=0, separators=["\\n\\n", "\\n", ".", " "])
4242
text = ('''Artificial intelligence (AI) - Introduction
4343
4444
AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.
@@ -47,10 +47,11 @@ class RecursiveDocumentSplitter:
4747
doc_chunks = chunker.run([doc])
4848
print(doc_chunks["documents"])
4949
# [
50-
# Document(id=..., content: 'Artificial intelligence (AI) - Introduction\\n\\n', meta: {'original_id': '...', 'split_id': 0, 'split_idx_start': 0, '_split_overlap': []})
51-
# Document(id=..., content: 'AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.\\n', meta: {'original_id': '...', 'split_id': 1, 'split_idx_start': 45, '_split_overlap': []})
52-
# Document(id=..., content: 'AI technology is widely used throughout industry, government, and science.', meta: {'original_id': '...', 'split_id': 2, 'split_idx_start': 142, '_split_overlap': []})
53-
# Document(id=..., content: ' Some high-profile applications include advanced web search engines; recommendation systems; interac...', meta: {'original_id': '...', 'split_id': 3, 'split_idx_start': 216, '_split_overlap': []})
50+
# Document(id=..., content: 'Artificial intelligence (AI) - Introduction\\n\\n', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 0, 'split_idx_start': 0, '_split_overlap': None, 'page_number': 1})
51+
# Document(id=..., content: 'AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.\\n', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 1, 'split_idx_start': 45, '_split_overlap': None, 'page_number': 1})
52+
# Document(id=..., content: 'AI technology is widely used throughout industry, government, and science.', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 2, 'split_idx_start': 142, '_split_overlap': None, 'page_number': 1})
53+
# Document(id=..., content: ' Some high-profile applications include advanced web search engines; recommendation systems; interac...', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 3, 'split_idx_start': 216, '_split_overlap': None, 'page_number': 1})
54+
# Document(id=..., content: 'vehicles; generative and creative tools; and superhuman play and analysis in strategy games.', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 4, 'split_idx_start': 350, '_split_overlap': None, 'page_number': 1})
5455
# ]
5556
```
5657
""" # noqa: E501
@@ -425,6 +426,10 @@ def _run_one(self, doc: Document) -> list[Document]:
425426

426427
for split_nr, chunk in enumerate(chunks):
427428
meta = deepcopy(doc.meta)
429+
# `source_id` is the key every other splitter writes, and the one components
430+
# like SentenceWindowRetriever look for by default. `parent_id` carries the
431+
# same value and is kept for callers already reading it.
432+
meta["source_id"] = doc.id
428433
meta["parent_id"] = doc.id
429434
meta["split_id"] = split_nr
430435
meta["split_idx_start"] = current_position
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``RecursiveDocumentSplitter`` not setting the ``source_id`` meta field on the chunks
5+
it produces. It wrote only ``parent_id``, while every other splitter in the library
6+
(``DocumentSplitter``, ``CSVDocumentSplitter``, ``EmbeddingBasedDocumentSplitter``,
7+
``HierarchicalDocumentSplitter``, ``MarkdownHeaderSplitter`` and ``PythonCodeSplitter``)
8+
writes ``source_id``. Components that follow that convention therefore rejected its output:
9+
``SentenceWindowRetriever`` reads ``source_id`` by default and raises when it is absent, so
10+
it failed with "The retrieved documents must have 'source_id' in their metadata." on a
11+
pipeline that worked with any other splitter. Chunks now carry ``source_id`` as well as
12+
``parent_id``, which keeps its previous value for callers already reading it.

test/components/preprocessors/test_recursive_splitter.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from haystack import Document, Pipeline
1212
from haystack.components.preprocessors.recursive_splitter import RecursiveDocumentSplitter
1313
from haystack.components.preprocessors.sentence_tokenizer import SentenceSplitter
14+
from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever
15+
from haystack.document_stores.in_memory import InMemoryDocumentStore
1416

1517

1618
def test_get_custom_sentence_tokenizer_success():
@@ -1055,6 +1057,36 @@ def test_recursive_splitter_generates_unique_ids_and_correct_meta():
10551057
assert chunk.meta["split_id"] == idx
10561058

10571059

1060+
def test_recursive_splitter_sets_source_id():
1061+
"""`source_id` is the key the rest of the library uses to tie a chunk back to its document."""
1062+
source_doc = Document(content="Haystack is awesome. " * 5)
1063+
1064+
chunks = RecursiveDocumentSplitter(split_length=3).run([source_doc])["documents"]
1065+
1066+
assert chunks
1067+
for chunk in chunks:
1068+
assert chunk.meta["source_id"] == source_doc.id
1069+
# parent_id carried the same value before source_id existed, so it stays.
1070+
assert chunk.meta["parent_id"] == source_doc.id
1071+
1072+
1073+
def test_recursive_splitter_output_works_with_sentence_window_retriever():
1074+
"""SentenceWindowRetriever looks up `source_id` by default and raises when it is
1075+
absent, so chunks from this splitter used to be unusable with it."""
1076+
source_doc = Document(content="Haystack is awesome. " * 10)
1077+
chunks = RecursiveDocumentSplitter(split_length=3).run([source_doc])["documents"]
1078+
assert len(chunks) > 2
1079+
1080+
store = InMemoryDocumentStore()
1081+
store.write_documents(chunks)
1082+
1083+
result = SentenceWindowRetriever(document_store=store, window_size=1).run(retrieved_documents=[chunks[1]])
1084+
1085+
# The middle chunk plus one neighbour on each side.
1086+
assert len(result["context_documents"]) == 3
1087+
assert result["context_windows"]
1088+
1089+
10581090
def test_warm_up_is_idempotent_sentence(monkeypatch):
10591091
splitter = RecursiveDocumentSplitter(separators=["sentence", " "])
10601092

0 commit comments

Comments
 (0)