chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,254 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, Pipeline
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.retrievers import InMemoryBM25Retriever
from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
class TestAutoMergingRetriever:
def test_init_default(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert retriever.threshold == 0.5
def test_init_with_parameters(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.7)
assert retriever.threshold == 0.7
def test_init_with_invalid_threshold(self, in_memory_doc_store):
with pytest.raises(ValueError):
AutoMergingRetriever(in_memory_doc_store, threshold=-2)
def test_run_missing_parent_id(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__level": 1, "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
retriever.run(documents=docs)
def test_run_missing_level(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__level'"
):
retriever.run(documents=docs)
def test_run_missing_block_size(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__level": 1})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__block_size'"
):
retriever.run(documents=docs)
def test_run_mixed_valid_and_invalid_documents(self, in_memory_doc_store):
docs = [
Document(content="valid", meta={"__parent_id": "parent1", "__level": 1, "__block_size": 10}),
Document(content="invalid", meta={"__level": 1, "__block_size": 10}),
]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
retriever.run(documents=docs)
def test_to_dict(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.7)
expected = retriever.to_dict()
assert expected["type"] == "haystack.components.retrievers.auto_merging_retriever.AutoMergingRetriever"
assert expected["init_parameters"]["threshold"] == 0.7
assert (
expected["init_parameters"]["document_store"]["type"]
== "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore"
)
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.auto_merging_retriever.AutoMergingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "6b122bb4-211b-465e-804d-77c5857bf4c5",
"shared": True,
},
},
"threshold": 0.7,
},
}
retriever = AutoMergingRetriever.from_dict(data)
assert retriever.threshold == 0.7
def test_serialization_deserialization_pipeline(self, in_memory_doc_store):
pipeline = Pipeline()
bm_25_retriever = InMemoryBM25Retriever(in_memory_doc_store)
auto_merging_retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
pipeline.add_component(name="bm_25_retriever", instance=bm_25_retriever)
pipeline.add_component(name="auto_merging_retriever", instance=auto_merging_retriever)
pipeline.connect("bm_25_retriever.documents", "auto_merging_retriever.documents")
pipeline_dict = pipeline.to_dict()
new_pipeline = Pipeline.from_dict(pipeline_dict)
assert new_pipeline == pipeline
def test_run_parent_not_found(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# a leaf document with a non-existent parent_id
leaf_doc = Document(
content="test", meta={"__parent_id": "non_existent_parent", "__level": 1, "__block_size": 10}
)
with pytest.raises(ValueError, match="Expected 1 parent document with id non_existent_parent, found 0"):
retriever.run([leaf_doc])
def test_run_parent_without_children_metadata(self, in_memory_doc_store):
"""Test case where a parent document exists but doesn't have the __children_ids metadata field"""
# Create and store a parent document without __children_ids metadata
parent_doc = Document(
content="parent content",
id="parent1",
meta={
"__level": 1, # Add other required metadata
"__block_size": 10,
},
)
in_memory_doc_store.write_documents([parent_doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# Create a leaf document that points to this parent
leaf_doc = Document(content="leaf content", meta={"__parent_id": "parent1", "__level": 2, "__block_size": 5})
with pytest.raises(ValueError, match="Parent document with id parent1 does not have any children"):
retriever.run([leaf_doc])
def test_run_empty_documents(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert retriever.run([]) == {"documents": []}
def test_run_return_parent_document(self, in_memory_doc_store):
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
# since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
docs = retriever.run(leaf_docs[4:6])
assert len(docs["documents"]) == 1
assert docs["documents"][0].content == "warm glow over the trees. Birds began to sing."
assert len(docs["documents"][0].meta["__children_ids"]) == 3
def test_run_return_leafs_document(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = retriever.run([leaf_docs[4]])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "eastern side of "
assert result["documents"][0].meta["__parent_id"] == docs["documents"][2].id
def test_run_return_leafs_document_different_parents(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = retriever.run([leaf_docs[4], leaf_docs[3]])
assert len(result["documents"]) == 2
assert result["documents"][0].meta["__parent_id"] != result["documents"][1].meta["__parent_id"]
def test_run_go_up_hierarchy_multiple_levels(self, in_memory_doc_store):
"""
Test if the retriever can go up the hierarchy multiple levels to find the parent document.
Simulate a scenario where we have 4 leaf-documents that matched some initial query. The leaf-documents
are continuously merged up the hierarchy until the threshold is no longer met.
In this case it goes from the 4th level in the hierarchy up the 1st level.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4, 2, 1}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.4)
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [d for d in docs["documents"] if d.content in {"The ", "sun ", "rose ", "early "}]
result = retriever.run(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].content == "The sun rose early in the "
def test_run_go_up_hierarchy_multiple_levels_hit_root_document(self, in_memory_doc_store):
"""
Test case where we go up hierarchy until the root document, so the root document is returned.
It's the only document in the hierarchy which has no parent.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.1) # set a low threshold to hit root document
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [
d
for d in docs["documents"]
if d.content in {"The sun rose early ", "in the ", "morning. It cast a ", "over the trees. Birds "}
]
result = retriever.run(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].meta["__level"] == 0 # hit root document
@@ -0,0 +1,208 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
class TestAutoMergingRetrieverAsync:
@pytest.mark.asyncio
async def test_run_missing_parent_id(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__level": 1, "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_missing_level(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__block_size": 10})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__level'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_missing_block_size(self, in_memory_doc_store):
docs = [Document(content="test", meta={"__parent_id": "parent1", "__level": 1})]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__block_size'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_mixed_valid_and_invalid_documents(self, in_memory_doc_store):
docs = [
Document(content="valid", meta={"__parent_id": "parent1", "__level": 1, "__block_size": 10}),
Document(content="invalid", meta={"__level": 1, "__block_size": 10}),
]
retriever = AutoMergingRetriever(in_memory_doc_store)
with pytest.raises(
ValueError, match="The matched leaf documents do not have the required meta field '__parent_id'"
):
await retriever.run_async(documents=docs)
@pytest.mark.asyncio
async def test_run_parent_not_found(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# a leaf document with a non-existent parent_id
leaf_doc = Document(
content="test", meta={"__parent_id": "non_existent_parent", "__level": 1, "__block_size": 10}
)
with pytest.raises(ValueError, match="Expected 1 parent document with id non_existent_parent, found 0"):
await retriever.run_async([leaf_doc])
@pytest.mark.asyncio
async def test_run_parent_without_children_metadata(self, in_memory_doc_store):
"""Test case where a parent document exists but doesn't have the __children_ids metadata field"""
# Create and store a parent document without __children_ids metadata
parent_doc = Document(
content="parent content",
id="parent1",
meta={
"__level": 1, # Add other required metadata
"__block_size": 10,
},
)
in_memory_doc_store.write_documents([parent_doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# Create a leaf document that points to this parent
leaf_doc = Document(content="leaf content", meta={"__parent_id": "parent1", "__level": 2, "__block_size": 5})
with pytest.raises(ValueError, match="Parent document with id parent1 does not have any children"):
await retriever.run_async([leaf_doc])
@pytest.mark.asyncio
async def test_run_empty_documents(self, in_memory_doc_store):
retriever = AutoMergingRetriever(in_memory_doc_store)
assert await retriever.run_async([]) == {"documents": []}
@pytest.mark.asyncio
async def test_run_return_parent_document(self, in_memory_doc_store):
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.5)
# assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
# since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
docs = await retriever.run_async(leaf_docs[4:6])
assert len(docs["documents"]) == 1
assert docs["documents"][0].content == "warm glow over the trees. Birds began to sing."
assert len(docs["documents"][0].meta["__children_ids"]) == 3
@pytest.mark.asyncio
async def test_run_return_leafs_document(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = await retriever.run_async([leaf_docs[4]])
assert len(result["documents"]) == 1
assert result["documents"][0].content == "eastern side of "
assert result["documents"][0].meta["__parent_id"] == docs["documents"][2].id
@pytest.mark.asyncio
async def test_run_return_leafs_document_different_parents(self, in_memory_doc_store):
docs = [Document(content="The monarch of the wild blue yonder rises from the eastern side of the horizon.")]
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run(docs)
for doc in docs["documents"]:
if doc.meta["__level"] == 1:
in_memory_doc_store.write_documents([doc])
leaf_docs = [doc for doc in docs["documents"] if not doc.meta["__children_ids"]]
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.6)
result = await retriever.run_async([leaf_docs[4], leaf_docs[3]])
assert len(result["documents"]) == 2
assert result["documents"][0].meta["__parent_id"] != result["documents"][1].meta["__parent_id"]
@pytest.mark.asyncio
async def test_run_go_up_hierarchy_multiple_levels(self, in_memory_doc_store):
"""
Test if the retriever can go up the hierarchy multiple levels to find the parent document.
Simulate a scenario where we have 4 leaf-documents that matched some initial query. The leaf-documents
are continuously merged up the hierarchy until the threshold is no longer met.
In this case it goes from the 4th level in the hierarchy up the 1st level.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4, 2, 1}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.4)
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [d for d in docs["documents"] if d.content in {"The ", "sun ", "rose ", "early "}]
result = await retriever.run_async(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].content == "The sun rose early in the "
@pytest.mark.asyncio
async def test_run_go_up_hierarchy_multiple_levels_hit_root_document(self, in_memory_doc_store):
"""
Test case where we go up hierarchy until the root document, so the root document is returned.
It's the only document in the hierarchy which has no parent.
"""
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
docs = [Document(content=text)]
builder = HierarchicalDocumentSplitter(block_sizes={6, 4}, split_overlap=0, split_by="word")
docs = builder.run(docs)
# store all non-leaf documents
for doc in docs["documents"]:
if doc.meta["__children_ids"]:
in_memory_doc_store.write_documents([doc])
retriever = AutoMergingRetriever(in_memory_doc_store, threshold=0.1) # set a low threshold to hit root document
# simulate a scenario where we have 4 leaf-documents that matched some initial query
retrieved_leaf_docs = [
d
for d in docs["documents"]
if d.content in {"The sun rose early ", "in the ", "morning. It cast a ", "over the trees. Birds "}
]
result = await retriever.run_async(retrieved_leaf_docs)
assert len(result["documents"]) == 1
assert result["documents"][0].meta["__level"] == 0 # hit root document
@@ -0,0 +1,146 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import pytest
from haystack import Pipeline
from haystack.components.retrievers.filter_retriever import FilterRetriever
from haystack.dataclasses import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.testing.factory import document_store_class
@pytest.fixture()
def sample_docs():
en_docs = [
Document(content="Javascript is a popular programming language", meta={"lang": "en"}),
Document(content="Python is a popular programming language", meta={"lang": "en"}),
Document(content="A chromosome is a package of DNA ", meta={"lang": "en"}),
]
de_docs = [
Document(content="python ist eine beliebte Programmiersprache", meta={"lang": "de"}),
Document(content="javascript ist eine beliebte Programmiersprache", meta={"lang": "de"}),
]
all_docs = en_docs + de_docs
return {"en_docs": en_docs, "de_docs": de_docs, "all_docs": all_docs}
@pytest.fixture()
def sample_document_store(sample_docs):
doc_store = InMemoryDocumentStore()
doc_store.write_documents(sample_docs["all_docs"])
return doc_store
class TestFilterRetriever:
@classmethod
def _documents_equal(cls, docs1: list[Document], docs2: list[Document]) -> bool:
# # Order doesn't matter; we sort before comparing
docs1.sort(key=lambda x: x.id)
docs2.sort(key=lambda x: x.id)
return docs1 == docs2
def test_init_default(self, in_memory_doc_store):
retriever = FilterRetriever(in_memory_doc_store)
assert retriever.filters is None
def test_init_with_parameters(self, in_memory_doc_store):
retriever = FilterRetriever(in_memory_doc_store, filters={"lang": "en"})
assert retriever.filters == {"lang": "en"}
def test_to_dict(self):
FilterDocStore = document_store_class("MyFakeStore", bases=(InMemoryDocumentStore,))
document_store = FilterDocStore()
document_store.to_dict = lambda: {"type": "FilterDocStore", "init_parameters": {}}
component = FilterRetriever(document_store=document_store)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.filter_retriever.FilterRetriever",
"init_parameters": {"document_store": {"type": "FilterDocStore", "init_parameters": {}}, "filters": None},
}
def test_to_dict_with_custom_init_parameters(self):
ds = InMemoryDocumentStore(index="test_to_dict_with_custom_init_parameters")
serialized_ds = ds.to_dict()
component = FilterRetriever(
document_store=InMemoryDocumentStore(index="test_to_dict_with_custom_init_parameters"),
filters={"lang": "en"},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.filter_retriever.FilterRetriever",
"init_parameters": {"document_store": serialized_ds, "filters": {"lang": "en"}},
}
def test_from_dict(self):
valid_data = {
"type": "haystack.components.retrievers.filter_retriever.FilterRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {},
},
"filters": {"lang": "en"},
},
}
component = FilterRetriever.from_dict(valid_data)
assert isinstance(component.document_store, InMemoryDocumentStore)
assert component.filters == {"lang": "en"}
def test_from_dict_without_docstore(self):
data = {"type": "haystack.components.retrievers.filter_retriever.FilterRetriever", "init_parameters": {}}
with pytest.raises(TypeError, match="missing 1 required positional argument: 'document_store'"):
FilterRetriever.from_dict(data)
def test_retriever_init_filter(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "en"})
result = retriever.run()
assert "documents" in result
assert len(result["documents"]) == 3
assert TestFilterRetriever._documents_equal(result["documents"], sample_docs["en_docs"])
def test_retriever_runtime_filter(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store)
result = retriever.run(filters={"field": "lang", "operator": "==", "value": "en"})
assert "documents" in result
assert len(result["documents"]) == 3
assert TestFilterRetriever._documents_equal(result["documents"], sample_docs["en_docs"])
def test_retriever_init_filter_run_filter_override(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "en"})
result = retriever.run(filters={"field": "lang", "operator": "==", "value": "de"})
assert "documents" in result
assert len(result["documents"]) == 2
assert TestFilterRetriever._documents_equal(result["documents"], sample_docs["de_docs"])
@pytest.mark.integration
def test_run_with_pipeline(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "de"})
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result: dict[str, Any] = pipeline.run(data={"retriever": {}})
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert TestFilterRetriever._documents_equal(results_docs, sample_docs["de_docs"])
result: dict[str, Any] = pipeline.run(
data={"retriever": {"filters": {"field": "lang", "operator": "==", "value": "en"}}}
)
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert TestFilterRetriever._documents_equal(results_docs, sample_docs["en_docs"])
@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import pytest
from haystack import Pipeline
from haystack.components.retrievers.filter_retriever import FilterRetriever
from haystack.dataclasses import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
@pytest.fixture()
def sample_docs():
en_docs = [
Document(content="Javascript is a popular programming language", meta={"lang": "en"}),
Document(content="Python is a popular programming language", meta={"lang": "en"}),
Document(content="A chromosome is a package of DNA ", meta={"lang": "en"}),
]
de_docs = [
Document(content="python ist eine beliebte Programmiersprache", meta={"lang": "de"}),
Document(content="javascript ist eine beliebte Programmiersprache", meta={"lang": "de"}),
]
all_docs = en_docs + de_docs
return {"en_docs": en_docs, "de_docs": de_docs, "all_docs": all_docs}
@pytest.fixture()
def sample_document_store(sample_docs):
doc_store = InMemoryDocumentStore()
doc_store.write_documents(sample_docs["all_docs"])
yield doc_store
doc_store.shutdown()
class TestFilterRetrieverAsync:
@classmethod
def _documents_equal(cls, docs1: list[Document], docs2: list[Document]) -> bool:
# # Order doesn't matter; we sort before comparing
docs1.sort(key=lambda x: x.id)
docs2.sort(key=lambda x: x.id)
return docs1 == docs2
@pytest.mark.asyncio
async def test_retriever_init_filter(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "en"})
result = await retriever.run_async()
assert "documents" in result
assert len(result["documents"]) == 3
assert TestFilterRetrieverAsync._documents_equal(result["documents"], sample_docs["en_docs"])
@pytest.mark.asyncio
async def test_retriever_runtime_filter(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store)
result = await retriever.run_async(filters={"field": "lang", "operator": "==", "value": "en"})
assert "documents" in result
assert len(result["documents"]) == 3
assert TestFilterRetrieverAsync._documents_equal(result["documents"], sample_docs["en_docs"])
@pytest.mark.asyncio
async def test_retriever_init_filter_run_filter_override(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "en"})
result = await retriever.run_async(filters={"field": "lang", "operator": "==", "value": "de"})
assert "documents" in result
assert len(result["documents"]) == 2
assert TestFilterRetrieverAsync._documents_equal(result["documents"], sample_docs["de_docs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_with_pipeline(self, sample_document_store, sample_docs):
retriever = FilterRetriever(sample_document_store, filters={"field": "lang", "operator": "==", "value": "de"})
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result: dict[str, Any] = await pipeline.run_async(data={"retriever": {}})
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert TestFilterRetrieverAsync._documents_equal(results_docs, sample_docs["de_docs"])
result: dict[str, Any] = await pipeline.run_async(
data={"retriever": {"filters": {"field": "lang", "operator": "==", "value": "en"}}}
)
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert TestFilterRetrieverAsync._documents_equal(results_docs, sample_docs["en_docs"])
@@ -0,0 +1,193 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import pytest
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.dataclasses import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import FilterPolicy
from haystack.testing.factory import document_store_class
@pytest.fixture()
def mock_docs():
return [
Document(content="Javascript is a popular programming language"),
Document(content="Java is a popular programming language"),
Document(content="Python is a popular programming language"),
Document(content="Ruby is a popular programming language"),
Document(content="PHP is a popular programming language"),
]
class TestMemoryBM25Retriever:
def test_init_default(self, in_memory_doc_store):
retriever = InMemoryBM25Retriever(in_memory_doc_store)
assert retriever.filters is None
assert retriever.top_k == 10
assert retriever.scale_score is False
def test_init_with_parameters(self, in_memory_doc_store):
retriever = InMemoryBM25Retriever(in_memory_doc_store, filters={"name": "test.txt"}, top_k=5, scale_score=True)
assert retriever.filters == {"name": "test.txt"}
assert retriever.top_k == 5
assert retriever.scale_score
def test_init_with_invalid_top_k_parameter(self, in_memory_doc_store):
with pytest.raises(ValueError):
InMemoryBM25Retriever(in_memory_doc_store, top_k=-2)
def test_to_dict(self):
MyFakeStore = document_store_class("MyFakeStore", bases=(InMemoryDocumentStore,))
document_store = MyFakeStore()
document_store.to_dict = lambda: {"type": "MyFakeStore", "init_parameters": {}}
component = InMemoryBM25Retriever(document_store=document_store)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {"type": "MyFakeStore", "init_parameters": {}},
"filters": None,
"top_k": 10,
"scale_score": False,
"filter_policy": "replace",
},
}
def test_to_dict_with_custom_init_parameters(self):
ds = InMemoryDocumentStore(index="test_to_dict_with_custom_init_parameters")
serialized_ds = ds.to_dict()
component = InMemoryBM25Retriever(
document_store=InMemoryDocumentStore(index="test_to_dict_with_custom_init_parameters"),
filters={"name": "test.txt"},
top_k=5,
scale_score=True,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": serialized_ds,
"filters": {"name": "test.txt"},
"top_k": 5,
"scale_score": True,
"filter_policy": "replace",
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {},
},
"filters": {"name": "test.txt"},
"top_k": 5,
},
}
component = InMemoryBM25Retriever.from_dict(data)
assert isinstance(component.document_store, InMemoryDocumentStore)
assert component.filters == {"name": "test.txt"}
assert component.top_k == 5
assert component.scale_score is False
assert component.filter_policy == FilterPolicy.REPLACE
def test_from_dict_without_docstore(self):
data = {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {},
}
with pytest.raises(TypeError, match="missing 1 required positional argument: 'document_store'"):
InMemoryBM25Retriever.from_dict(data)
def test_from_dict_without_docstore_type(self):
data = {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {"document_store": {"init_parameters": {}}},
}
with pytest.raises(TypeError, match="document_store must be an instance of InMemoryDocumentStore"):
InMemoryBM25Retriever.from_dict(data)
def test_from_dict_nonexisting_docstore(self):
# Use a type whose module passes the deserialization allowlist (haystack.*) but cannot be
# resolved, so we still exercise the "import failed" code path rather than the allowlist gate.
data = {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {"document_store": {"type": "haystack.does.not.exist.Docstore", "init_parameters": {}}},
}
with pytest.raises(
ImportError, match=r"Failed to deserialize 'document_store':.*haystack\.does\.not\.exist\.Docstore"
):
InMemoryBM25Retriever.from_dict(data)
def test_retriever_valid_run(self, in_memory_doc_store, mock_docs):
in_memory_doc_store.write_documents(mock_docs)
retriever = InMemoryBM25Retriever(in_memory_doc_store, top_k=5)
result = retriever.run(query="PHP")
assert "documents" in result
assert len(result["documents"]) == 5
assert result["documents"][0].content == "PHP is a popular programming language"
def test_invalid_run_wrong_store_type(self):
SomeOtherDocumentStore = document_store_class("SomeOtherDocumentStore")
with pytest.raises(TypeError, match="document_store must be an instance of InMemoryDocumentStore"):
InMemoryBM25Retriever(SomeOtherDocumentStore())
@pytest.mark.integration
@pytest.mark.parametrize(
"query, query_result",
[
("Javascript", "Javascript is a popular programming language"),
("Java", "Java is a popular programming language"),
],
)
def test_run_with_pipeline(self, in_memory_doc_store, mock_docs, query: str, query_result: str):
in_memory_doc_store.write_documents(mock_docs)
retriever = InMemoryBM25Retriever(in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result: dict[str, Any] = pipeline.run(data={"retriever": {"query": query}})
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert results_docs[0].content == query_result
@pytest.mark.integration
@pytest.mark.parametrize(
"query, query_result, top_k",
[
("Javascript", "Javascript is a popular programming language", 1),
("Java", "Java is a popular programming language", 2),
("Ruby", "Ruby is a popular programming language", 3),
],
)
def test_run_with_pipeline_and_top_k(
self, in_memory_doc_store, mock_docs, query: str, query_result: str, top_k: int
):
in_memory_doc_store.write_documents(mock_docs)
retriever = InMemoryBM25Retriever(in_memory_doc_store)
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result: dict[str, Any] = pipeline.run(data={"retriever": {"query": query, "top_k": top_k}})
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert len(results_docs) == top_k
assert results_docs[0].content == query_result
@@ -0,0 +1,172 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import pytest
from haystack import Pipeline
from haystack.components.retrievers.in_memory.embedding_retriever import InMemoryEmbeddingRetriever
from haystack.dataclasses import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import FilterPolicy
from haystack.testing.factory import document_store_class
class TestMemoryEmbeddingRetriever:
def test_init_default(self, in_memory_doc_store):
retriever = InMemoryEmbeddingRetriever(in_memory_doc_store)
assert retriever.filters is None
assert retriever.top_k == 10
assert retriever.scale_score is False
def test_init_with_parameters(self, in_memory_doc_store):
retriever = InMemoryEmbeddingRetriever(
in_memory_doc_store, filters={"name": "test.txt"}, top_k=5, scale_score=True
)
assert retriever.filters == {"name": "test.txt"}
assert retriever.top_k == 5
assert retriever.scale_score
def test_init_with_invalid_top_k_parameter(self, in_memory_doc_store):
with pytest.raises(ValueError):
InMemoryEmbeddingRetriever(in_memory_doc_store, top_k=-2)
def test_to_dict(self):
MyFakeStore = document_store_class("MyFakeStore", bases=(InMemoryDocumentStore,))
document_store = MyFakeStore()
document_store.to_dict = lambda: {"type": "test_module.MyFakeStore", "init_parameters": {}}
component = InMemoryEmbeddingRetriever(document_store=document_store)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {"type": "test_module.MyFakeStore", "init_parameters": {}},
"filters": None,
"top_k": 10,
"scale_score": False,
"return_embedding": False,
"filter_policy": "replace",
},
}
def test_to_dict_with_custom_init_parameters(self):
MyFakeStore = document_store_class("MyFakeStore", bases=(InMemoryDocumentStore,))
document_store = MyFakeStore()
document_store.to_dict = lambda: {"type": "test_module.MyFakeStore", "init_parameters": {}}
component = InMemoryEmbeddingRetriever(
document_store=document_store,
filters={"name": "test.txt"},
top_k=5,
scale_score=True,
return_embedding=True,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {"type": "test_module.MyFakeStore", "init_parameters": {}},
"filters": {"name": "test.txt"},
"top_k": 5,
"scale_score": True,
"return_embedding": True,
"filter_policy": "replace",
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {},
},
"filters": {"name": "test.txt"},
"top_k": 5,
"filter_policy": "merge",
},
}
component = InMemoryEmbeddingRetriever.from_dict(data)
assert isinstance(component.document_store, InMemoryDocumentStore)
assert component.filters == {"name": "test.txt"}
assert component.top_k == 5
assert component.scale_score is False
assert component.filter_policy == FilterPolicy.MERGE
def test_from_dict_without_docstore(self):
data = {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {},
}
with pytest.raises(TypeError, match="missing 1 required positional argument: 'document_store'"):
InMemoryEmbeddingRetriever.from_dict(data)
def test_from_dict_without_docstore_type(self):
data = {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {"document_store": {"init_parameters": {}}},
}
with pytest.raises(TypeError, match="document_store must be an instance of InMemoryDocumentStore"):
InMemoryEmbeddingRetriever.from_dict(data)
def test_from_dict_nonexisting_docstore(self):
# Use a type whose module passes the deserialization allowlist (haystack.*) but cannot be
# resolved, so we still exercise the "import failed" code path rather than the allowlist gate.
data = {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {"document_store": {"type": "haystack.does.not.exist.Docstore", "init_parameters": {}}},
}
with pytest.raises(
ImportError, match=r"Failed to deserialize 'document_store':.*haystack\.does\.not\.exist\.Docstore"
):
InMemoryEmbeddingRetriever.from_dict(data)
def test_valid_run(self):
top_k = 3
ds = InMemoryDocumentStore(embedding_similarity_function="cosine")
docs = [
Document(content="my document", embedding=[0.1, 0.2, 0.3, 0.4]),
Document(content="another document", embedding=[1.0, 1.0, 1.0, 1.0]),
Document(content="third document", embedding=[0.5, 0.7, 0.5, 0.7]),
]
ds.write_documents(docs)
retriever = InMemoryEmbeddingRetriever(ds, top_k=top_k)
result = retriever.run(query_embedding=[0.1, 0.1, 0.1, 0.1], return_embedding=True)
assert "documents" in result
assert len(result["documents"]) == top_k
assert result["documents"][0].embedding == [1.0, 1.0, 1.0, 1.0]
def test_invalid_run_wrong_store_type(self):
SomeOtherDocumentStore = document_store_class("SomeOtherDocumentStore")
with pytest.raises(TypeError, match="document_store must be an instance of InMemoryDocumentStore"):
InMemoryEmbeddingRetriever(SomeOtherDocumentStore())
@pytest.mark.integration
def test_run_with_pipeline(self):
ds = InMemoryDocumentStore(embedding_similarity_function="cosine")
top_k = 2
docs = [
Document(content="my document", embedding=[0.1, 0.2, 0.3, 0.4]),
Document(content="another document", embedding=[1.0, 1.0, 1.0, 1.0]),
Document(content="third document", embedding=[0.5, 0.7, 0.5, 0.7]),
]
ds.write_documents(docs)
retriever = InMemoryEmbeddingRetriever(ds, top_k=top_k)
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result: dict[str, Any] = pipeline.run(
data={"retriever": {"query_embedding": [0.1, 0.1, 0.1, 0.1], "return_embedding": True}}
)
assert result
assert "retriever" in result
results_docs = result["retriever"]["documents"]
assert results_docs
assert len(results_docs) == top_k
assert results_docs[0].embedding == [1.0, 1.0, 1.0, 1.0]
@@ -0,0 +1,345 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock
import numpy as np
import pytest
from haystack import Document, Pipeline, component
from haystack.components.embedders import OpenAIDocumentEmbedder, OpenAITextEmbedder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.query import QueryExpander
from haystack.components.retrievers import InMemoryEmbeddingRetriever, MultiQueryEmbeddingRetriever
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
@component
class MockQueryEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
class TestMultiQueryEmbeddingRetriever:
@pytest.fixture
def sample_documents(self):
return [
Document(
content="Renewable energy is energy that is collected from renewable resources.",
meta={"category": None},
),
Document(
content="Solar energy is a type of green energy that is harnessed from the sun.",
meta={"category": "solar"},
),
Document(
content="Wind energy is another type of green energy that is generated by wind turbines",
meta={"category": "wind"},
),
Document(
content="Hydropower is a form of renewable energy using the flow of water to generate electricity.",
meta={"category": "hydro"},
),
Document(
content="Geothermal energy is heat that comes from the sub-surface of the earth.",
meta={"category": "geo"},
),
Document(
content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources.",
meta={"category": "fossil"},
),
Document(
content="Nuclear energy is produced through nuclear reactions, typically using uranium or "
"plutonium as fuel.",
meta={"category": "nuclear"},
),
]
@pytest.fixture
def document_store_with_embeddings(self, sample_documents):
"""Create a document store populated with embedded documents."""
document_store = InMemoryDocumentStore()
doc_embedder = OpenAIDocumentEmbedder()
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
embedded_docs = doc_embedder.run(sample_documents)["documents"]
doc_writer.run(documents=embedded_docs)
return document_store
def test_init_with_default_parameters(self, in_memory_doc_store):
embedding_retriever = InMemoryEmbeddingRetriever(document_store=in_memory_doc_store)
query_embedder = MockQueryEmbedder()
retriever = MultiQueryEmbeddingRetriever(retriever=embedding_retriever, query_embedder=query_embedder)
assert retriever.retriever == embedding_retriever
assert retriever.query_embedder == query_embedder
assert retriever.max_workers == 3
def test_init_with_custom_parameters(self, in_memory_doc_store):
embedding_retriever = InMemoryEmbeddingRetriever(document_store=in_memory_doc_store)
query_embedder = MockQueryEmbedder()
retriever = MultiQueryEmbeddingRetriever(
retriever=embedding_retriever, query_embedder=query_embedder, max_workers=2
)
assert retriever.retriever == embedding_retriever
assert retriever.query_embedder == query_embedder
assert retriever.max_workers == 2
def test_run_with_empty_queries(self, in_memory_doc_store):
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=in_memory_doc_store), query_embedder=MockQueryEmbedder()
)
result = multi_retriever.run(queries=[])
assert "documents" in result
assert result["documents"] == []
def test_run_with_empty_results(self, in_memory_doc_store):
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=in_memory_doc_store), query_embedder=MockQueryEmbedder()
)
result = multi_retriever.run(queries=["query"])
assert "documents" in result
assert result["documents"] == []
def test_to_dict(self, in_memory_doc_store):
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=in_memory_doc_store),
query_embedder=MockQueryEmbedder(),
max_workers=2,
)
result = multi_retriever.to_dict()
assert result == {
"type": "haystack.components.retrievers.multi_query_embedding_retriever.MultiQueryEmbeddingRetriever",
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": ANY,
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"return_embedding": False,
"filter_policy": "replace",
},
},
"query_embedder": {
"type": "retrievers.test_multi_query_embedding_retriever.MockQueryEmbedder",
"init_parameters": {},
},
"max_workers": 2,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.retrievers.multi_query_embedding_retriever.MultiQueryEmbeddingRetriever", # noqa E501
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "4bb5369d-779f-487b-9c16-3c40f503438b",
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"return_embedding": False,
"filter_policy": "replace",
},
},
"query_embedder": {
"type": "haystack.components.embedders.openai_text_embedder.OpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["OPENAI_API_KEY"], "strict": True, "type": "env_var"},
"api_base_url": None,
"dimensions": None,
"model": "text-embedding-ada-002",
"organization": None,
"http_client_kwargs": None,
"prefix": "",
"suffix": "",
"timeout": None,
"max_retries": None,
},
},
"max_workers": 2,
},
}
result = MultiQueryEmbeddingRetriever.from_dict(data)
assert isinstance(result, MultiQueryEmbeddingRetriever)
assert result.max_workers == 2
def test_deduplication_with_overlapping_results(self):
doc1 = Document(content="Solar energy is renewable", id="doc1", score=0.9)
doc2 = Document(content="Wind energy is clean", id="doc2", score=0.8)
# same as doc1 w/ different score
doc3 = Document(content="Solar energy is renewable", id="doc1", score=0.7)
call_count = 0
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self,
query_embedding: list[float],
filters: dict[str, Any] | None = None,
top_k: int | None = None,
**kwargs: Any,
) -> dict[str, list[Document]]:
nonlocal call_count
if call_count == 1:
return {"documents": [doc1, doc2]}
return {"documents": [doc3, doc2]}
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=MockRetriever(), query_embedder=MockQueryEmbedder(), max_workers=1
)
result = multi_retriever.run(queries=["query1", "query2"])
assert "documents" in result
assert len(result["documents"]) == 2 # Only 2 unique documents (doc1/doc3 and doc2)
contents = [doc.content for doc in result["documents"]]
assert contents.count("Solar energy is renewable") == 1
assert contents.count("Wind energy is clean") == 1
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_filters(self, document_store_with_embeddings):
in_memory_retriever = InMemoryEmbeddingRetriever(document_store=document_store_with_embeddings)
query_embedder = OpenAITextEmbedder()
multi_retriever = MultiQueryEmbeddingRetriever(retriever=in_memory_retriever, query_embedder=query_embedder)
result = multi_retriever.run(["energy"], {"filters": {"field": "category", "operator": "==", "value": "solar"}})
assert "documents" in result
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_pipeline_integration(self, document_store_with_embeddings):
expander = QueryExpander(
chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"), n_expansions=3, include_original_query=True
)
in_memory_retriever = InMemoryEmbeddingRetriever(document_store=document_store_with_embeddings)
query_embedder = OpenAITextEmbedder()
multiquery_retriever = MultiQueryEmbeddingRetriever(
retriever=in_memory_retriever, query_embedder=query_embedder, max_workers=3
)
pipeline = Pipeline()
pipeline.add_component("query_expander", expander)
pipeline.add_component("multiquery_retriever", multiquery_retriever)
pipeline.connect("query_expander.queries", "multiquery_retriever.queries")
data = {
"query_expander": {"query": "green energy sources"},
"multiquery_retriever": {"retriever_kwargs": {"top_k": 3}},
}
results = pipeline.run(data=data, include_outputs_from={"query_expander", "multiquery_retriever"})
assert "multiquery_retriever" in results
assert "documents" in results["multiquery_retriever"]
assert len(results["multiquery_retriever"]["documents"]) > 0
assert "query_expander" in results
assert "queries" in results["query_expander"]
assert len(results["query_expander"]["queries"]) == 4
# assert that documents are sorted by score (highest first)
scores = [doc.score for doc in results["multiquery_retriever"]["documents"] if doc.score is not None]
assert scores == sorted(scores, reverse=True)
# assert there are not duplicates
ids = [doc.id for doc in results["multiquery_retriever"]["documents"]]
assert len(ids) == len(set(ids))
class TestComponentLifecycle:
def test_warm_up_delegates_to_inner_components(self):
query_embedder = Mock(spec=["run", "warm_up"])
retriever = Mock(spec=["run", "warm_up"])
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
component.warm_up()
query_embedder.warm_up.assert_called_once()
retriever.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_inner_components(self):
query_embedder = Mock(spec=["run", "warm_up_async"])
query_embedder.warm_up_async = AsyncMock()
retriever = Mock(spec=["run", "warm_up_async"])
retriever.warm_up_async = AsyncMock()
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
await component.warm_up_async()
query_embedder.warm_up_async.assert_awaited_once()
retriever.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
query_embedder = Mock(spec=["run", "warm_up"])
retriever = Mock(spec=["run", "warm_up"])
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
await component.warm_up_async()
query_embedder.warm_up.assert_called_once()
retriever.warm_up.assert_called_once()
def test_close_delegates_to_inner_components(self):
query_embedder = Mock(spec=["run", "close"])
retriever = Mock(spec=["run", "close"])
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
component.close()
query_embedder.close.assert_called_once()
retriever.close.assert_called_once()
async def test_close_async_delegates_to_inner_components(self):
query_embedder = Mock(spec=["run", "close_async"])
query_embedder.close_async = AsyncMock()
retriever = Mock(spec=["run", "close_async"])
retriever.close_async = AsyncMock()
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
await component.close_async()
query_embedder.close_async.assert_awaited_once()
retriever.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
query_embedder = Mock(spec=["run", "close"])
retriever = Mock(spec=["run", "close"])
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
await component.close_async()
query_embedder.close.assert_called_once()
retriever.close.assert_called_once()
async def test_lifecycle_is_safe_when_inner_components_lack_methods(self):
query_embedder = Mock(spec=["run"])
retriever = Mock(spec=["run"])
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
component.warm_up()
await component.warm_up_async()
component.close()
await component.close_async()
@@ -0,0 +1,214 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import numpy as np
import pytest
from haystack import Document, Pipeline, component
from haystack.components.retrievers import InMemoryEmbeddingRetriever, MultiQueryEmbeddingRetriever
from haystack.document_stores.in_memory import InMemoryDocumentStore
@component
class MockQueryEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
@component.output_types(embedding=list[float])
async def run_async(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
class TestMultiQueryEmbeddingRetrieverAsync:
@pytest.mark.asyncio
async def test_run_async_with_empty_queries(self):
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
query_embedder=MockQueryEmbedder(),
)
result = await multi_retriever.run_async(queries=[])
assert "documents" in result
assert result["documents"] == []
@pytest.mark.asyncio
async def test_run_async_returns_documents_sorted_by_score(self):
doc_high = Document(content="Solar energy", id="doc1", score=0.9)
doc_low = Document(content="Fossil fuels", id="doc2", score=0.3)
doc_mid = Document(content="Wind energy", id="doc3", score=0.6)
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self,
query_embedding: list[float],
filters: dict[str, Any] | None = None,
top_k: int | None = None,
**kwargs: Any,
) -> dict[str, list[Document]]:
return {"documents": [doc_low, doc_high, doc_mid]}
@component.output_types(documents=list[Document])
async def run_async(
self,
query_embedding: list[float],
filters: dict[str, Any] | None = None,
top_k: int | None = None,
**kwargs: Any,
) -> dict[str, list[Document]]:
return {"documents": [doc_low, doc_high, doc_mid]}
multi_retriever = MultiQueryEmbeddingRetriever(retriever=MockRetriever(), query_embedder=MockQueryEmbedder())
result = await multi_retriever.run_async(queries=["query1", "query2"])
scores = [doc.score for doc in result["documents"]]
assert scores == sorted(scores, reverse=True)
@pytest.mark.asyncio
async def test_run_async_deduplication(self):
doc2 = Document(content="Wind energy is clean", id="doc2", score=0.8)
# doc3 intentionally uses the duplicate id "doc1" to simulate deduplication across multiple queries
doc3 = Document(content="Solar energy is renewable", id="doc1", score=0.7)
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self,
query_embedding: list[float],
filters: dict[str, Any] | None = None,
top_k: int | None = None,
**kwargs: Any,
) -> dict[str, list[Document]]:
return {"documents": [doc3, doc2]}
@component.output_types(documents=list[Document])
async def run_async(
self,
query_embedding: list[float],
filters: dict[str, Any] | None = None,
top_k: int | None = None,
**kwargs: Any,
) -> dict[str, list[Document]]:
return {"documents": [doc3, doc2]}
multi_retriever = MultiQueryEmbeddingRetriever(retriever=MockRetriever(), query_embedder=MockQueryEmbedder())
result = await multi_retriever.run_async(queries=["query1", "query2"])
assert "documents" in result
assert len(result["documents"]) == 2
contents = [doc.content for doc in result["documents"]]
assert contents.count("Solar energy is renewable") == 1
assert contents.count("Wind energy is clean") == 1
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_when_no_run_async(self, document_store_with_categorized_docs):
@component
class SyncOnlyEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_categorized_docs),
query_embedder=SyncOnlyEmbedder(),
)
result = await multi_retriever.run_async(queries=["query"])
assert "documents" in result
assert len(result["documents"]) > 0
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_retriever_when_no_run_async(self):
@component
class SyncOnlyRetriever:
@component.output_types(documents=list[Document])
def run(
self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None
) -> dict[str, list[Document]]:
return {"documents": [Document(content="Solar energy", id="doc1", score=0.9)]}
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=SyncOnlyRetriever(), query_embedder=MockQueryEmbedder()
)
result = await multi_retriever.run_async(queries=["query1", "query2"])
assert "documents" in result
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Solar energy"
@pytest.fixture
def document_store_with_categorized_docs(self):
documents = [
Document(
content="Solar energy is harnessed from the sun.",
embedding=np.ones(384).tolist(),
meta={"category": "solar"},
),
Document(
content="Solar panels convert sunlight into electricity.",
embedding=np.ones(384).tolist(),
meta={"category": "solar"},
),
Document(
content="Photovoltaic cells are the building blocks of solar panels.",
embedding=np.ones(384).tolist(),
meta={"category": "solar"},
),
Document(
content="Wind energy is generated by wind turbines.",
embedding=np.ones(384).tolist(),
meta={"category": "wind"},
),
Document(
content="Geothermal energy comes from the sub-surface of the earth.",
embedding=np.ones(384).tolist(),
meta={"category": "geo"},
),
Document(
content="Renewable energy is collected from renewable resources.",
embedding=np.ones(384).tolist(),
meta={"category": "renewable"},
),
Document(
content="Hydropower uses the flow of water to generate electricity.",
embedding=np.ones(384).tolist(),
meta={"category": "hydro"},
),
]
document_store = InMemoryDocumentStore()
document_store.write_documents(documents)
return document_store
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_filters(self, document_store_with_categorized_docs):
in_memory_retriever = InMemoryEmbeddingRetriever(document_store=document_store_with_categorized_docs)
filters = {"field": "category", "operator": "==", "value": "solar"}
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=in_memory_retriever, query_embedder=MockQueryEmbedder()
)
result = await multi_retriever.run_async(
queries=["energy", "sunlight", "photovoltaic"], retriever_kwargs={"filters": filters}
)
assert "documents" in result
assert len(result["documents"]) > 0
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_pipeline(self):
multi_retriever = MultiQueryEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
query_embedder=MockQueryEmbedder(),
)
pipeline = Pipeline()
pipeline.add_component("retriever", multi_retriever)
result = await pipeline.run_async(data={"retriever": {"queries": ["green energy", "solar power"]}})
assert result
assert "retriever" in result
assert "documents" in result["retriever"]
assert result["retriever"]["documents"] == []
@@ -0,0 +1,242 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from unittest.mock import ANY, AsyncMock, Mock
import pytest
from haystack import Document, Pipeline
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.query import QueryExpander
from haystack.components.retrievers import InMemoryBM25Retriever, MultiQueryTextRetriever
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
class TestMultiQueryTextRetriever:
@pytest.fixture
def sample_documents(self):
return [
Document(
content="Renewable energy is energy that is collected from renewable resources.",
meta={"category": None},
),
Document(
content="Solar energy is a type of green energy that is harnessed from the sun.",
meta={"category": "solar"},
),
Document(
content="Wind energy is another type of green energy that is generated by wind turbines",
meta={"category": "wind"},
),
Document(
content="Hydropower is a form of renewable energy using the flow of water to generate electricity.",
meta={"category": "hydro"},
),
Document(
content="Geothermal energy is heat that comes from the sub-surface of the earth.",
meta={"category": "geo"},
),
Document(
content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources.",
meta={"category": "fossil"},
),
Document(
content="Nuclear energy is produced through nuclear reactions, typically using uranium or plutonium "
"as fuel.",
meta={"category": "nuclear"},
),
]
@pytest.fixture
def document_store_with_docs(self, sample_documents):
document_store = InMemoryDocumentStore()
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
doc_writer.run(documents=sample_documents)
return document_store
def test_init_with_default_parameters(self, in_memory_doc_store):
in_memory_retriever = InMemoryBM25Retriever(document_store=in_memory_doc_store)
retriever = MultiQueryTextRetriever(retriever=in_memory_retriever)
assert retriever.retriever == in_memory_retriever
assert retriever.max_workers == 3
def test_init_with_custom_parameters(self, in_memory_doc_store):
in_memory_retriever = InMemoryBM25Retriever(document_store=in_memory_doc_store)
retriever = MultiQueryTextRetriever(retriever=in_memory_retriever, max_workers=2)
assert retriever.retriever == in_memory_retriever
assert retriever.max_workers == 2
def test_to_dict(self, in_memory_doc_store):
in_memory_retriever = InMemoryBM25Retriever(document_store=in_memory_doc_store)
multi_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever, max_workers=2)
result = multi_retriever.to_dict()
assert result == {
"type": "haystack.components.retrievers.multi_query_text_retriever.MultiQueryTextRetriever",
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": ANY,
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"filter_policy": "replace",
},
},
"max_workers": 2,
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.multi_query_text_retriever.MultiQueryTextRetriever",
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "88144fa9-6e45-4e5d-8647-4c4002d8b6db",
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"filter_policy": "replace",
},
},
"max_workers": 3,
},
}
result = MultiQueryTextRetriever.from_dict(data)
assert isinstance(result, MultiQueryTextRetriever)
assert result.retriever.__class__.__name__ == "InMemoryBM25Retriever"
assert result.max_workers == 3
def test_run_with_multiple_queries(self, document_store_with_docs):
in_memory_retriever = InMemoryBM25Retriever(document_store=document_store_with_docs)
multi_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever)
queries = ["renewable energy", "solar power", "wind turbines"]
result = multi_retriever.run(queries=queries)
assert "documents" in result
assert len(result["documents"]) > 0
assert all(isinstance(doc, Document) for doc in result["documents"])
scores = [doc.score for doc in result["documents"] if doc.score is not None]
assert scores == sorted(scores, reverse=True)
@pytest.mark.integration
def test_run_with_filters(self, document_store_with_docs):
in_memory_retriever = InMemoryBM25Retriever(document_store=document_store_with_docs)
filters = {"field": "category", "operator": "==", "value": "solar"}
multi_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever)
result = multi_retriever.run(queries=["energy"], retriever_kwargs={"filters": filters})
assert "documents" in result
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
def test_pipeline_integration(self, document_store_with_docs):
pipeline = Pipeline()
expander = QueryExpander(
chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"), n_expansions=3, include_original_query=True
)
in_memory_retriever = InMemoryBM25Retriever(document_store=document_store_with_docs)
multiquery_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever, max_workers=3)
pipeline.add_component("query_expander", expander)
pipeline.add_component("multiquery_retriever", multiquery_retriever)
pipeline.connect("query_expander.queries", "multiquery_retriever.queries")
data = {
"query_expander": {"query": "green energy sources"},
"multiquery_retriever": {"retriever_kwargs": {"top_k": 3}},
}
results = pipeline.run(data=data, include_outputs_from={"query_expander", "multiquery_retriever"})
assert "multiquery_retriever" in results
assert "documents" in results["multiquery_retriever"]
assert len(results["multiquery_retriever"]["documents"]) > 0
assert "query_expander" in results
assert "queries" in results["query_expander"]
assert len(results["query_expander"]["queries"]) == 4
# assert that documents are sorted by score (highest first)
scores = [doc.score for doc in results["multiquery_retriever"]["documents"] if doc.score is not None]
assert scores == sorted(scores, reverse=True)
# assert there are not duplicates
contents = [doc.content for doc in results["multiquery_retriever"]["documents"]]
assert len(contents) == len(set(contents))
class TestComponentLifecycle:
def test_warm_up_delegates_to_retriever(self):
retriever = Mock(spec=["run", "warm_up"])
component = MultiQueryTextRetriever(retriever=retriever)
component.warm_up()
retriever.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_retriever(self):
retriever = Mock(spec=["run", "warm_up_async"])
retriever.warm_up_async = AsyncMock()
component = MultiQueryTextRetriever(retriever=retriever)
await component.warm_up_async()
retriever.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
retriever = Mock(spec=["run", "warm_up"])
component = MultiQueryTextRetriever(retriever=retriever)
await component.warm_up_async()
retriever.warm_up.assert_called_once()
def test_close_delegates_to_retriever(self):
retriever = Mock(spec=["run", "close"])
component = MultiQueryTextRetriever(retriever=retriever)
component.close()
retriever.close.assert_called_once()
async def test_close_async_delegates_to_retriever(self):
retriever = Mock(spec=["run", "close_async"])
retriever.close_async = AsyncMock()
component = MultiQueryTextRetriever(retriever=retriever)
await component.close_async()
retriever.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
retriever = Mock(spec=["run", "close"])
component = MultiQueryTextRetriever(retriever=retriever)
await component.close_async()
retriever.close.assert_called_once()
async def test_lifecycle_is_safe_when_retriever_lacks_methods(self):
retriever = Mock(spec=["run"])
component = MultiQueryTextRetriever(retriever=retriever)
component.warm_up()
await component.warm_up_async()
component.close()
await component.close_async()
@@ -0,0 +1,145 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import pytest
from haystack import Document, Pipeline, component
from haystack.components.retrievers import InMemoryBM25Retriever, MultiQueryTextRetriever
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
class TestMultiQueryTextRetrieverAsync:
@pytest.fixture
def sample_documents(self):
return [
Document(
content="Solar energy is a type of green energy that is harnessed from the sun.",
meta={"category": "solar"},
),
Document(content="Solar panels convert sunlight directly into electricity.", meta={"category": "solar"}),
Document(content="Photovoltaic cells are the building blocks of solar panels.", meta={"category": "solar"}),
Document(
content="Wind energy is another type of green energy that is generated by wind turbines.",
meta={"category": "wind"},
),
Document(
content="Geothermal energy is heat that comes from the sub-surface of the earth.",
meta={"category": "geo"},
),
Document(
content="Renewable energy is energy that is collected from renewable resources.",
meta={"category": "renewable"},
),
Document(
content="Hydropower is a form of renewable energy using the flow of water.", meta={"category": "hydro"}
),
]
@pytest.fixture
def document_store_with_docs(self, sample_documents):
document_store = InMemoryDocumentStore()
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
doc_writer.run(documents=sample_documents)
return document_store
@pytest.mark.asyncio
async def test_run_async_with_empty_queries(self, document_store_with_docs):
multi_retriever = MultiQueryTextRetriever(
retriever=InMemoryBM25Retriever(document_store=document_store_with_docs)
)
result = await multi_retriever.run_async(queries=[])
assert "documents" in result
assert result["documents"] == []
@pytest.mark.asyncio
async def test_run_async_with_multiple_queries(self, document_store_with_docs):
multi_retriever = MultiQueryTextRetriever(
retriever=InMemoryBM25Retriever(document_store=document_store_with_docs)
)
result = await multi_retriever.run_async(queries=["renewable energy", "solar power"])
assert "documents" in result
assert len(result["documents"]) > 0
assert all(isinstance(doc, Document) for doc in result["documents"])
scores = [doc.score for doc in result["documents"] if doc.score is not None]
assert scores == sorted(scores, reverse=True)
@pytest.mark.asyncio
async def test_run_async_deduplication(self):
doc2 = Document(content="Wind energy is clean", id="doc2", score=0.8)
# doc3 intentionally uses the duplicate id "doc1" to simulate deduplication across multiple queries
doc3 = Document(content="Solar energy is renewable", id="doc1", score=0.7)
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None, **kwargs: Any
) -> dict[str, list[Document]]:
return {"documents": [doc3, doc2]}
@component.output_types(documents=list[Document])
async def run_async(
self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None, **kwargs: Any
) -> dict[str, list[Document]]:
return {"documents": [doc3, doc2]}
multi_retriever = MultiQueryTextRetriever(retriever=MockRetriever())
result = await multi_retriever.run_async(queries=["query1", "query2"])
assert "documents" in result
assert len(result["documents"]) == 2
contents = [doc.content for doc in result["documents"]]
assert contents.count("Solar energy is renewable") == 1
assert contents.count("Wind energy is clean") == 1
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_when_no_run_async(self):
@component
class SyncOnlyRetriever:
@component.output_types(documents=list[Document])
def run(
self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None
) -> dict[str, list[Document]]:
return {"documents": [Document(content="Renewable energy", id="doc1", score=0.9)]}
multi_retriever = MultiQueryTextRetriever(retriever=SyncOnlyRetriever())
result = await multi_retriever.run_async(queries=["query"])
assert "documents" in result
assert len(result["documents"]) == 1
assert result["documents"][0].content == "Renewable energy"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_filters(self, document_store_with_docs):
in_memory_retriever = InMemoryBM25Retriever(document_store=document_store_with_docs)
filters = {"field": "category", "operator": "==", "value": "solar"}
multi_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever)
result = await multi_retriever.run_async(
queries=["energy", "sunlight", "photovoltaic"], retriever_kwargs={"filters": filters}
)
assert "documents" in result
assert len(result["documents"]) > 0
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_pipeline(self, document_store_with_docs):
multi_retriever = MultiQueryTextRetriever(
retriever=InMemoryBM25Retriever(document_store=document_store_with_docs)
)
pipeline = Pipeline()
pipeline.add_component("retriever", multi_retriever)
result = await pipeline.run_async(data={"retriever": {"queries": ["renewable energy", "solar power"]}})
assert result
assert "retriever" in result
assert "documents" in result["retriever"]
assert len(result["retriever"]["documents"]) > 0
scores = [doc.score for doc in result["retriever"]["documents"] if doc.score is not None]
assert scores == sorted(scores, reverse=True)
@@ -0,0 +1,661 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock
import pytest
from haystack import Document, component
from haystack.components.embedders import OpenAIDocumentEmbedder, OpenAITextEmbedder
from haystack.components.retrievers import (
InMemoryBM25Retriever,
InMemoryEmbeddingRetriever,
MultiRetriever,
TextEmbeddingRetriever,
)
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
from haystack.utils.experimental import ExperimentalWarning
pytestmark = pytest.mark.filterwarnings("ignore::haystack.utils.experimental.ExperimentalWarning")
@component
class MockRetriever:
def __init__(self, documents: list[Document] | None = None):
self.documents = documents or []
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
return {"documents": self.documents}
@component
class FailingRetriever:
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
raise RuntimeError("connection error")
@pytest.fixture
def sample_documents():
return [
Document(
content="Renewable energy is energy that is collected from renewable resources.",
meta={"category": "renewable"},
id="doc1",
),
Document(
content="Solar energy is a type of green energy that is harnessed from the sun.",
meta={"category": "solar"},
id="doc2",
),
Document(
content="Wind energy is another type of green energy that is generated by wind turbines.",
meta={"category": "wind"},
id="doc3",
),
Document(
content="Geothermal energy is heat that comes from the sub-surface of the earth.",
meta={"category": "geothermal"},
id="doc4",
),
Document(
content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources.",
meta={"category": "fossil"},
id="doc5",
),
]
@pytest.fixture
def document_store_with_embeddings(sample_documents):
"""Create a document store populated with embedded documents."""
document_store = InMemoryDocumentStore()
doc_embedder = OpenAIDocumentEmbedder()
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
embedded_docs = doc_embedder.run(sample_documents)["documents"]
doc_writer.run(documents=embedded_docs)
return document_store
@pytest.fixture
def bm25_retriever(document_store_with_embeddings):
return InMemoryBM25Retriever(document_store=document_store_with_embeddings)
@pytest.fixture
def embedding_retriever(document_store_with_embeddings):
return TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_embeddings),
text_embedder=OpenAITextEmbedder(),
)
class TestMultiRetriever:
def test_init_default_parameters(self):
retrievers = {"mock": MockRetriever()}
retriever = MultiRetriever(retrievers=retrievers)
assert retriever.retrievers == retrievers
assert retriever.filters is None
assert retriever.top_k_per_retriever is None
assert retriever.top_k is None
assert retriever.max_workers == 4
assert retriever.join_mode == "reciprocal_rank_fusion"
def test_init_custom_parameters(self):
retrievers = {"mock": MockRetriever()}
retriever = MultiRetriever(
retrievers=retrievers, filters={"field": "meta.category"}, top_k=5, max_workers=2, join_mode="concatenate"
)
assert retriever.retrievers == retrievers
assert retriever.filters == {"field": "meta.category"}
assert retriever.top_k == 5
assert retriever.max_workers == 2
assert retriever.join_mode == "concatenate"
def test_run_rrf_assigns_scores_and_sorts(self, sample_documents):
docs_a = [sample_documents[0], sample_documents[1], sample_documents[2]]
docs_b = [sample_documents[2], sample_documents[0], sample_documents[3]]
retriever = MultiRetriever(
retrievers={"a": MockRetriever(docs_a), "b": MockRetriever(docs_b)}, join_mode="reciprocal_rank_fusion"
)
result = retriever.run(query="energy")
assert all(doc.score is not None for doc in result["documents"])
scores = [doc.score for doc in result["documents"]]
assert scores == sorted(scores, reverse=True)
# doc1 ranked 1st in a and 2nd in b, doc3 ranked 3rd in a and 1st in b — doc1 should beat doc3
ids = [doc.id for doc in result["documents"]]
assert ids.index("doc1") < ids.index("doc3")
def test_run_with_empty_document_store(self):
retriever = MultiRetriever(retrievers={"mock": MockRetriever()})
result = retriever.run(query="green energy")
assert "documents" in result
assert result["documents"] == []
def test_run_combines_results_from_multiple_retrievers(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=[sample_documents[0]]),
"b": MockRetriever(documents=[sample_documents[1]]),
},
max_workers=2,
)
result = retriever.run(query="energy")
assert len(result["documents"]) == 2
assert {doc.id for doc in result["documents"]} == {"doc1", "doc2"}
def test_run_deduplicates_results(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"c": MockRetriever(documents=[sample_documents[0], sample_documents[1]]),
"d": MockRetriever(documents=[sample_documents[0]]),
},
max_workers=2,
)
result = retriever.run(query="energy")
assert len(result["documents"]) == 2
ids = [doc.id for doc in result["documents"]]
assert ids.count("doc1") == 1
def test_run_resolves_filters_and_top_k_per_retriever(self):
received: dict = {}
@component
class CapturingRetriever:
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
received["filters"] = filters
received["top_k"] = top_k
return {"documents": []}
retriever = MultiRetriever(
retrievers={"capturing": CapturingRetriever()}, filters={"field": "meta.category"}, top_k_per_retriever=5
)
# Should use init-time values when not overridden (top_k_per_retriever is forwarded as the retriever's top_k)
retriever.run(query="energy")
assert received["filters"] == {"field": "meta.category"}
assert received["top_k"] == 5
# Should prefer run-time values when provided
retriever.run(query="energy", filters={"field": "meta.other"}, top_k_per_retriever=2)
assert received["filters"] == {"field": "meta.other"}
assert received["top_k"] == 2
def test_run_forwards_top_k_per_retriever_not_overall_top_k(self):
received: dict = {}
@component
class CapturingRetriever:
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
received["top_k"] = top_k
return {"documents": []}
retriever = MultiRetriever(retrievers={"capturing": CapturingRetriever()})
# top_k_per_retriever is forwarded to each retriever as its top_k
retriever.run(query="energy", top_k_per_retriever=3)
assert received["top_k"] == 3
# the overall top_k is applied at merge-time only, not forwarded to retrievers
received.clear()
retriever.run(query="energy", top_k=5)
assert received.get("top_k") is None
def test_run_top_k_truncates_merged_results(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=sample_documents[:3]),
"b": MockRetriever(documents=sample_documents[2:5]),
},
max_workers=2,
)
result = retriever.run(query="energy", top_k=2)
assert len(result["documents"]) == 2
scores = [doc.score for doc in result["documents"]]
assert all(score is not None for score in scores)
assert scores == sorted(scores, reverse=True)
def test_run_top_k_forces_rrf_in_concatenate_mode(self, sample_documents):
# In concatenate mode there is no global ranking, so setting top_k falls back to RRF to truncate consistently
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=sample_documents[:3]),
"b": MockRetriever(documents=sample_documents[1:4]),
},
join_mode="concatenate",
max_workers=2,
)
result = retriever.run(query="energy", top_k=2)
assert len(result["documents"]) == 2
assert all(doc.score is not None for doc in result["documents"])
def test_run_with_active_retrievers(self, sample_documents):
retriever = MultiRetriever(
retrievers={"a": MockRetriever([sample_documents[0]]), "b": MockRetriever([sample_documents[1]])}
)
# Only run retriever "a"
result = retriever.run(query="energy", active_retrievers=["a"])
assert len(result["documents"]) == 1
assert result["documents"][0].id == "doc1"
def test_run_with_unknown_active_retriever_raises(self):
retriever = MultiRetriever(retrievers={"mock": MockRetriever()})
with pytest.raises(ValueError, match="Unknown retriever name"):
retriever.run(query="energy", active_retrievers=["nonexistent"])
def test_run_retriever_failure_raises_with_name(self):
retriever = MultiRetriever(retrievers={"failing": FailingRetriever()})
with pytest.raises(RuntimeError, match="Retriever 'failing' failed"):
retriever.run(query="energy")
def test_to_dict(self):
retriever = MultiRetriever(
retrievers={"bm25": InMemoryBM25Retriever(document_store=InMemoryDocumentStore())},
filters=None,
top_k_per_retriever=3,
top_k=5,
max_workers=2,
)
result = retriever.to_dict()
assert result == {
"type": "haystack.components.retrievers.multi_retriever.MultiRetriever",
"init_parameters": {
"retrievers": {
"bm25": {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": ANY,
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"filter_policy": "replace",
},
}
},
"filters": None,
"top_k_per_retriever": 3,
"top_k": 5,
"max_workers": 2,
"join_mode": "reciprocal_rank_fusion",
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.multi_retriever.MultiRetriever",
"init_parameters": {
"retrievers": {
"bm25": {
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "4bb5369d-779f-487b-9c16-3c40f503438b",
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"filter_policy": "replace",
},
}
},
"filters": None,
"top_k_per_retriever": 3,
"top_k": 5,
"max_workers": 2,
"join_mode": "concatenate",
},
}
result = MultiRetriever.from_dict(data)
assert isinstance(result, MultiRetriever)
assert len(result.retrievers) == 1
assert "bm25" in result.retrievers
assert isinstance(result.retrievers["bm25"], InMemoryBM25Retriever)
assert result.top_k_per_retriever == 3
assert result.top_k == 5
assert result.max_workers == 2
assert result.join_mode == "concatenate"
def test_from_dict_with_no_retrievers(self):
data = {
"type": "haystack.components.retrievers.multi_retriever.MultiRetriever",
"init_parameters": {"retrievers": {}, "filters": None, "top_k": 10, "max_workers": 4},
}
result = MultiRetriever.from_dict(data)
assert isinstance(result, MultiRetriever)
assert result.retrievers == {}
def test_from_dict_with_unknown_retriever_type_raises(self):
data = {
"type": "haystack.components.retrievers.multi_retriever.MultiRetriever",
"init_parameters": {
"retrievers": {
"bad": {"type": "haystack.components.retrievers.NonExistentRetriever", "init_parameters": {}}
},
"filters": None,
"top_k": 10,
"max_workers": 4,
},
}
with pytest.raises(ImportError, match="Could not import class"):
MultiRetriever.from_dict(data)
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_filters(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result = retriever.run(query="energy", filters={"field": "meta.category", "operator": "==", "value": "solar"})
assert len(result["documents"]) == 1
assert result["documents"][0].meta["category"] == "solar"
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_top_k(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result = retriever.run(query="energy", top_k=2)
assert len(result["documents"]) == 2
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_active_retrievers_integration(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result_bm25_active = retriever.run(query="energy", active_retrievers=["bm25"])
result_bm25 = bm25_retriever.run(query="energy")
# Scores differ because MultiRetriever applies join_mode processing (e.g. RRF) even for a single retriever.
assert [doc.id for doc in result_bm25_active["documents"]] == [doc.id for doc in result_bm25["documents"]]
class TestMultiRetrieverAsync:
@pytest.mark.asyncio
async def test_run_async_with_empty_results(self):
retriever = MultiRetriever(retrievers={"mock": MockRetriever()})
result = await retriever.run_async(query="green energy")
assert "documents" in result
assert result["documents"] == []
@pytest.mark.asyncio
async def test_run_async_combines_results_from_multiple_retrievers(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=[sample_documents[0]]),
"b": MockRetriever(documents=[sample_documents[1]]),
}
)
result = await retriever.run_async(query="energy")
assert len(result["documents"]) == 2
assert {doc.id for doc in result["documents"]} == {"doc1", "doc2"}
@pytest.mark.asyncio
async def test_run_async_deduplicates_results(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"c": MockRetriever(documents=[sample_documents[0], sample_documents[1]]),
"d": MockRetriever(documents=[sample_documents[0]]),
}
)
result = await retriever.run_async(query="energy")
assert len(result["documents"]) == 2
assert [doc.id for doc in result["documents"]].count("doc1") == 1
@pytest.mark.asyncio
async def test_run_async_rrf_assigns_scores_and_sorts(self, sample_documents):
docs_a = [sample_documents[0], sample_documents[1], sample_documents[2]]
docs_b = [sample_documents[2], sample_documents[0], sample_documents[3]]
retriever = MultiRetriever(
retrievers={"a": MockRetriever(docs_a), "b": MockRetriever(docs_b)}, join_mode="reciprocal_rank_fusion"
)
result = await retriever.run_async(query="energy")
assert all(doc.score is not None for doc in result["documents"])
scores = [doc.score for doc in result["documents"]]
assert scores == sorted(scores, reverse=True)
ids = [doc.id for doc in result["documents"]]
assert ids.index("doc1") < ids.index("doc3")
@pytest.mark.asyncio
async def test_run_async_resolves_filters_and_top_k_per_retriever(self):
received: dict = {}
@component
class CapturingRetriever:
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
received["filters"] = filters
received["top_k"] = top_k
return {"documents": []}
retriever = MultiRetriever(
retrievers={"capturing": CapturingRetriever()}, filters={"field": "meta.category"}, top_k_per_retriever=5
)
# top_k_per_retriever is forwarded as the retriever's top_k
await retriever.run_async(query="energy")
assert received["filters"] == {"field": "meta.category"}
assert received["top_k"] == 5
await retriever.run_async(query="energy", filters={"field": "meta.other"}, top_k_per_retriever=2)
assert received["filters"] == {"field": "meta.other"}
assert received["top_k"] == 2
@pytest.mark.asyncio
async def test_run_async_forwards_top_k_per_retriever_not_overall_top_k(self):
received: dict = {}
@component
class CapturingRetriever:
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
received["top_k"] = top_k
return {"documents": []}
retriever = MultiRetriever(retrievers={"capturing": CapturingRetriever()})
# top_k_per_retriever is forwarded to each retriever as its top_k
await retriever.run_async(query="energy", top_k_per_retriever=3)
assert received["top_k"] == 3
# the overall top_k is applied at merge-time only, not forwarded to retrievers
received.clear()
await retriever.run_async(query="energy", top_k=5)
assert received.get("top_k") is None
@pytest.mark.asyncio
async def test_run_async_top_k_truncates_merged_results(self, sample_documents):
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=sample_documents[:3]),
"b": MockRetriever(documents=sample_documents[2:5]),
}
)
result = await retriever.run_async(query="energy", top_k=2)
assert len(result["documents"]) == 2
scores = [doc.score for doc in result["documents"]]
assert all(score is not None for score in scores)
assert scores == sorted(scores, reverse=True)
@pytest.mark.asyncio
async def test_run_async_top_k_forces_rrf_in_concatenate_mode(self, sample_documents):
# In concatenate mode there is no global ranking, so setting top_k falls back to RRF to truncate consistently
retriever = MultiRetriever(
retrievers={
"a": MockRetriever(documents=sample_documents[:3]),
"b": MockRetriever(documents=sample_documents[1:4]),
},
join_mode="concatenate",
)
result = await retriever.run_async(query="energy", top_k=2)
assert len(result["documents"]) == 2
assert all(doc.score is not None for doc in result["documents"])
@pytest.mark.asyncio
async def test_run_async_with_active_retrievers(self, sample_documents):
retriever = MultiRetriever(
retrievers={"a": MockRetriever([sample_documents[0]]), "b": MockRetriever([sample_documents[1]])}
)
result = await retriever.run_async(query="energy", active_retrievers=["a"])
assert len(result["documents"]) == 1
assert result["documents"][0].id == "doc1"
@pytest.mark.asyncio
async def test_run_async_with_unknown_active_retriever_raises(self):
retriever = MultiRetriever(retrievers={"mock": MockRetriever()})
with pytest.raises(ValueError, match="Unknown retriever name"):
await retriever.run_async(query="energy", active_retrievers=["nonexistent"])
@pytest.mark.asyncio
async def test_run_async_retriever_failure_raises_with_name(self):
retriever = MultiRetriever(retrievers={"failing": FailingRetriever()})
with pytest.raises(RuntimeError, match="Retriever 'failing' failed"):
await retriever.run_async(query="energy")
@pytest.mark.asyncio
async def test_run_async_uses_run_async_on_retriever_if_available(self):
@component
class AsyncCapableRetriever:
def __init__(self):
self.used_async = False
@component.output_types(documents=list[Document])
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
return {"documents": []}
@component.output_types(documents=list[Document])
async def run_async(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None):
self.used_async = True
return {"documents": [Document(content="async result", id="async1")]}
inner = AsyncCapableRetriever()
retriever = MultiRetriever(retrievers={"async_capable": inner})
result = await retriever.run_async(query="energy")
assert inner.used_async is True
assert len(result["documents"]) == 1
assert result["documents"][0].id == "async1"
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_run_async_with_filters(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result = await retriever.run_async(
query="energy", filters={"field": "meta.category", "operator": "==", "value": "solar"}
)
assert len(result["documents"]) == 1
assert result["documents"][0].meta["category"] == "solar"
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_run_async_with_top_k(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result = await retriever.run_async(query="energy", top_k=2)
assert len(result["documents"]) == 2
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_run_async_with_active_retrievers_integration(self, bm25_retriever, embedding_retriever):
retriever = MultiRetriever(retrievers={"bm25": bm25_retriever, "embedding": embedding_retriever})
result_bm25_active = await retriever.run_async(query="energy", active_retrievers=["bm25"])
result_bm25 = await bm25_retriever.run_async(query="energy")
# Scores differ because MultiRetriever applies join_mode processing (e.g. RRF) even for a single retriever.
assert [doc.id for doc in result_bm25_active["documents"]] == [doc.id for doc in result_bm25["documents"]]
class TestMultiRetrieverExperimental:
@pytest.mark.filterwarnings("always::haystack.utils.experimental.ExperimentalWarning")
def test_emits_experimental_warning_on_init(self):
with pytest.warns(ExperimentalWarning, match="MultiRetriever.*experimental"):
MultiRetriever(retrievers={"mock": MockRetriever()})
@pytest.mark.filterwarnings("always::haystack.utils.experimental.ExperimentalWarning")
def test_experimental_attribute_is_set(self):
assert getattr(MultiRetriever, "__experimental__", False) is True
class TestComponentLifecycle:
def test_warm_up_delegates_to_all_retrievers(self):
a = Mock(spec=["run", "warm_up"])
b = Mock(spec=["run", "warm_up"])
retriever = MultiRetriever(retrievers={"a": a, "b": b})
retriever.warm_up()
a.warm_up.assert_called_once()
b.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_all_retrievers(self):
a = Mock(spec=["run", "warm_up_async"])
a.warm_up_async = AsyncMock()
b = Mock(spec=["run", "warm_up_async"])
b.warm_up_async = AsyncMock()
retriever = MultiRetriever(retrievers={"a": a, "b": b})
await retriever.warm_up_async()
a.warm_up_async.assert_awaited_once()
b.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
a = Mock(spec=["run", "warm_up"])
b = Mock(spec=["run", "warm_up"])
retriever = MultiRetriever(retrievers={"a": a, "b": b})
await retriever.warm_up_async()
a.warm_up.assert_called_once()
b.warm_up.assert_called_once()
def test_close_delegates_to_all_retrievers(self):
a = Mock(spec=["run", "close"])
b = Mock(spec=["run", "close"])
retriever = MultiRetriever(retrievers={"a": a, "b": b})
retriever.close()
a.close.assert_called_once()
b.close.assert_called_once()
async def test_close_async_delegates_to_all_retrievers(self):
a = Mock(spec=["run", "close_async"])
a.close_async = AsyncMock()
b = Mock(spec=["run", "close_async"])
b.close_async = AsyncMock()
retriever = MultiRetriever(retrievers={"a": a, "b": b})
await retriever.close_async()
a.close_async.assert_awaited_once()
b.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
a = Mock(spec=["run", "close"])
b = Mock(spec=["run", "close"])
retriever = MultiRetriever(retrievers={"a": a, "b": b})
await retriever.close_async()
a.close.assert_called_once()
b.close.assert_called_once()
async def test_lifecycle_is_safe_when_retrievers_lack_methods(self):
a = Mock(spec=["run"])
b = Mock(spec=["run"])
retriever = MultiRetriever(retrievers={"a": a, "b": b})
retriever.warm_up()
await retriever.warm_up_async()
retriever.close()
await retriever.close_async()
@@ -0,0 +1,331 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import random
import re
from unittest.mock import ANY
import pytest
from haystack import DeserializationError, Document, Pipeline
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.retrievers import InMemoryBM25Retriever
from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever
from haystack.document_stores.in_memory import InMemoryDocumentStore
class TestSentenceWindowRetriever:
def test_init_default(self, in_memory_doc_store):
retriever = SentenceWindowRetriever(in_memory_doc_store)
assert retriever.window_size == 3
def test_init_with_parameters(self, in_memory_doc_store):
retriever = SentenceWindowRetriever(in_memory_doc_store, window_size=5)
assert retriever.window_size == 5
def test_init_with_invalid_window_size_parameter(self, in_memory_doc_store):
with pytest.raises(ValueError):
SentenceWindowRetriever(in_memory_doc_store, window_size=-2)
def test_merge_documents(self):
docs = [
{
"id": "doc_0",
"content": "This is a text with some words. There is a ",
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
"page_number": 1,
"split_id": 0,
"split_idx_start": 0,
"_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}],
},
{
"id": "doc_1",
"content": "some words. There is a second sentence. And there is ",
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
"page_number": 1,
"split_id": 1,
"split_idx_start": 20,
"_split_overlap": [{"doc_id": "doc_0", "range": (20, 43)}, {"doc_id": "doc_2", "range": (0, 29)}],
},
{
"id": "doc_2",
"content": "second sentence. And there is also a third sentence",
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
"page_number": 1,
"split_id": 2,
"split_idx_start": 43,
"_split_overlap": [{"doc_id": "doc_1", "range": (23, 52)}],
},
]
merged_text = SentenceWindowRetriever.merge_documents_text([Document.from_dict(doc) for doc in docs])
expected = "This is a text with some words. There is a second sentence. And there is also a third sentence"
assert merged_text == expected
def test_to_dict(self, in_memory_doc_store):
window_retriever = SentenceWindowRetriever(in_memory_doc_store)
data = window_retriever.to_dict()
assert data == {
"type": "haystack.components.retrievers.sentence_window_retriever.SentenceWindowRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
"embedding_similarity_function": "dot_product",
"index": ANY,
"shared": True,
"return_embedding": True,
},
},
"window_size": 3,
"source_id_meta_field": "source_id",
"split_id_meta_field": "split_id",
"raise_on_missing_meta_fields": True,
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.retrievers.sentence_window_retriever.SentenceWindowRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {},
},
"window_size": 5,
"source_id_meta_field": "source_id_test",
"split_id_meta_field": "split_id_test",
"raise_on_missing_meta_fields": False,
},
}
component = SentenceWindowRetriever.from_dict(data)
assert isinstance(component.document_store, InMemoryDocumentStore)
assert component.window_size == 5
assert component.source_id_meta_field == "source_id_test"
assert component.split_id_meta_field == "split_id_test"
assert not component.raise_on_missing_meta_fields
def test_from_dict_without_docstore(self):
data = {
"type": "haystack.components.retrievers.sentence_window_retriever.SentenceWindowRetriever",
"init_parameters": {},
}
with pytest.raises(TypeError, match="missing 1 required positional argument: 'document_store'"):
SentenceWindowRetriever.from_dict(data)
def test_from_dict_without_docstore_type(self):
data = {"type": "SentenceWindowRetriever", "init_parameters": {"document_store": {"init_parameters": {}}}}
with pytest.raises(DeserializationError):
SentenceWindowRetriever.from_dict(data)
def test_from_dict_non_existing_docstore(self):
data = {
"type": "SentenceWindowRetriever",
"init_parameters": {"document_store": {"type": "Nonexisting.Docstore", "init_parameters": {}}},
}
with pytest.raises(DeserializationError):
SentenceWindowRetriever.from_dict(data)
def test_document_without_split_id(self, in_memory_doc_store):
docs = [
Document(content="This is a text with some words. There is a ", meta={"id": "doc_0"}),
Document(content="some words. There is a second sentence. And there is ", meta={"id": "doc_1"}),
]
with pytest.raises(ValueError, match="The retrieved documents must have 'split_id_test' in their metadata."):
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store, window_size=3, split_id_meta_field="split_id_test"
)
retriever.run(retrieved_documents=docs)
def test_document_without_source_id(self, in_memory_doc_store):
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, "source_id_test": "source1"},
),
]
with pytest.raises(ValueError, match="The retrieved documents must have 'source_id_test' in their metadata."):
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store, window_size=3, source_id_meta_field="source_id_test"
)
retriever.run(retrieved_documents=docs)
def test_document_without_all_source_ids(self, in_memory_doc_store):
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=in_memory_doc_store, window_size=3, source_id_meta_field=["id", "section_id"]
)
retriever.run(retrieved_documents=docs)
def test_run_invalid_window_size(self, in_memory_doc_store):
docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})]
with pytest.raises(ValueError):
retriever = SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=0)
retriever.run(retrieved_documents=docs)
def test_constructor_parameter_does_not_change(self, in_memory_doc_store):
retriever = SentenceWindowRetriever(in_memory_doc_store, window_size=5)
assert retriever.window_size == 5
doc = {
"id": "doc_0",
"content": "This is a text with some words. There is a ",
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
"page_number": 1,
"split_id": 0,
"split_idx_start": 0,
"_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}],
}
retriever.run(retrieved_documents=[Document.from_dict(doc)], window_size=1)
assert retriever.window_size == 5
def test_context_documents_returned_are_ordered_by_split_idx_start(self, in_memory_doc_store):
docs = []
accumulated_length = 0
for sent in range(10):
content = f"Sentence {sent}."
docs.append(
Document(
content=content,
meta={
"id": f"doc_{sent}",
"split_idx_start": accumulated_length,
"source_id": "source1",
"split_id": sent,
},
)
)
accumulated_length += len(content)
random.shuffle(docs)
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=3)
# run the retriever with a document whose content = "Sentence 4."
result = retriever.run(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
# assert that the context documents are in the correct order
assert len(result["context_documents"]) == 7
assert [doc.meta["split_idx_start"] for doc in result["context_documents"]] == [11, 22, 33, 44, 55, 66, 77]
def test_run_custom_fields(self, in_memory_doc_store):
docs = []
accumulated_length = 0
for sent in range(10):
content = f"Sentence {sent}."
docs.append(
Document(
content=content,
meta={
"id": f"doc_{sent}",
# Missing split_idx_start
"source_id_test": "source1",
"split_id_test": sent,
},
)
)
accumulated_length += len(content)
random.shuffle(docs)
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store,
window_size=3,
source_id_meta_field="source_id_test",
split_id_meta_field="split_id_test",
)
# run the retriever with a document whose content = "Sentence 4."
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, in_memory_doc_store):
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"}
),
]
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(
document_store=in_memory_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, in_memory_doc_store):
splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="period")
text = (
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
"It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence"
)
doc = Document(content=text)
docs = splitter.run([doc])
in_memory_doc_store.write_documents(docs["documents"])
pipe = Pipeline()
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(in_memory_doc_store, top_k=1))
pipe.add_component(
"sentence_window_retriever", SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=2)
)
pipe.connect("bm25_retriever", "sentence_window_retriever")
result = pipe.run({"bm25_retriever": {"query": "third"}})
assert result["sentence_window_retriever"]["context_windows"] == [
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
"It also contains a fourth sentence. And a fifth sentence."
]
assert len(result["sentence_window_retriever"]["context_documents"]) == 5
result = pipe.run({"bm25_retriever": {"query": "third"}, "sentence_window_retriever": {"window_size": 1}})
assert result["sentence_window_retriever"]["context_windows"] == [
" There is a second sentence. And there is also a third sentence. It also contains a fourth sentence."
]
assert len(result["sentence_window_retriever"]["context_documents"]) == 3
@pytest.mark.integration
def test_serialization_deserialization_in_pipeline(self, in_memory_doc_store):
pipe = Pipeline()
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(in_memory_doc_store, top_k=1))
pipe.add_component(
"sentence_window_retriever", SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=2)
)
pipe.connect("bm25_retriever", "sentence_window_retriever")
serialized = pipe.to_dict()
deserialized = Pipeline.from_dict(serialized)
assert deserialized == pipe
@@ -0,0 +1,226 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import random
import re
import pytest
from haystack import Document, Pipeline
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.retrievers import InMemoryBM25Retriever
from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever
class TestSentenceWindowRetrieverAsync:
async def test_document_without_split_id(self, in_memory_doc_store):
docs = [
Document(content="This is a text with some words. There is a ", meta={"id": "doc_0"}),
Document(content="some words. There is a second sentence. And there is ", meta={"id": "doc_1"}),
]
with pytest.raises(ValueError, match="The retrieved documents must have 'split_id_test' in their metadata."):
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store, window_size=3, split_id_meta_field="split_id_test"
)
await retriever.run_async(retrieved_documents=docs)
@pytest.mark.asyncio
async def test_document_without_source_id(self, in_memory_doc_store):
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, "source_id_test": "source1"},
),
]
with pytest.raises(ValueError, match="The retrieved documents must have 'source_id_test' in their metadata."):
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store, window_size=3, source_id_meta_field="source_id_test"
)
await retriever.run_async(retrieved_documents=docs)
@pytest.mark.asyncio
async def test_document_without_all_source_ids(self, in_memory_doc_store):
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=in_memory_doc_store, window_size=3, source_id_meta_field=["id", "section_id"]
)
await retriever.run_async(retrieved_documents=docs)
@pytest.mark.asyncio
async def test_run_async_invalid_window_size(self, in_memory_doc_store):
docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})]
with pytest.raises(ValueError):
retriever = SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=0)
await retriever.run_async(retrieved_documents=docs)
@pytest.mark.asyncio
async def test_constructor_parameter_does_not_change(self, in_memory_doc_store):
retriever = SentenceWindowRetriever(in_memory_doc_store, window_size=5)
assert retriever.window_size == 5
doc = {
"id": "doc_0",
"content": "This is a text with some words. There is a ",
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
"page_number": 1,
"split_id": 0,
"split_idx_start": 0,
"_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}],
}
await retriever.run_async(retrieved_documents=[Document.from_dict(doc)], window_size=1)
assert retriever.window_size == 5
@pytest.mark.asyncio
async def test_context_documents_returned_are_ordered_by_split_idx_start(self, in_memory_doc_store):
docs = []
accumulated_length = 0
for sent in range(10):
content = f"Sentence {sent}."
docs.append(
Document(
content=content,
meta={
"id": f"doc_{sent}",
"split_idx_start": accumulated_length,
"source_id": "source1",
"split_id": sent,
},
)
)
accumulated_length += len(content)
random.shuffle(docs)
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=3)
# run the retriever with a document whose content = "Sentence 4."
result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
# assert that the context documents are in the correct order
assert len(result["context_documents"]) == 7
assert [doc.meta["split_idx_start"] for doc in result["context_documents"]] == [11, 22, 33, 44, 55, 66, 77]
@pytest.mark.asyncio
async def test_run_async_custom_fields(self, in_memory_doc_store):
docs = []
accumulated_length = 0
for sent in range(10):
content = f"Sentence {sent}."
docs.append(
Document(
content=content,
meta={
"id": f"doc_{sent}",
# Missing split_idx_start
"source_id_test": "source1",
"split_id_test": sent,
},
)
)
accumulated_length += len(content)
random.shuffle(docs)
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store,
window_size=3,
source_id_meta_field="source_id_test",
split_id_meta_field="split_id_test",
)
# run the retriever with a document whose content = "Sentence 4."
result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
assert len(result["context_documents"]) == 7
@pytest.mark.asyncio
async def test_run_async_with_multiple_source_ids(self, in_memory_doc_store):
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"}
),
]
in_memory_doc_store.write_documents(docs)
retriever = SentenceWindowRetriever(
document_store=in_memory_doc_store, window_size=5, source_id_meta_field=["section", "source_id"]
)
result = await retriever.run_async(
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.asyncio
@pytest.mark.integration
async def test_run_async_with_pipeline(self, in_memory_doc_store):
splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="period")
text = (
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
"It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence"
)
doc = Document(content=text)
docs = splitter.run([doc])
in_memory_doc_store.write_documents(docs["documents"])
pipe = Pipeline()
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(in_memory_doc_store, top_k=1))
pipe.add_component(
"sentence_window_retriever", SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=2)
)
pipe.connect("bm25_retriever", "sentence_window_retriever")
result = await pipe.run_async({"bm25_retriever": {"query": "third"}})
assert result["sentence_window_retriever"]["context_windows"] == [
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
"It also contains a fourth sentence. And a fifth sentence."
]
assert len(result["sentence_window_retriever"]["context_documents"]) == 5
result = await pipe.run_async(
{"bm25_retriever": {"query": "third"}, "sentence_window_retriever": {"window_size": 1}}
)
assert result["sentence_window_retriever"]["context_windows"] == [
" There is a second sentence. And there is also a third sentence. It also contains a fourth sentence."
]
assert len(result["sentence_window_retriever"]["context_documents"]) == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_serialization_deserialization_in_pipeline(self, in_memory_doc_store):
pipe = Pipeline()
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(in_memory_doc_store, top_k=1))
pipe.add_component(
"sentence_window_retriever", SentenceWindowRetriever(document_store=in_memory_doc_store, window_size=2)
)
pipe.connect("bm25_retriever", "sentence_window_retriever")
serialized = pipe.to_dict()
deserialized = Pipeline.from_dict(serialized)
assert deserialized == pipe
@@ -0,0 +1,254 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock
import numpy as np
import pytest
from haystack import Document, component
from haystack.components.embedders import OpenAIDocumentEmbedder, OpenAITextEmbedder
from haystack.components.retrievers import InMemoryEmbeddingRetriever, TextEmbeddingRetriever
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
@component
class MockTextEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
class TestTextEmbeddingRetriever:
@pytest.fixture
def sample_documents(self):
return [
Document(content="Renewable energy is energy that is collected from renewable resources."),
Document(content="Solar energy is a type of green energy that is harnessed from the sun."),
Document(content="Wind energy is another type of green energy that is generated by wind turbines."),
Document(content="Geothermal energy is heat that comes from the sub-surface of the earth."),
Document(content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources."),
]
@pytest.fixture
def document_store_with_embeddings(self, sample_documents):
"""Create a document store populated with embedded documents."""
document_store = InMemoryDocumentStore()
doc_embedder = OpenAIDocumentEmbedder()
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
embedded_docs = doc_embedder.run(sample_documents)["documents"]
doc_writer.run(documents=embedded_docs)
return document_store
def test_init(self):
embedding_retriever = InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore())
text_embedder = MockTextEmbedder()
retriever = TextEmbeddingRetriever(retriever=embedding_retriever, text_embedder=text_embedder)
assert retriever.retriever == embedding_retriever
assert retriever.text_embedder == text_embedder
def test_run_with_empty_document_store(self):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
text_embedder=MockTextEmbedder(),
)
result = retriever.run(query="green energy")
assert "documents" in result
assert result["documents"] == []
def test_run_returns_documents_sorted_by_score(self):
doc_high = Document(content="Solar energy", id="doc1", score=0.9)
doc_low = Document(content="Fossil fuels", id="doc2", score=0.3)
doc_mid = Document(content="Wind energy", id="doc3", score=0.6)
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None
) -> dict[str, list[Document]]:
return {"documents": [doc_low, doc_high, doc_mid]}
retriever = TextEmbeddingRetriever(retriever=MockRetriever(), text_embedder=MockTextEmbedder())
result = retriever.run(query="energy")
scores = [doc.score for doc in result["documents"]]
assert scores == sorted(scores, reverse=True)
def test_to_dict(self):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
text_embedder=MockTextEmbedder(),
)
result = retriever.to_dict()
assert result == {
"type": "haystack.components.retrievers.text_embedding_retriever.TextEmbeddingRetriever",
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": ANY,
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"return_embedding": False,
"filter_policy": "replace",
},
},
"text_embedder": {
"type": "retrievers.test_text_embedding_retriever.MockTextEmbedder",
"init_parameters": {},
},
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
data = {
"type": "haystack.components.retrievers.text_embedding_retriever.TextEmbeddingRetriever",
"init_parameters": {
"retriever": {
"type": "haystack.components.retrievers.in_memory.embedding_retriever.InMemoryEmbeddingRetriever",
"init_parameters": {
"document_store": {
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
"init_parameters": {
"bm25_tokenization_regex": "(?u)\\b\\w\\w+\\b",
"bm25_algorithm": "BM25L",
"bm25_parameters": {},
"embedding_similarity_function": "dot_product",
"index": "4bb5369d-779f-487b-9c16-3c40f503438b",
"shared": True,
"return_embedding": True,
},
},
"filters": None,
"top_k": 10,
"scale_score": False,
"return_embedding": False,
"filter_policy": "replace",
},
},
"text_embedder": {
"type": "haystack.components.embedders.openai_text_embedder.OpenAITextEmbedder",
"init_parameters": {
"api_key": {"env_vars": ["OPENAI_API_KEY"], "strict": True, "type": "env_var"},
"api_base_url": None,
"dimensions": None,
"model": "text-embedding-ada-002",
"organization": None,
"http_client_kwargs": None,
"prefix": "",
"suffix": "",
"timeout": None,
"max_retries": None,
},
},
},
}
result = TextEmbeddingRetriever.from_dict(data)
assert isinstance(result, TextEmbeddingRetriever)
assert isinstance(result.retriever, InMemoryEmbeddingRetriever)
assert isinstance(result.text_embedder, OpenAITextEmbedder)
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_filters(self, document_store_with_embeddings):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_embeddings),
text_embedder=OpenAITextEmbedder(),
)
result = retriever.run(query="energy", filters={"field": "meta.category", "operator": "==", "value": "solar"})
assert "documents" in result
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.skipif(os.environ.get("OPENAI_API_KEY", "") == "", reason="OPENAI_API_KEY is not set")
@pytest.mark.integration
def test_run_with_top_k(self, document_store_with_embeddings):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_embeddings),
text_embedder=OpenAITextEmbedder(),
)
result = retriever.run(query="energy", top_k=2)
assert "documents" in result
assert len(result["documents"]) <= 2
class TestComponentLifecycle:
def test_warm_up_delegates_to_inner_components(self):
text_embedder = Mock(spec=["run", "warm_up"])
retriever = Mock(spec=["run", "warm_up"])
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
component.warm_up()
text_embedder.warm_up.assert_called_once()
retriever.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_inner_components(self):
text_embedder = Mock(spec=["run", "warm_up_async"])
text_embedder.warm_up_async = AsyncMock()
retriever = Mock(spec=["run", "warm_up_async"])
retriever.warm_up_async = AsyncMock()
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
await component.warm_up_async()
text_embedder.warm_up_async.assert_awaited_once()
retriever.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
text_embedder = Mock(spec=["run", "warm_up"])
retriever = Mock(spec=["run", "warm_up"])
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
await component.warm_up_async()
text_embedder.warm_up.assert_called_once()
retriever.warm_up.assert_called_once()
def test_close_delegates_to_inner_components(self):
text_embedder = Mock(spec=["run", "close"])
retriever = Mock(spec=["run", "close"])
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
component.close()
text_embedder.close.assert_called_once()
retriever.close.assert_called_once()
async def test_close_async_delegates_to_inner_components(self):
text_embedder = Mock(spec=["run", "close_async"])
text_embedder.close_async = AsyncMock()
retriever = Mock(spec=["run", "close_async"])
retriever.close_async = AsyncMock()
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
await component.close_async()
text_embedder.close_async.assert_awaited_once()
retriever.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
text_embedder = Mock(spec=["run", "close"])
retriever = Mock(spec=["run", "close"])
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
await component.close_async()
text_embedder.close.assert_called_once()
retriever.close.assert_called_once()
async def test_lifecycle_is_safe_when_inner_components_lack_methods(self):
text_embedder = Mock(spec=["run"])
retriever = Mock(spec=["run"])
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
component.warm_up()
await component.warm_up_async()
component.close()
await component.close_async()
@@ -0,0 +1,139 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
import numpy as np
import pytest
from haystack import Document, Pipeline, component
from haystack.components.retrievers import InMemoryEmbeddingRetriever, TextEmbeddingRetriever
from haystack.document_stores.in_memory import InMemoryDocumentStore
@component
class MockTextEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
@component.output_types(embedding=list[float])
async def run_async(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
class TestTextEmbeddingRetrieverAsync:
@pytest.mark.asyncio
async def test_run_async_with_empty_document_store(self):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
text_embedder=MockTextEmbedder(),
)
result = await retriever.run_async(query="green energy")
assert "documents" in result
assert result["documents"] == []
@pytest.mark.asyncio
async def test_run_async_returns_documents_sorted_by_score(self):
doc_high = Document(content="Solar energy", id="doc1", score=0.9)
doc_low = Document(content="Fossil fuels", id="doc2", score=0.3)
doc_mid = Document(content="Wind energy", id="doc3", score=0.6)
@component
class MockRetriever:
@component.output_types(documents=list[Document])
def run(
self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None
) -> dict[str, list[Document]]:
return {"documents": [doc_low, doc_high, doc_mid]}
@component.output_types(documents=list[Document])
async def run_async(
self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None
) -> dict[str, list[Document]]:
return {"documents": [doc_low, doc_high, doc_mid]}
retriever = TextEmbeddingRetriever(retriever=MockRetriever(), text_embedder=MockTextEmbedder())
result = await retriever.run_async(query="energy")
scores = [doc.score for doc in result["documents"]]
assert scores == sorted(scores, reverse=True)
@pytest.mark.asyncio
async def test_run_async_falls_back_to_sync_when_no_run_async(self, document_store_with_categorized_docs):
@component
class SyncOnlyEmbedder:
@component.output_types(embedding=list[float])
def run(self, text: str) -> dict[str, list[float]]:
return {"embedding": np.ones(384).tolist()}
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_categorized_docs),
text_embedder=SyncOnlyEmbedder(),
)
result = await retriever.run_async(query="green energy")
assert "documents" in result
assert len(result["documents"]) > 0
@pytest.fixture
def document_store_with_categorized_docs(self):
documents = [
Document(
content="Solar energy is harnessed from the sun.",
embedding=np.ones(384).tolist(),
meta={"category": "solar"},
),
Document(
content="Solar panels convert sunlight into electricity.",
embedding=np.ones(384).tolist(),
meta={"category": "solar"},
),
Document(
content="Wind energy is generated by wind turbines.",
embedding=np.ones(384).tolist(),
meta={"category": "wind"},
),
Document(
content="Geothermal energy comes from the sub-surface of the earth.",
embedding=np.ones(384).tolist(),
meta={"category": "geo"},
),
Document(
content="Renewable energy is collected from renewable resources.",
embedding=np.ones(384).tolist(),
meta={"category": "renewable"},
),
]
document_store = InMemoryDocumentStore()
document_store.write_documents(documents)
return document_store
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_filters(self, document_store_with_categorized_docs):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=document_store_with_categorized_docs),
text_embedder=MockTextEmbedder(),
)
filters = {"field": "category", "operator": "==", "value": "solar"}
result = await retriever.run_async(query="energy", filters=filters)
assert "documents" in result
assert len(result["documents"]) > 0
assert all(doc.meta.get("category") == "solar" for doc in result["documents"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_run_async_with_pipeline(self):
retriever = TextEmbeddingRetriever(
retriever=InMemoryEmbeddingRetriever(document_store=InMemoryDocumentStore()),
text_embedder=MockTextEmbedder(),
)
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
result = await pipeline.run_async(data={"retriever": {"query": "green energy"}})
assert result
assert "retriever" in result
assert "documents" in result["retriever"]
assert result["retriever"]["documents"] == []