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
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:
@@ -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"] == []
|
||||
Reference in New Issue
Block a user