Repository: deepset-ai/haystack

Issue:
Support for multiple metadatafields in the Sentence Window retriever
**Is your feature request related to a problem? Please describe.**
I would like to retrieve neighboring passages/documents from one specific section. Section header information is present in the metadata.


**Describe the solution you'd like**
To further enhance the functionality of the SentenceWindowRetrieval we could change it to accept a list of metadatafields. By that we can filter not only by individual document id, but also on e.g. the section header. This will lead to retrieve only documents from a specific section.

**Describe alternatives you've considered**
Dedicated component which creates filters based on document metadata


cc: @sjrl 



Discussion:

Patch:
diff --git a/haystack/components/retrievers/sentence_window_retriever.py b/haystack/components/retrievers/sentence_window_retriever.py
index 69be60264f..6568f5795f 100644
--- a/haystack/components/retrievers/sentence_window_retriever.py
+++ b/haystack/components/retrievers/sentence_window_retriever.py
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from typing import Any, Optional
+from typing import Any, Optional, Union
 
 from haystack import Document, component, default_from_dict, default_to_dict, logging
 from haystack.document_stores.types import DocumentStore
@@ -87,7 +87,7 @@ def __init__(
         document_store: DocumentStore,
         window_size: int = 3,
         *,
-        source_id_meta_field: str = "source_id",
+        source_id_meta_field: Union[str, list[str]] = "source_id",
         split_id_meta_field: str = "split_id",
         raise_on_missing_meta_fields: bool = True,
     ):
@@ -98,6 +98,8 @@ def __init__(
         :param window_size: The number of documents to retrieve before and after the relevant one.
                 For example, `window_size: 2` fetches 2 preceding and 2 following documents.
         :param source_id_meta_field: The metadata field that contains the source ID of the document.
+            This can be a single field or a list of fields. If multiple fields are provided, the retriever will
+            consider the document as part of the same source if all the fields match.
         :param split_id_meta_field: The metadata field that contains the split ID of the document.
         :param raise_on_missing_meta_fields: If True, raises an error if the documents do not contain the required
             metadata fields. If False, it will skip retrieving the context for documents that are missing
@@ -109,6 +111,10 @@ def __init__(
         self.window_size = window_size
         self.document_store = document_store
         self.source_id_meta_field = source_id_meta_field
+        # Use this to have an attribute that is always a list of source id meta fields.
+        self._source_id_meta_fields = (
+            source_id_meta_field if isinstance(source_id_meta_field, list) else [source_id_meta_field]
+        )
         self.split_id_meta_field = split_id_meta_field
         self.raise_on_missing_meta_fields = raise_on_missing_meta_fields
 
@@ -209,7 +215,7 @@ def run(self, retrieved_documents: list[Document], window_size: Optional[int] =
             raise ValueError(f"The retrieved documents must have '{self.split_id_meta_field}' in their metadata.")
 
         if (
-            not all(self.source_id_meta_field in doc.meta for doc in retrieved_documents)
+            not all(field in doc.meta for doc in retrieved_documents for field in self._source_id_meta_fields)
             and self.raise_on_missing_meta_fields
         ):
             raise ValueError(f"The retrieved documents must have '{self.source_id_meta_field}' in their metadata.")
@@ -217,33 +223,33 @@ def run(self, retrieved_documents: list[Document], window_size: Optional[int] =
         context_text = []
         context_documents = []
         for doc in retrieved_documents:
-            source_id = doc.meta.get(self.source_id_meta_field)
+            source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
             split_id = doc.meta.get(self.split_id_meta_field)
 
-            if source_id is None or split_id is None:
+            if any(source_id is None for source_id in source_ids) or split_id is None:
                 logger.warning(
                     "Document {doc_id} is missing required metadata fields to be used with "
                     "SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
                     doc_id=doc.id,
-                    source_id=source_id,
-                    split_id=split_id,
+                    source_id=self._source_id_meta_fields,
+                    split_id=self.split_id_meta_field,
                 )
                 context_text.append(doc.content or "")
                 context_documents.append(doc)
                 continue
 
-            min_before = min(list(range(split_id - 1, split_id - window_size - 1, -1)))
-            max_after = max(list(range(split_id + 1, split_id + window_size + 1, 1)))
-            context_docs = self.document_store.filter_documents(
-                {
-                    "operator": "AND",
-                    "conditions": [
-                        {"field": f"meta.{self.source_id_meta_field}", "operator": "==", "value": source_id},
-                        {"field": f"meta.{self.split_id_meta_field}", "operator": ">=", "value": min_before},
-                        {"field": f"meta.{self.split_id_meta_field}", "operator": "<=", "value": max_after},
-                    ],
-                }
-            )
+            min_before = split_id - window_size
+            max_after = split_id + window_size
+            source_id_filters = [
+                {"field": f"meta.{source_id_meta_field}", "operator": "==", "value": source_id}
+                for source_id_meta_field, source_id in zip(self._source_id_meta_fields, source_ids)
+            ]
+            conditions = [
+                {"field": f"meta.{self.split_id_meta_field}", "operator": ">=", "value": min_before},
+                {"field": f"meta.{self.split_id_meta_field}", "operator": "<=", "value": max_after},
+                *source_id_filters,
+            ]
+            context_docs = self.document_store.filter_documents({"operator": "AND", "conditions": conditions})
             context_text.append(self.merge_documents_text(context_docs))
             context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
             context_documents.extend(context_docs_sorted)
diff --git a/releasenotes/notes/update-sentence-window-retriever-7e28965608b84808.yaml b/releasenotes/notes/update-sentence-window-retriever-7e28965608b84808.yaml
new file mode 100644
index 0000000000..28cf7a449e
--- /dev/null
+++ b/releasenotes/notes/update-sentence-window-retriever-7e28965608b84808.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - |
+    Updated `SentenceWindowRetriever`'s source_id_meta_field parameter to also accept a list of strings. If a list of fields are provided, then only documents matching both fields will be retrieved.


Test patch:
diff --git a/test/components/retrievers/test_sentence_window_retriever.py b/test/components/retrievers/test_sentence_window_retriever.py
index 65e21360fd..e1bbdf2216 100644
--- a/test/components/retrievers/test_sentence_window_retriever.py
+++ b/test/components/retrievers/test_sentence_window_retriever.py
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import random
+import re
 from unittest.mock import ANY
 
 import pytest
@@ -140,7 +141,8 @@ def test_document_without_source_id(self):
         docs = [
             Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0}),
             Document(
-                content="some words. There is a second sentence. And there is ", meta={"id": "doc_1", "split_id": 1}
+                content="some words. There is a second sentence. And there is ",
+                meta={"id": "doc_1", "split_id": 1, "source_id_test": "source1"},
             ),
         ]
         with pytest.raises(ValueError, match="The retrieved documents must have 'source_id_test' in their metadata."):
@@ -149,6 +151,25 @@ def test_document_without_source_id(self):
             )
             retriever.run(retrieved_documents=docs)
 
+    def test_document_without_all_source_ids(self):
+        docs = [
+            Document(
+                content="These are words from the first section",
+                meta={"id": "doc_1", "split_id": 0, "section_id": "section1"},
+            ),
+            Document(
+                content="These are words from the second section, but missing section_id",
+                meta={"id": "doc_0", "split_id": 0},
+            ),
+        ]
+        with pytest.raises(
+            ValueError, match=re.escape("The retrieved documents must have '['id', 'section_id']' in their metadata.")
+        ):
+            retriever = SentenceWindowRetriever(
+                document_store=InMemoryDocumentStore(), window_size=3, source_id_meta_field=["id", "section_id"]
+            )
+            retriever.run(retrieved_documents=docs)
+
     def test_run_invalid_window_size(self):
         docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})]
         with pytest.raises(ValueError):
@@ -236,6 +257,33 @@ def test_run_custom_fields(self):
         result = retriever.run(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
         assert len(result["context_documents"]) == 7
 
+    def test_run_with_multiple_source_ids(self):
+        docs = [
+            Document(content="This is the first chunk.", meta={"section": "1", "split_id": 0, "source_id": "source1"}),
+            Document(content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"}),
+            Document(content="This is the third chunk.", meta={"section": "1", "split_id": 2, "source_id": "source1"}),
+            Document(
+                content="This is a chunk from section 2.", meta={"section": "2", "split_id": 3, "source_id": "source1"}
+            ),
+        ]
+        doc_store = InMemoryDocumentStore()
+        doc_store.write_documents(docs)
+
+        retriever = SentenceWindowRetriever(
+            document_store=doc_store, window_size=5, source_id_meta_field=["section", "source_id"]
+        )
+        result = retriever.run(
+            retrieved_documents=[
+                Document(
+                    content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"}
+                )
+            ]
+        )
+
+        assert len(result["context_windows"]) == 1
+        assert len(result["context_documents"]) == 3
+        assert all(doc.meta["section"] == "1" for doc in result["context_documents"])
+
     @pytest.mark.integration
     def test_run_with_pipeline(self):
         splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="period")