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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.evaluators import AnswerExactMatchEvaluator
def test_run_with_all_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin", "Paris"])
assert result == {"individual_scores": [1, 1], "score": 1.0}
def test_run_with_no_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Paris", "London"])
assert result == {"individual_scores": [0, 0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin", "London"])
assert result == {"individual_scores": [1, 0], "score": 0.5}
def test_run_with_complex_data():
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(
ground_truth_answers=[
"France",
"9th century",
"9th",
"classical music",
"classical",
"11th century",
"the 11th",
"Denmark",
"Iceland",
"Norway",
"10th century",
"10th",
],
predicted_answers=[
"France",
"9th century",
"10th century",
"9th",
"classic music",
"rock music",
"dubstep",
"the 11th",
"11th century",
"Denmark, Iceland and Norway",
"10th century",
"10th",
],
)
assert result == {"individual_scores": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1], "score": 0.3333333333333333}
def test_run_with_different_lengths():
evaluator = AnswerExactMatchEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=["Berlin"], predicted_answers=["Berlin", "London"])
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=["Berlin", "Paris"], predicted_answers=["Berlin"])
@@ -0,0 +1,346 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
import os
import pytest
from haystack import Pipeline
from haystack.components.evaluators import ContextRelevanceEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils.auth import Secret
class TestContextRelevanceEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
assert component.instructions == (
"Please extract only sentences from the provided context which are absolutely relevant and "
"required to answer the following question. If no relevant sentences are found, or if you "
"believe the question cannot be answered from the given context, return an empty list, example: []"
)
assert component.inputs == [("questions", list[str]), ("contexts", list[list[str]])]
assert component.outputs == ["relevant_statements"]
assert component.examples == [
{
"inputs": {
"questions": "What is the capital of Germany?",
"contexts": ["Berlin is the capital of Germany. Berlin and was founded in 1244."],
},
"outputs": {"relevant_statements": ["Berlin is the capital of Germany."]},
},
{
"inputs": {
"questions": "What is the capital of France?",
"contexts": [
"Berlin is the capital of Germany and was founded in 1244.",
"Europe is a continent with 44 countries.",
"Madrid is the capital of Spain.",
],
},
"outputs": {"relevant_statements": []},
},
{
"inputs": {"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."]},
"outputs": {"relevant_statements": ["Rome is the capital of Italy."]},
},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = ContextRelevanceEvaluator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(
examples=[
{"inputs": {"questions": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"questions": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
)
assert component.examples == [
{"inputs": {"questions": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"questions": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = ContextRelevanceEvaluator(chat_generator=chat_generator)
assert component._chat_generator is chat_generator
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "test-api-key")
chat_generator = OpenAIChatGenerator(
generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42},
api_key=Secret.from_env_var("ENV_VAR"),
)
component = ContextRelevanceEvaluator(
chat_generator=chat_generator,
examples=[{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
raise_on_failure=False,
progress_bar=False,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.context_relevance.ContextRelevanceEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
"progress_bar": False,
"raise_on_failure": False,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.context_relevance.ContextRelevanceEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}],
},
}
component = ContextRelevanceEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.examples == [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
pipeline = Pipeline()
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python is design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
results = component.run(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
def test_run_no_statements_extracted(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[],
]
results = component.run(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
def test_run_missing_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
component.run()
def test_run_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(raise_on_failure=False)
def chat_generator_run(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["c", "d"], "score": 1}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.context_relevance"):
results = component.run(questions=questions, contexts=contexts)
assert results["score"] == 1
assert results["results"][0] == {"relevant_statements": ["c", "d"], "score": 1}
assert results["results"][1]["relevant_statements"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@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_live_run(self):
questions = ["Who created the Python language?"]
contexts = [["Python, created by Guido van Rossum, is a high-level general-purpose programming language."]]
evaluator = ContextRelevanceEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = evaluator.run(questions=questions, contexts=contexts)
required_fields = {"results"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "relevant_statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
class TestContextRelevanceEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator()
async def chat_generator_run_async(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["a", "b"], "score": 1}')]}
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": [], "score": 0}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
["Football is the world's most popular sport."],
["Python is a cross-platform programming language."],
]
results = await component.run_async(questions=questions, contexts=contexts)
assert results == {
"results": [{"score": 1, "relevant_statements": ["a", "b"]}, {"score": 0, "relevant_statements": []}],
"score": 0.5,
"meta": None,
"individual_scores": [1, 0],
}
@pytest.mark.asyncio
async def test_run_async_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = ContextRelevanceEvaluator(raise_on_failure=False)
async def chat_generator_run_async(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"relevant_statements": ["c", "d"], "score": 1}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is popular."], ["Python was created by Guido van Rossum."]]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.context_relevance"):
results = await component.run_async(questions=questions, contexts=contexts)
assert results["score"] == 1
assert results["results"][0] == {"relevant_statements": ["c", "d"], "score": 1}
assert results["results"][1]["relevant_statements"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.asyncio
@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
async def test_live_run_async(self):
questions = ["Who created the Python language?"]
contexts = [["Python, created by Guido van Rossum, is a high-level general-purpose programming language."]]
evaluator = ContextRelevanceEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = await evaluator.run_async(questions=questions, contexts=contexts)
required_fields = {"results"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "relevant_statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_map import DocumentMAPEvaluator
def test_to_dict():
evaluator = DocumentMAPEvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_map.DocumentMAPEvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_map.DocumentMAPEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentMAPEvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"})],
[Document(content="y", meta={"file_id": "b"})],
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"})],
[Document(content="w", meta={"file_id": "c"})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentMAPEvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"source": {"url": "https://a.com"}})],
[Document(content="y", meta={"source": {"url": "https://b.com"}})],
],
retrieved_documents=[
[Document(content="z", meta={"source": {"url": "https://a.com"}})],
[Document(content="w", meta={"source": {"url": "https://c.com"}})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_all_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data():
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert result == {
"individual_scores": [
1.0,
pytest.approx(0.8333333333333333),
1.0,
pytest.approx(0.5833333333333333),
0.0,
pytest.approx(0.8055555555555555),
],
"score": pytest.approx(0.7037037037037037),
}
def test_run_with_different_lengths():
with pytest.raises(ValueError):
evaluator = DocumentMAPEvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator = DocumentMAPEvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
@@ -0,0 +1,152 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_mrr import DocumentMRREvaluator
def test_to_dict():
evaluator = DocumentMRREvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_to_dict_custom_field():
evaluator = DocumentMRREvaluator(document_comparison_field="id")
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_mrr.DocumentMRREvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentMRREvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"})],
[Document(content="y", meta={"file_id": "b"})],
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"})],
[Document(content="w", meta={"file_id": "c"})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentMRREvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"source": {"url": "https://a.com"}})],
[Document(content="y", meta={"source": {"url": "https://b.com"}})],
],
retrieved_documents=[
[Document(content="z", meta={"source": {"url": "https://a.com"}})],
[Document(content="w", meta={"source": {"url": "https://c.com"}})],
],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_all_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data():
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="10th century"), Document(content="9th century"), Document(content="9th")],
[Document(content="rock music"), Document(content="dubstep"), Document(content="classical")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert result == {
"individual_scores": [1.0, 0.5, 0.3333333333333333, 0.5, 0.0, 1.0],
"score": pytest.approx(0.555555555555555),
}
def test_run_with_different_lengths():
with pytest.raises(ValueError):
evaluator = DocumentMRREvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator = DocumentMRREvaluator()
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
@@ -0,0 +1,355 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import Document, default_from_dict
from haystack.components.evaluators.document_ndcg import DocumentNDCGEvaluator
def test_run_with_scores():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[
[
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc6", score=2),
Document(content="doc7", score=3),
Document(content="doc8", score=2),
]
],
retrieved_documents=[
[
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.6592, abs=1e-4)
def test_run_without_scores():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="France"), Document(content="Paris")]],
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_multiple_lists_of_docs():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France"), Document(content="Paris")],
[
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc6", score=2),
Document(content="doc7", score=3),
Document(content="doc8", score=2),
],
],
retrieved_documents=[
[Document(content="France"), Document(content="Germany"), Document(content="Paris")],
[
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
],
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["individual_scores"][1] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.7895, abs=1e-4)
def test_run_with_different_lengths():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_run_with_mixed_documents_with_and_without_scores():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="France", score=3), Document(content="Paris")]],
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
)
def test_run_empty_retrieved():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[Document(content="France")]], retrieved_documents=[[]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_empty_ground_truth():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[]], retrieved_documents=[[Document(content="France")]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_empty_retrieved_and_empty_ground_truth():
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(ground_truth_documents=[[]], retrieved_documents=[[]])
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_no_retrieved():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
_ = evaluator.run(ground_truth_documents=[[Document(content="France")]], retrieved_documents=[])
def test_run_no_ground_truth():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_documents=[], retrieved_documents=[[Document(content="France")]])
def test_run_no_retrieved_and_no_ground_truth():
evaluator = DocumentNDCGEvaluator()
with pytest.raises(ValueError):
evaluator.run(ground_truth_documents=[], retrieved_documents=[])
def test_calculate_dcg_with_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [
Document(content="doc1", score=3),
Document(content="doc2", score=2),
Document(content="doc3", score=3),
Document(content="doc4", score=0),
Document(content="doc5", score=1),
Document(content="doc6", score=2),
]
ret_docs = [
Document(content="doc1"),
Document(content="doc2"),
Document(content="doc3"),
Document(content="doc4"),
Document(content="doc5"),
Document(content="doc6"),
]
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == pytest.approx(6.8611, abs=1e-4)
def test_calculate_dcg_without_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1"), Document(content="doc2")]
ret_docs = [Document(content="doc2"), Document(content="doc3"), Document(content="doc1")]
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == pytest.approx(1.5, abs=1e-4)
def test_calculate_dcg_empty():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1")]
ret_docs = []
dcg = evaluator.calculate_dcg(gt_docs, ret_docs)
assert dcg == 0
def test_calculate_idcg_with_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [
Document(content="doc1", score=3),
Document(content="doc2", score=3),
Document(content="doc3", score=2),
Document(content="doc4", score=3),
Document(content="doc5", score=2),
Document(content="doc6", score=2),
]
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == pytest.approx(8.7403, abs=1e-4)
def test_calculate_idcg_without_scores():
evaluator = DocumentNDCGEvaluator()
gt_docs = [Document(content="doc1"), Document(content="doc2"), Document(content="doc3")]
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == pytest.approx(2.1309, abs=1e-4)
def test_calculate_idcg_empty():
evaluator = DocumentNDCGEvaluator()
gt_docs = []
idcg = evaluator.calculate_idcg(gt_docs)
assert idcg == 0
def test_to_dict_default():
evaluator = DocumentNDCGEvaluator()
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "content"},
}
def test_to_dict_custom_field():
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
def test_from_dict():
data = {
"type": "haystack.components.evaluators.document_ndcg.DocumentNDCGEvaluator",
"init_parameters": {"document_comparison_field": "id"},
}
evaluator = default_from_dict(DocumentNDCGEvaluator, data)
assert evaluator.document_comparison_field == "id"
def test_run_with_id_comparison():
# Documents with same content but different IDs — id comparison
# must match on id, not content
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="France"), Document(id="doc2", content="Paris")]],
retrieved_documents=[
[
Document(id="doc1", content="different text"),
Document(id="doc3", content="Germany"),
Document(id="doc2", content="also different"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_id_comparison_no_match():
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="France")]],
retrieved_documents=[[Document(id="doc99", content="France")]],
)
# Same content, different ID — should NOT match when comparing by id
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_with_meta_comparison():
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="France", meta={"file_id": "f1"}), Document(content="Paris", meta={"file_id": "f2"})]
],
retrieved_documents=[
[
Document(content="different", meta={"file_id": "f1"}),
Document(content="irrelevant", meta={"file_id": "f99"}),
Document(content="also different", meta={"file_id": "f2"}),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.9197, abs=1e-4)
assert result["score"] == pytest.approx(0.9197, abs=1e-4)
def test_run_with_nested_meta_comparison():
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[[Document(content="x", meta={"source": {"url": "https://a.com"}})]],
retrieved_documents=[[Document(content="z", meta={"source": {"url": "https://a.com"}})]],
)
assert result["individual_scores"] == [1.0]
assert result["score"] == 1.0
def test_run_with_meta_missing_key_treated_as_no_match():
# Documents missing the meta key should not match anything
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[[Document(content="France", meta={"file_id": "f1"})]],
retrieved_documents=[[Document(content="France", meta={})]],
)
assert result["individual_scores"] == [0.0]
assert result["score"] == 0.0
def test_run_with_id_comparison_with_scores():
# Verify that relevance scores are honoured when comparing by id
evaluator = DocumentNDCGEvaluator(document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[
[
Document(id="doc1", content="foo", score=3),
Document(id="doc2", content="bar", score=2),
Document(id="doc3", content="baz", score=3),
Document(id="doc6", content="qux", score=2),
Document(id="doc7", content="quux", score=3),
Document(id="doc8", content="corge", score=2),
]
],
retrieved_documents=[
[
Document(id="doc1", content="x"),
Document(id="doc2", content="y"),
Document(id="doc3", content="z"),
Document(id="doc4", content="w"),
Document(id="doc5", content="v"),
]
],
)
assert result["individual_scores"][0] == pytest.approx(0.6592, abs=1e-4)
assert result["score"] == pytest.approx(0.6592, abs=1e-4)
def test_unsupported_comparison_field_raises():
evaluator = DocumentNDCGEvaluator(document_comparison_field="embedding")
with pytest.raises(ValueError, match="Unsupported document_comparison_field"):
evaluator.run(
ground_truth_documents=[[Document(content="France")]], retrieved_documents=[[Document(content="France")]]
)
def test_run_with_meta_missing_key_can_still_reach_perfect_ndcg():
"""
Regression test for the IDCG/DCG inflation bug: ground truth documents that
cannot be matched (missing the configured meta key) must be excluded from
IDCG too, otherwise NDCG can never reach 1.0 even for a perfect retrieval.
"""
evaluator = DocumentNDCGEvaluator(document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[
Document(content="France", meta={"file_id": "f1"}),
Document(content="unmatchable", meta={}), # no file_id -> cannot be matched
]
],
retrieved_documents=[[Document(content="France", meta={"file_id": "f1"})]],
)
# Perfect retrieval of the one matchable document should yield NDCG of exactly 1.0
assert result["individual_scores"] == [1.0]
assert result["score"] == 1.0
@@ -0,0 +1,267 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack import default_from_dict
from haystack.components.evaluators.document_recall import DocumentRecallEvaluator, RecallMode
from haystack.dataclasses import Document
def test_init_with_unknown_mode_string():
with pytest.raises(ValueError):
DocumentRecallEvaluator(mode="unknown_mode")
def test_init_with_string_mode():
evaluator = DocumentRecallEvaluator(mode="single_hit")
assert evaluator.mode == RecallMode.SINGLE_HIT
evaluator = DocumentRecallEvaluator(mode="multi_hit")
assert evaluator.mode == RecallMode.MULTI_HIT
def test_init_default_comparison_field():
evaluator = DocumentRecallEvaluator()
assert evaluator.document_comparison_field == "content"
def test_run_with_id_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT, document_comparison_field="id")
result = evaluator.run(
ground_truth_documents=[[Document(id="doc1", content="foo")], [Document(id="doc2", content="bar")]],
retrieved_documents=[[Document(id="doc1", content="different")], [Document(id="wrong", content="bar")]],
)
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_meta_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.file_id")
result = evaluator.run(
ground_truth_documents=[
[Document(content="x", meta={"file_id": "a"}), Document(content="y", meta={"file_id": "b"})]
],
retrieved_documents=[
[Document(content="z", meta={"file_id": "a"}), Document(content="w", meta={"file_id": "c"})]
],
)
assert result == {"individual_scores": [0.5], "score": 0.5}
def test_run_with_nested_meta_comparison():
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.source.url")
result = evaluator.run(
ground_truth_documents=[
[
Document(content="x", meta={"source": {"url": "https://a.com"}}),
Document(content="y", meta={"source": {"url": "https://b.com"}}),
]
],
retrieved_documents=[
[
Document(content="z", meta={"source": {"url": "https://a.com"}}),
Document(content="w", meta={"source": {"url": "https://c.com"}}),
]
],
)
assert result == {"individual_scores": [0.5], "score": 0.5}
class TestDocumentRecallEvaluatorSingleHit:
@pytest.fixture
def evaluator(self):
return DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT)
def test_run_with_all_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data(self, evaluator):
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[Document(content="Denmark, Iceland and Norway")],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1, 1, 1, 1, 0, 1], "score": 0.8333333333333334}
def test_run_with_different_lengths(self, evaluator):
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_to_dict(self, evaluator):
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "single_hit", "document_comparison_field": "content"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "single_hit"},
}
new_evaluator = default_from_dict(DocumentRecallEvaluator, data)
assert new_evaluator.mode == RecallMode.SINGLE_HIT
class TestDocumentRecallEvaluatorMultiHit:
@pytest.fixture
def evaluator(self):
return DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT)
def test_run_with_all_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0], "score": 1.0}
def test_run_with_no_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Paris")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [0.0, 0.0], "score": 0.0}
def test_run_with_partial_matching(self, evaluator):
result = evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 0.0], "score": 0.5}
def test_run_with_complex_data(self, evaluator):
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
[Document(content="classical music"), Document(content="classical")],
[Document(content="11th century"), Document(content="the 11th")],
[
Document(content="Denmark"),
Document(content="Iceland"),
Document(content="Norway"),
Document(content="Denmark, Iceland and Norway"),
],
[Document(content="10th century"), Document(content="10th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
[Document(content="classical"), Document(content="rock music"), Document(content="dubstep")],
[Document(content="11th"), Document(content="the 11th"), Document(content="11th century")],
[Document(content="Denmark"), Document(content="Norway"), Document(content="Iceland")],
[
Document(content="10th century"),
Document(content="the first half of the 10th century"),
Document(content="10th"),
Document(content="10th"),
],
],
)
assert all(isinstance(individual_score, float) for individual_score in result["individual_scores"])
assert result == {"individual_scores": [1.0, 1.0, 0.5, 1.0, 0.75, 1.0], "score": 0.875}
def test_run_with_different_lengths(self, evaluator):
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")]],
retrieved_documents=[[Document(content="Berlin")], [Document(content="London")]],
)
with pytest.raises(ValueError):
evaluator.run(
ground_truth_documents=[[Document(content="Berlin")], [Document(content="Paris")]],
retrieved_documents=[[Document(content="Berlin")]],
)
def test_to_dict(self, evaluator):
data = evaluator.to_dict()
assert data == {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "multi_hit", "document_comparison_field": "content"},
}
def test_from_dict(self):
data = {
"type": "haystack.components.evaluators.document_recall.DocumentRecallEvaluator",
"init_parameters": {"mode": "multi_hit"},
}
new_evaluator = default_from_dict(DocumentRecallEvaluator, data)
assert new_evaluator.mode == RecallMode.MULTI_HIT
def test_empty_ground_truth_documents(self, evaluator):
ground_truth_documents = [[]]
retrieved_documents = [[Document(content="test")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_retrieved_documents(self, evaluator):
ground_truth_documents = [[Document(content="test")]]
retrieved_documents = [[]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_string_ground_truth_documents(self, evaluator):
ground_truth_documents = [[Document(content="")]]
retrieved_documents = [[Document(content="test")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
def test_empty_string_retrieved_documents(self, evaluator):
ground_truth_documents = [[Document(content="test")]]
retrieved_documents = [[Document(content="")]]
score = evaluator.run(ground_truth_documents, retrieved_documents)
assert score == {"individual_scores": [0.0], "score": 0.0}
@@ -0,0 +1,407 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
import os
import pytest
from haystack import Pipeline
from haystack.components.evaluators import FaithfulnessEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils.auth import Secret
class TestFaithfulnessEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
assert component.instructions == (
"Your task is to judge the faithfulness or groundedness of statements based "
"on context information. First, please extract statements from a provided predicted "
"answer to a question. Second, calculate a faithfulness score for each "
"statement made in the predicted answer. The score is 1 if the statement can be "
"inferred from the provided context or 0 if it cannot be inferred."
)
assert component.inputs == [
("questions", list[str]),
("contexts", list[list[str]]),
("predicted_answers", list[str]),
]
assert component.outputs == ["statements", "statement_scores"]
assert component.examples == [
{
"inputs": {
"questions": "What is the capital of Germany and when was it founded?",
"contexts": ["Berlin is the capital of Germany and was founded in 1244."],
"predicted_answers": "The capital of Germany, Berlin, was founded in the 13th century.",
},
"outputs": {
"statements": ["Berlin is the capital of Germany.", "Berlin was founded in 1244."],
"statement_scores": [1, 1],
},
},
{
"inputs": {
"questions": "What is the capital of France?",
"contexts": ["Berlin is the capital of Germany."],
"predicted_answers": "Paris",
},
"outputs": {"statements": ["Paris is the capital of France."], "statement_scores": [0]},
},
{
"inputs": {
"questions": "What is the capital of Italy?",
"contexts": ["Rome is the capital of Italy."],
"predicted_answers": "Rome is the capital of Italy with more than 4 million inhabitants.",
},
"outputs": {
"statements": ["Rome is the capital of Italy.", "Rome has more than 4 million inhabitants."],
"statement_scores": [1, 0],
},
},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = FaithfulnessEvaluator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(
examples=[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
]
)
assert component.examples == [
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"custom_score": 1}},
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"custom_score": 0}},
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = FaithfulnessEvaluator(chat_generator=chat_generator)
assert component._chat_generator is chat_generator
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("ENV_VAR", "test-api-key")
chat_generator = OpenAIChatGenerator(
generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42},
api_key=Secret.from_env_var("ENV_VAR"),
)
component = FaithfulnessEvaluator(
chat_generator=chat_generator,
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=False,
progress_bar=False,
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.faithfulness.FaithfulnessEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
"progress_bar": False,
"raise_on_failure": False,
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.faithfulness.FaithfulnessEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
component = FaithfulnessEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
pipeline = Pipeline()
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"Python is a high-level general-purpose programming language that was created by George Lucas.",
]
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 1],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 1, "statement_scores": [1, 1], "statements": ["c", "d"]},
],
"score": 0.75,
"meta": None,
}
def test_run_no_statements_extracted(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
def chat_generator_run(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": [], "statement_scores": []}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"I don't know.",
]
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 0],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 0, "statement_scores": [], "statements": []},
],
"score": 0.25,
"meta": None,
}
def test_run_missing_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
component.run()
def test_run_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(raise_on_failure=False)
def chat_generator_run(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [
[
"The popularity of sports can be measured in various ways, including TV viewership, social media "
"presence, number of participants, and economic impact. Football is undoubtedly the world's most "
"popular sport with major events like the FIFA World Cup and sports personalities like Ronaldo and "
"Messi, drawing a followership of more than 4 billion people."
],
[
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
"programmers write clear, logical code for both small and large-scale software projects."
],
]
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide.",
"Guido van Rossum.",
]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.faithfulness"):
results = component.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results["score"] == 1.0
assert results["individual_scores"][0] == 1.0
assert math.isnan(results["individual_scores"][1])
assert results["results"][0] == {"statements": ["c", "d"], "statement_scores": [1, 1], "score": 1.0}
assert results["results"][1]["statements"] == []
assert results["results"][1]["statement_scores"] == []
assert math.isnan(results["results"][1]["score"])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@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_live_run(self):
questions = ["What is Python and who created it?"]
contexts = [["Python is a programming language created by Guido van Rossum."]]
predicted_answers = ["Python is a programming language created by George Lucas."]
evaluator = FaithfulnessEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = evaluator.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
required_fields = {"individual_scores", "results", "score"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "statement_scores", "statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
# assert that metadata is present in the result
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
class TestFaithfulnessEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_calculates_mean_score(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator()
async def chat_generator_run_async(self, *args, **kwargs):
if "Football" in kwargs["messages"][0].text:
return {
"replies": [ChatMessage.from_assistant('{"statements": ["a", "b"], "statement_scores": [1, 0]}')]
}
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is the world's most popular sport."], ["Python was created by Guido van Rossum."]]
predicted_answers = ["Football is the most popular sport.", "Python is a language created by George Lucas."]
results = await component.run_async(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
assert results == {
"individual_scores": [0.5, 1.0],
"results": [
{"score": 0.5, "statement_scores": [1, 0], "statements": ["a", "b"]},
{"score": 1.0, "statement_scores": [1, 1], "statements": ["c", "d"]},
],
"score": 0.75,
"meta": None,
}
@pytest.mark.asyncio
async def test_run_async_returns_nan_raise_on_failure_false(self, monkeypatch, caplog):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = FaithfulnessEvaluator(raise_on_failure=False)
async def chat_generator_run_async(self, *args, **kwargs):
if "Python" in kwargs["messages"][0].text:
raise Exception("OpenAI API request failed.")
return {"replies": [ChatMessage.from_assistant('{"statements": ["c", "d"], "statement_scores": [1, 1]}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
questions = ["Which is the most popular global sport?", "Who created the Python language?"]
contexts = [["Football is popular."], ["Python was created by Guido."]]
predicted_answers = ["Football is popular.", "Guido van Rossum."]
with caplog.at_level("WARNING", logger="haystack.components.evaluators.faithfulness"):
results = await component.run_async(
questions=questions, contexts=contexts, predicted_answers=predicted_answers
)
assert results["score"] == 1.0
assert results["individual_scores"][0] == 1.0
assert math.isnan(results["individual_scores"][1])
assert "1 query(s) failed and were excluded from the score." in caplog.text
@pytest.mark.asyncio
@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
async def test_live_run_async(self):
questions = ["What is Python and who created it?"]
contexts = [["Python is a programming language created by Guido van Rossum."]]
predicted_answers = ["Python is a programming language created by George Lucas."]
evaluator = FaithfulnessEvaluator(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"))
result = await evaluator.run_async(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
required_fields = {"individual_scores", "results", "score"}
assert all(field in result for field in required_fields)
nested_required_fields = {"score", "statement_scores", "statements"}
assert all(field in result["results"][0] for field in nested_required_fields)
# assert that metadata is present in the result
assert "meta" in result
assert "prompt_tokens" in result["meta"][0]["usage"]
assert "completion_tokens" in result["meta"][0]["usage"]
assert "total_tokens" in result["meta"][0]["usage"]
@@ -0,0 +1,636 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import AsyncMock, Mock
import pytest
from haystack import Pipeline
from haystack.components.evaluators import LLMEvaluator
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses.chat_message import ChatMessage
class TestLLMEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
assert component.instructions == "test-instruction"
assert component.inputs == [("predicted_answers", list[str])]
assert component.outputs == ["score"]
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
component.warm_up()
def test_init_with_chat_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"custom_key": "custom_value"})
component = LLMEvaluator(
instructions="test-instruction",
chat_generator=chat_generator,
inputs=[("predicted_answers", list[str])],
outputs=["custom_score"],
examples=[
{"inputs": {"predicted_answers": "answer 1"}, "outputs": {"custom_score": 1}},
{"inputs": {"predicted_answers": "answer 2"}, "outputs": {"custom_score": 0}},
],
)
assert component._chat_generator is chat_generator
def test_init_with_invalid_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
# Invalid inputs
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs={("predicted_answers", list[str])},
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[(list[str], "predicted_answers")],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[list[str]],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs={("predicted_answers", str)},
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# Invalid outputs
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs="score",
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=[["score"]],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# Invalid examples
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples={
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
}
]
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"wrong_key": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": [{"predicted_answers": "Damn, this is straight outta hell!!!"}],
"outputs": [{"custom_score": 1}],
}
],
)
with pytest.raises(ValueError):
LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[{"inputs": {1: "Damn, this is straight outta hell!!!"}, "outputs": {2: 1}}],
)
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["score"],
"raise_on_failure": True,
"progress_bar": True,
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
def test_to_dict_with_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["custom_score"],
raise_on_failure=False,
examples=[
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
],
)
data = component.to_dict()
assert data == {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["custom_score"],
"raise_on_failure": False,
"progress_bar": True,
"examples": [
{
"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"},
"outputs": {"custom_score": 1},
},
{
"inputs": {"predicted_answers": "Football is the most popular sport."},
"outputs": {"custom_score": 0},
},
],
},
}
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
chat_generator = OpenAIChatGenerator(generation_kwargs={"response_format": {"type": "json_object"}, "seed": 42})
data = {
"type": "haystack.components.evaluators.llm_evaluator.LLMEvaluator",
"init_parameters": {
"chat_generator": chat_generator.to_dict(),
"instructions": "test-instruction",
"inputs": [["predicted_answers", "list[str]"]],
"outputs": ["score"],
"examples": [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
},
}
component = LLMEvaluator.from_dict(data)
assert isinstance(component._chat_generator, OpenAIChatGenerator)
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
assert component.instructions == "test-instruction"
assert component.inputs == [("predicted_answers", list[str])]
assert component.outputs == ["score"]
assert component.examples == [
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
]
def test_pipeline_serde(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
pipeline = Pipeline()
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
pipeline.add_component("evaluator", component)
serialized_pipeline = pipeline.dumps()
deserialized_pipeline = Pipeline.loads(serialized_pipeline)
assert deserialized_pipeline == pipeline
def test_run_with_different_lengths(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 0.5}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
with pytest.raises(ValueError):
component.run(questions=["What is the capital of Germany?"], predicted_answers=[["Berlin"], ["Paris"]])
with pytest.raises(ValueError):
component.run(
questions=["What is the capital of Germany?", "What is the capital of France?"],
predicted_answers=[["Berlin"]],
)
def test_run_returns_parsed_result(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[list[str]])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 0.5}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
results = component.run(questions=["What is the capital of Germany?"], predicted_answers=["Berlin"])
assert results == {"results": [{"score": 0.5}], "meta": None}
def test_prepare_template(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"score": 1}},
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}},
],
)
template = component.prepare_template()
assert (
template
== "Instructions:\ntest-instruction\n\nGenerate the response in JSON format with the following keys:"
'\n["score"]\nConsider the instructions and the examples below to determine those values.\n\n'
'Examples:\nInputs:\n{"predicted_answers": "Damn, this is straight outta hell!!!"}\nOutputs:'
'\n{"score": 1}\nInputs:\n{"predicted_answers": "Football is the most popular sport."}\nOutputs:'
'\n{"score": 0}\n\nInputs:\n{"predicted_answers": {{ predicted_answers }}}\nOutputs:\n'
)
def test_invalid_input_parameters(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
# None of the expected parameters are received
with pytest.raises(ValueError):
component.validate_input_parameters(
expected={"predicted_answers": list[str]}, received={"questions": list[str]}
)
# Only one but not all the expected parameters are received
with pytest.raises(ValueError):
component.validate_input_parameters(
expected={"predicted_answers": list[str], "questions": list[str]}, received={"questions": list[str]}
)
# Received inputs are not lists
with pytest.raises(ValueError):
component.validate_input_parameters(expected={"questions": list[str]}, received={"questions": str})
def test_invalid_outputs(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 1.0}')]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
# Test missing key "another_expected_output"
component.outputs = ["score", "another_expected_output"]
with pytest.raises(ValueError, match="Missing expected keys"):
component.run(predicted_answers=["answer"])
# Test wrong key
def chat_generator_run_wrong_key(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"wrong_name": 1.0}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run_wrong_key
)
component.outputs = ["score"]
with pytest.raises(ValueError, match="Missing expected keys"):
component.run(predicted_answers=["answer"])
def test_output_invalid_json_raise_on_failure_false(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=False,
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant("some_invalid_json_output")]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
result = component.run(predicted_answers=["answer"])
assert result["results"] == [None]
def test_output_invalid_json_raise_on_failure_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
raise_on_failure=True,
)
def chat_generator_run(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant("some_invalid_json_output")]}
monkeypatch.setattr("haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run", chat_generator_run)
with pytest.raises(
ValueError
): # json_utils/LLMEvaluator might raise JSONDecodeError which inherits from ValueError or wrapped
component.run(predicted_answers=["answer"])
class TestLLMEvaluatorAsync:
@pytest.mark.asyncio
async def test_run_async_returns_parsed_result(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the value of any non-zero number raised to the power of zero?",
"predicted_answers": "Zero",
},
"outputs": {"score": 0},
}
],
)
async def chat_generator_run_async(self, *args, **kwargs):
return {"replies": [ChatMessage.from_assistant('{"score": 1}')]}
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
results = await component.run_async(
questions=["What is the perimeter of a circle called?"], predicted_answers=["Circumference"]
)
assert results == {"results": [{"score": 1}], "meta": None}
@pytest.mark.asyncio
async def test_run_async_fallback_to_thread_with_sync_generator(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
class SyncOnlyGenerator:
def run(self, messages):
return {"replies": [ChatMessage.from_assistant('{"score": 0}')]}
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the top sport?",
"predicted_answers": "Football is the most popular sport.",
},
"outputs": {"score": 1},
}
],
chat_generator=SyncOnlyGenerator(),
)
results = await component.run_async(questions=["question"], predicted_answers=["answer"])
assert results == {"results": [{"score": 0}], "meta": None}
@pytest.mark.asyncio
async def test_run_async_raise_on_failure_false(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the value of any non-zero number raised to the power of zero?",
"predicted_answers": "One",
},
"outputs": {"score": 1},
}
],
raise_on_failure=False,
)
async def chat_generator_run_async(self, *args, **kwargs):
raise Exception("API error")
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
result = await component.run_async(questions=["question"], predicted_answers=["answer"])
assert result["results"] == [None]
@pytest.mark.asyncio
async def test_run_async_raise_on_failure_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = LLMEvaluator(
instructions="test-instruction",
inputs=[("questions", list[str]), ("predicted_answers", list[str])],
outputs=["score"],
examples=[
{
"inputs": {
"questions": "What is the smallest unit of data in a computer?",
"predicted_answers": "Bit",
},
"outputs": {"score": 1},
}
],
)
async def chat_generator_run_async(self, *args, **kwargs):
raise Exception("API error")
monkeypatch.setattr(
"haystack.components.evaluators.llm_evaluator.OpenAIChatGenerator.run_async", chat_generator_run_async
)
with pytest.raises(ValueError):
await component.run_async(questions=["question"], predicted_answers=["answer"])
class TestComponentLifecycle:
@staticmethod
def _make_evaluator(chat_generator):
return LLMEvaluator(
instructions="test-instruction",
inputs=[("predicted_answers", list[str])],
outputs=["score"],
examples=[
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
],
chat_generator=chat_generator,
)
def test_warm_up_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "warm_up"])
evaluator = self._make_evaluator(chat_generator)
evaluator.warm_up()
chat_generator.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "warm_up_async"])
chat_generator.warm_up_async = AsyncMock()
evaluator = self._make_evaluator(chat_generator)
await evaluator.warm_up_async()
chat_generator.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
chat_generator = Mock(spec=["run", "warm_up"])
evaluator = self._make_evaluator(chat_generator)
await evaluator.warm_up_async()
chat_generator.warm_up.assert_called_once()
def test_close_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "close"])
evaluator = self._make_evaluator(chat_generator)
evaluator.close()
chat_generator.close.assert_called_once()
async def test_close_async_delegates_to_chat_generator(self):
chat_generator = Mock(spec=["run", "close_async"])
chat_generator.close_async = AsyncMock()
evaluator = self._make_evaluator(chat_generator)
await evaluator.close_async()
chat_generator.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
chat_generator = Mock(spec=["run", "close"])
evaluator = self._make_evaluator(chat_generator)
await evaluator.close_async()
chat_generator.close.assert_called_once()
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
chat_generator = Mock(spec=["run"])
evaluator = self._make_evaluator(chat_generator)
evaluator.warm_up()
await evaluator.warm_up_async()
evaluator.close()
await evaluator.close_async()
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import pytest
from haystack.components.evaluators.sas_evaluator import SASEvaluator
from haystack.utils.device import ComponentDevice
class TestSASEvaluator:
def test_init_default(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator()
assert evaluator._model == "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
assert evaluator._batch_size == 32
assert evaluator._device is None
assert evaluator._token.resolve_value() == "fake-token"
def test_to_dict(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator(device=ComponentDevice.from_str("cuda:0"))
expected_dict = {
"type": "haystack.components.evaluators.sas_evaluator.SASEvaluator",
"init_parameters": {
"model": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"batch_size": 32,
"device": {"type": "single", "device": "cuda:0"},
"token": {"type": "env_var", "env_vars": ["HF_API_TOKEN", "HF_TOKEN"], "strict": False},
},
}
assert evaluator.to_dict() == expected_dict
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("HF_API_TOKEN", "fake-token")
evaluator = SASEvaluator.from_dict(
{
"type": "haystack.components.evaluators.sas_evaluator.SASEvaluator",
"init_parameters": {
"model": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
"batch_size": 32,
"device": {"type": "single", "device": "cuda:0"},
"token": {"type": "env_var", "env_vars": ["HF_API_TOKEN", "HF_TOKEN"], "strict": False},
},
}
)
assert evaluator._model == "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
assert evaluator._batch_size == 32
assert evaluator._device.to_torch_str() == "cuda:0"
assert evaluator._token.resolve_value() == "fake-token"
def test_run_with_empty_inputs(self):
evaluator = SASEvaluator()
result = evaluator.run(ground_truth_answers=[], predicted_answers=[])
assert len(result) == 2
assert result["score"] == 0.0
assert result["individual_scores"] == [0.0]
def test_run_with_different_lengths(self):
evaluator = SASEvaluator()
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
def test_run_with_none_in_predictions(self):
evaluator = SASEvaluator()
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
predictions = [
"A construction budget of US $2.3 billion",
None,
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
with pytest.raises(ValueError):
evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
@pytest.mark.integration
@pytest.mark.slow
def test_run_with_bi_encoder_model(self, del_hf_env_vars):
evaluator = SASEvaluator("sentence-transformers-testing/stsb-bert-tiny-safetensors")
ground_truths = [
"US $2.3 billion",
"Paris's cultural magnificence is symbolized by the Eiffel Tower",
"Japan was transformed into a modernized world power after the Meiji Restoration.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
result = evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
assert len(result) == 2
assert result["score"] == pytest.approx(0.912335)
assert result["individual_scores"] == pytest.approx([0.855047, 0.907907, 0.974050], abs=1e-5)
@pytest.mark.integration
@pytest.mark.slow
def test_run_with_cross_encoder_model(self, del_hf_env_vars):
evaluator = SASEvaluator(model="cross-encoder-testing/reranker-bert-tiny-gooaq-bce")
ground_truths = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
predictions = [
"A construction budget of US $2.3 billion",
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
]
result = evaluator.run(ground_truth_answers=ground_truths, predicted_answers=predictions)
assert len(result) == 2
assert result["score"] == pytest.approx(0.938108, abs=1e-5)
assert result["individual_scores"] == pytest.approx([0.930112, 0.9431504, 0.9410622], abs=1e-5)