Repository: deepset-ai/haystack

Issue:
`DocumentSplitter` + `DocumentLengthRouter` do not work well for single-page non-textual PDF documents
While writing docs for `DocumentLengthRouter`, I found an issue:
since the `DocumentSplitter` skips empty Documents, trying to then extract text using `LLMDocumentContentExtractor` is not effective.

**This only happens for single-page non-textual PDFs**, since multiple page ones are non-empty (`!=""`).

### To reproduce

```python
from haystack import Pipeline
from haystack.components.converters.pypdf import PyPDFToDocument
from haystack.components.joiners import DocumentJoiner
from haystack.components.preprocessors.document_splitter import DocumentSplitter
from haystack.components.writers.document_writer import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore

from haystack_experimental.components.extractors.llm_document_content_extractor import LLMDocumentContentExtractor
from haystack_experimental.components.generators.chat.openai import OpenAIChatGenerator
from haystack_experimental.components.routers.document_length_router import DocumentLengthRouter


document_store = InMemoryDocumentStore()

pdf_converter = PyPDFToDocument(store_full_path=True)
pdf_splitter = DocumentSplitter(split_by="page", split_length=1)
doc_length_router = DocumentLengthRouter(threshold=10)
content_extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-mini"))
final_doc_joiner = DocumentJoiner(sort_by_score=False)
document_writer = DocumentWriter(document_store=document_store)

indexing_pipe = Pipeline()
indexing_pipe.add_component("pdf_converter", pdf_converter)
indexing_pipe.add_component("pdf_splitter", pdf_splitter)
indexing_pipe.add_component("doc_length_router", doc_length_router)
indexing_pipe.add_component("content_extractor", content_extractor)
indexing_pipe.add_component("final_doc_joiner", final_doc_joiner)
indexing_pipe.add_component("document_writer", document_writer)

indexing_pipe.connect("pdf_converter.documents", "pdf_splitter.documents")
indexing_pipe.connect("pdf_splitter.documents", "doc_length_router.documents")
# The short PDF pages will be enriched/captioned along with the other images
indexing_pipe.connect("doc_length_router.short_documents", "content_extractor.documents")
indexing_pipe.connect("doc_length_router.long_documents", "final_doc_joiner.documents")

indexing_pipe.connect("content_extractor.documents", "final_doc_joiner.documents")
indexing_pipe.connect("final_doc_joiner.documents", "document_writer.documents")

# Run the indexing pipeline with sources
indexing_result = indexing_pipe.run(
    data={
        "sources": [
            "test/test_files/pdf/sample_pdf_1.pdf", # a PDF with 4 pages
            "test/test_files/pdf/non_text_searchable.pdf", # a non-text searchable PDF with 1 page
        ]
    },
)

# PyPDFToDocument could not extract text from the file test/test_files/pdf/non_text_searchable.pdf. Returning an empty document.
# Document ID 674ac2a36dbd4c6ac62646e3cc7c24012e30dc6fa51b291209bdc2347a02f636 has an empty content. Skipping this document.

# Inspect the documents
indexed_documents = document_store.filter_documents()
print(f"Indexed {len(indexed_documents)} documents:")
for doc in indexed_documents:
    print(doc.meta)

# {'file_path': 'test/test_files/pdf/sample_pdf_1.pdf', 'source_id': '...', 'page_number': 1, 'split_id': 0, 'split_idx_start': 0}
# {'file_path': 'test/test_files/pdf/sample_pdf_1.pdf', 'source_id': '...', 'page_number': 2, 'split_id': 1, 'split_idx_start': 1587}
# {'file_path': 'test/test_files/pdf/sample_pdf_1.pdf', 'source_id': '...', 'page_number': 4, 'split_id': 3, 'split_idx_start': 1611}
# {'file_path': 'test/test_files/pdf/sample_pdf_1.pdf', 'source_id': '...', 'page_number': 3, 'split_id': 2, 'split_idx_start': 1610}
# non_text_searchable.pdf IS MISSING!
```

I am not sure if this is a bug, but for sure it is annoying.

An easy fix could be adding a `keep_empty_documents` flag to `DocumentSplitter`, but there could be better ways to fix it.

(@sjrl)


Discussion:
Good catch! The way I've used this component mostly in the past was checking against files that immediately came out of converters and not after the DocumentSplitter. But I can see why we'd like to run this after the splitter as well. I think adding a new variable as you suggest would be a good solution. 
> The way I've used this component mostly in the past was checking against files that immediately came out of converters and not after the DocumentSplitter.

I want to point out that this does not seem to work currently because the page number is missing.
This is probably a different issue.

```python

from haystack import Pipeline
from haystack.components.converters.pypdf import PyPDFToDocument
from haystack.components.joiners import DocumentJoiner
from haystack.components.writers.document_writer import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore

from haystack.components.extractors.image.llm_document_content_extractor import LLMDocumentContentExtractor
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.routers.document_length_router import DocumentLengthRouter


document_store = InMemoryDocumentStore()

pdf_converter = PyPDFToDocument(store_full_path=True)
doc_length_router = DocumentLengthRouter(threshold=10)
content_extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4.1-mini"))
final_doc_joiner = DocumentJoiner(sort_by_score=False)
document_writer = DocumentWriter(document_store=document_store)

indexing_pipe = Pipeline()
indexing_pipe.add_component("pdf_converter", pdf_converter)
indexing_pipe.add_component("doc_length_router", doc_length_router)
indexing_pipe.add_component("content_extractor", content_extractor)
indexing_pipe.add_component("final_doc_joiner", final_doc_joiner)
indexing_pipe.add_component("document_writer", document_writer)

indexing_pipe.connect("pdf_converter.documents", "doc_length_router.documents")
# The short PDF pages will be enriched/captioned along with the other images
indexing_pipe.connect("doc_length_router.short_documents", "content_extractor.documents")
indexing_pipe.connect("doc_length_router.long_documents", "final_doc_joiner.documents")

indexing_pipe.connect("content_extractor.documents", "final_doc_joiner.documents")
indexing_pipe.connect("final_doc_joiner.documents", "document_writer.documents")

# Run the indexing pipeline with sources
indexing_result = indexing_pipe.run(
    data={
        "sources": [
            "test/test_files/pdf/sample_pdf_1.pdf", # a PDF with 4 pages
            "test/test_files/pdf/non_text_searchable.pdf", # a non-text searchable PDF with 1 page
        ]
    },
)

# PyPDFToDocument could not extract text from the file test/test_files/pdf/non_text_searchable.pdf. Returning an empty document.

# Inspect the documents
indexed_documents = document_store.filter_documents()
print(f"Indexed {len(indexed_documents)} documents:")
for doc in indexed_documents:
    print(doc.content)
    print(doc.meta["page_number"])

# Traceback (most recent call last):
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/core/pipeline/pipeline.py", line 61, in _run_component
#    component_output = instance.run(**inputs)
#                       ^^^^^^^^^^^^^^^^^^^^^^
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/components/extractors/image/llm_document_content_extractor.py", line 227, in run
#    image_contents = self._document_to_image_content.run(documents=documents)["image_contents"]
#                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/components/converters/image/document_to_image.py", line 116, in run
#    images_source_info = _extract_image_sources_info(
#                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/components/converters/image/image_utils.py", line 273, in _extract_image_sources_info
#    raise ValueError(
#ValueError: Document with ID '674ac2a36dbd4c6ac62646e3cc7c24012e30dc6fa51b291209bdc2347a02f636' comes from the PDF file 'test/test_files/pdf/non_text_searchable.pdf' but is missing the 'page_number' key in its metadata. Please ensure that PDF documents you are trying to convert have this key set.
#
#The above exception was the direct cause of the following exception:
#
#Traceback (most recent call last):
#  File "/Users/stefano.fiorucci/dev/haystack/prob2.py", line 38, in <module>
#    indexing_result = indexing_pipe.run(
#                      ^^^^^^^^^^^^^^^^^^
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/core/pipeline/pipeline.py", line 258, in run
#    component_outputs = self._run_component(
#                        ^^^^^^^^^^^^^^^^^^^^
#  File "/Users/stefano.fiorucci/dev/haystack/haystack/core/pipeline/pipeline.py", line 63, in _run_component
#    raise PipelineRuntimeError.from_exception(component_name, instance.__class__, error) from error
#haystack.core.errors.PipelineRuntimeError: The following component failed to run:
#Component name: 'content_extractor'
#Component type: 'LLMDocumentContentExtractor'
#Error: Document with ID '674ac2a36dbd4c6ac62646e3cc7c24012e30dc6fa51b291209bdc2347a02f636' comes from the PDF file 'test/test_files/pdf/non_text_searchable.pdf' but is missing the 'page_number' key in its metadata. Please ensure that PDF documents you are trying to convert have this key set.
```
> > The way I've used this component mostly in the past was checking against files that immediately came out of converters and not after the DocumentSplitter.
> 
> I want to point out that this does not seem to work currently because the page number is missing. This is probably a different issue.

Right that is a good point. And I think this is something I overlooked when adding the LLMDocumentContentExtractor. The way this workflow worked previously in the deepset platform is that we used a custom component called `DeepsetVLMPDFToDocumentConverter` which could handle full PDF files by internally handling the loading and splitting of the PDF file. This is how the workflow I described was able to work. 

I suppose in our case it would be better to use your workflow with the DocumentSplitter coming before the DocumentLengthRouter and adding this new param to avoid the edge case you describe. 

That or we could also update `LLMDocumentContentExtractor` to be able to work on a whole PDF file if a the page number is missing. WDYT?

OK with trying to add the new parameter. (I haven't tested this approach extensively yet.)

Do you think that it makes sense to try to include it in the 2.16.0 release?
> OK with trying to add the new parameter. (I haven't tested this approach extensively yet.)
> 
> Do you think that it makes sense to try to include it in the 2.16.0 release?

Yes I think so if possible



Patch:
diff --git a/haystack/components/preprocessors/document_splitter.py b/haystack/components/preprocessors/document_splitter.py
index 7c646176b0..fcd58e999e 100644
--- a/haystack/components/preprocessors/document_splitter.py
+++ b/haystack/components/preprocessors/document_splitter.py
@@ -62,6 +62,8 @@ def __init__(  # pylint: disable=too-many-positional-arguments
         language: Language = "en",
         use_split_rules: bool = True,
         extend_abbreviations: bool = True,
+        *,
+        skip_empty_documents: bool = True,
     ):
         """
         Initialize DocumentSplitter.
@@ -87,6 +89,9 @@ def __init__(  # pylint: disable=too-many-positional-arguments
         :param use_split_rules: Choose whether to use additional split rules when splitting by `sentence`.
         :param extend_abbreviations: Choose whether to extend NLTK's PunktTokenizer abbreviations with a list
             of curated abbreviations, if available. This is currently supported for English ("en") and German ("de").
+        :param skip_empty_documents: Choose whether to skip documents with empty content. Default is True.
+            Set to False when downstream components in the Pipeline (like LLMDocumentContentExtractor) can extract text
+            from non-textual documents.
         """
 
         self.split_by = split_by
@@ -98,6 +103,7 @@ def __init__(  # pylint: disable=too-many-positional-arguments
         self.language = language
         self.use_split_rules = use_split_rules
         self.extend_abbreviations = extend_abbreviations
+        self.skip_empty_documents = skip_empty_documents
 
         self._init_checks(
             split_by=split_by,
@@ -194,7 +200,7 @@ def run(self, documents: List[Document]):
                 raise ValueError(
                     f"DocumentSplitter only works with text documents but content for document ID {doc.id} is None."
                 )
-            if doc.content == "":
+            if doc.content == "" and self.skip_empty_documents:
                 logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
                 continue
 
@@ -287,8 +293,8 @@ def _concatenate_units(
                 # concatenate the last split with the current one
                 text_splits[-1] += txt
 
-            # NOTE: This line skips documents that have content=""
-            elif len(txt) > 0:
+            # NOTE: If skip_empty_documents is True, this line skips documents that have content=""
+            elif not self.skip_empty_documents or len(txt) > 0:
                 text_splits.append(txt)
                 splits_pages.append(cur_page)
                 splits_start_idxs.append(cur_start_idx)
@@ -375,6 +381,7 @@ def to_dict(self) -> Dict[str, Any]:
             language=self.language,
             use_split_rules=self.use_split_rules,
             extend_abbreviations=self.extend_abbreviations,
+            skip_empty_documents=self.skip_empty_documents,
         )
         if self.splitting_function:
             serialized["init_parameters"]["splitting_function"] = serialize_callable(self.splitting_function)
diff --git a/releasenotes/notes/doc-splitter-skip-empty-docs-a031fa7c5ddf8c93.yaml b/releasenotes/notes/doc-splitter-skip-empty-docs-a031fa7c5ddf8c93.yaml
new file mode 100644
index 0000000000..48c2314ebd
--- /dev/null
+++ b/releasenotes/notes/doc-splitter-skip-empty-docs-a031fa7c5ddf8c93.yaml
@@ -0,0 +1,6 @@
+---
+enhancements:
+  - |
+    Add the init parameter `skip_empty_documents` to the `DocumentSplitter` component. The default value is True.
+    Setting it to False can be useful when downstream components in the Pipeline (like `LLMDocumentContentExtractor`)
+    can extract text from non-textual documents.


Test patch:
diff --git a/e2e/pipelines/test_pdf_content_extraction_pipeline.py b/e2e/pipelines/test_pdf_content_extraction_pipeline.py
new file mode 100644
index 0000000000..f4ca83a8df
--- /dev/null
+++ b/e2e/pipelines/test_pdf_content_extraction_pipeline.py
@@ -0,0 +1,82 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from haystack import Pipeline
+from haystack.components.converters.pypdf import PyPDFToDocument
+from haystack.components.joiners import DocumentJoiner
+from haystack.components.preprocessors.document_splitter import DocumentSplitter
+from haystack.components.writers.document_writer import DocumentWriter
+from haystack.document_stores.in_memory import InMemoryDocumentStore
+from haystack.components.extractors.image.llm_document_content_extractor import LLMDocumentContentExtractor
+from haystack.components.generators.chat.openai import OpenAIChatGenerator
+from haystack.components.routers.document_length_router import DocumentLengthRouter
+
+
+def test_pdf_content_extraction_pipeline():
+    """
+    Test a pipeline that processes PDFs with the following steps:
+    1. Convert PDFs to documents
+    2. Split documents by page
+    3. Route documents by length (short vs long)
+    4. Extract content from short documents using LLM
+    5. Join documents back together
+    6. Write to document store
+    """
+    document_store = InMemoryDocumentStore()
+
+    pdf_converter = PyPDFToDocument(store_full_path=True)
+    pdf_splitter = DocumentSplitter(split_by="page", split_length=1, skip_empty_documents=False)
+    doc_length_router = DocumentLengthRouter(threshold=10)
+    content_extractor = LLMDocumentContentExtractor(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"))
+    final_doc_joiner = DocumentJoiner(sort_by_score=False)
+    document_writer = DocumentWriter(document_store=document_store)
+
+    # Create and configure pipeline
+    indexing_pipe = Pipeline()
+    indexing_pipe.add_component("pdf_converter", pdf_converter)
+    indexing_pipe.add_component("pdf_splitter", pdf_splitter)
+    indexing_pipe.add_component("doc_length_router", doc_length_router)
+    indexing_pipe.add_component("content_extractor", content_extractor)
+    indexing_pipe.add_component("final_doc_joiner", final_doc_joiner)
+    indexing_pipe.add_component("document_writer", document_writer)
+
+    # Connect components
+    indexing_pipe.connect("pdf_converter.documents", "pdf_splitter.documents")
+    indexing_pipe.connect("pdf_splitter.documents", "doc_length_router.documents")
+    # The short PDF pages will be enriched/captioned
+    indexing_pipe.connect("doc_length_router.short_documents", "content_extractor.documents")
+    indexing_pipe.connect("doc_length_router.long_documents", "final_doc_joiner.documents")
+    indexing_pipe.connect("content_extractor.documents", "final_doc_joiner.documents")
+    indexing_pipe.connect("final_doc_joiner.documents", "document_writer.documents")
+
+    # Test with both text-searchable and non-text-searchable PDFs
+    test_files = [
+        "test/test_files/pdf/sample_pdf_1.pdf",  # a PDF with 4 pages
+        "test/test_files/pdf/non_text_searchable.pdf",  # a non-text searchable PDF with 1 page
+    ]
+
+    # Run the indexing pipeline
+    indexing_result = indexing_pipe.run(data={"sources": test_files})
+
+    assert indexing_result is not None
+    assert "document_writer" in indexing_result
+
+    indexed_documents = document_store.filter_documents()
+
+    # We expect documents from both PDFs
+    # sample_pdf_1.pdf has 4 pages, non_text_searchable.pdf has 1 page
+    assert len(indexed_documents) == 5
+
+    file_paths = {doc.meta["file_path"] for doc in indexed_documents}
+    assert file_paths == set(test_files)
+
+    for doc in indexed_documents:
+        assert hasattr(doc, "content")
+        assert hasattr(doc, "meta")
+        assert "file_path" in doc.meta
+        assert "page_number" in doc.meta
+
+    for doc in indexed_documents:
+        assert isinstance(doc.meta["page_number"], int)
+        assert doc.meta["page_number"] >= 1
diff --git a/test/components/preprocessors/test_document_splitter.py b/test/components/preprocessors/test_document_splitter.py
index 91779f606e..551b155775 100644
--- a/test/components/preprocessors/test_document_splitter.py
+++ b/test/components/preprocessors/test_document_splitter.py
@@ -444,6 +444,7 @@ def test_to_dict(self):
         assert serialized["init_parameters"]["split_length"] == 10
         assert serialized["init_parameters"]["split_overlap"] == 2
         assert serialized["init_parameters"]["split_threshold"] == 5
+        assert serialized["init_parameters"]["skip_empty_documents"]
         assert "splitting_function" not in serialized["init_parameters"]
 
     def test_to_dict_with_splitting_function(self):
@@ -457,6 +458,7 @@ def test_to_dict_with_splitting_function(self):
         assert serialized["type"] == "haystack.components.preprocessors.document_splitter.DocumentSplitter"
         assert serialized["init_parameters"]["split_by"] == "function"
         assert "splitting_function" in serialized["init_parameters"]
+        assert serialized["init_parameters"]["skip_empty_documents"]
         assert callable(deserialize_callable(serialized["init_parameters"]["splitting_function"]))
 
     def test_from_dict(self):
@@ -465,7 +467,13 @@ def test_from_dict(self):
         """
         data = {
             "type": "haystack.components.preprocessors.document_splitter.DocumentSplitter",
-            "init_parameters": {"split_by": "word", "split_length": 10, "split_overlap": 2, "split_threshold": 5},
+            "init_parameters": {
+                "split_by": "word",
+                "split_length": 10,
+                "split_overlap": 2,
+                "split_threshold": 5,
+                "skip_empty_documents": False,
+            },
         }
         splitter = DocumentSplitter.from_dict(data)
 
@@ -474,6 +482,7 @@ def test_from_dict(self):
         assert splitter.split_overlap == 2
         assert splitter.split_threshold == 5
         assert splitter.splitting_function is None
+        assert splitter.skip_empty_documents is False
 
     def test_from_dict_with_splitting_function(self):
         """
@@ -516,7 +525,7 @@ def test_roundtrip_serialization_with_splitting_function(self):
         assert callable(deserialized_splitter.splitting_function)
         assert deserialized_splitter.splitting_function("a.b.c") == ["a", "b", "c"]
 
-    def test_run_empty_document(self):
+    def test_run_empty_document_with_skip_empty_documents_true(self):
         """
         Test if the component runs correctly with an empty document.
         """
@@ -526,6 +535,14 @@ def test_run_empty_document(self):
         results = splitter.run([doc])
         assert results["documents"] == []
 
+    def test_run_empty_document_with_skip_empty_documents_false(self):
+        splitter = DocumentSplitter(skip_empty_documents=False)
+        doc = Document(content="")
+        splitter.warm_up()
+        results = splitter.run([doc])
+        assert len(results["documents"]) == 1
+        assert results["documents"][0].content == ""
+
     def test_run_document_only_whitespaces(self):
         """
         Test if the component runs correctly with a document containing only whitespaces.