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
@@ -0,0 +1,34 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
_import_structure = {
"answer_exact_match": ["AnswerExactMatchEvaluator"],
"context_relevance": ["ContextRelevanceEvaluator"],
"document_map": ["DocumentMAPEvaluator"],
"document_mrr": ["DocumentMRREvaluator"],
"document_ndcg": ["DocumentNDCGEvaluator"],
"document_recall": ["DocumentRecallEvaluator"],
"faithfulness": ["FaithfulnessEvaluator"],
"llm_evaluator": ["LLMEvaluator"],
"sas_evaluator": ["SASEvaluator"],
}
if TYPE_CHECKING:
from .answer_exact_match import AnswerExactMatchEvaluator as AnswerExactMatchEvaluator
from .context_relevance import ContextRelevanceEvaluator as ContextRelevanceEvaluator
from .document_map import DocumentMAPEvaluator as DocumentMAPEvaluator
from .document_mrr import DocumentMRREvaluator as DocumentMRREvaluator
from .document_ndcg import DocumentNDCGEvaluator as DocumentNDCGEvaluator
from .document_recall import DocumentRecallEvaluator as DocumentRecallEvaluator
from .faithfulness import FaithfulnessEvaluator as FaithfulnessEvaluator
from .llm_evaluator import LLMEvaluator as LLMEvaluator
from .sas_evaluator import SASEvaluator as SASEvaluator
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack.core.component import component
@component
class AnswerExactMatchEvaluator:
"""
An answer exact match evaluator class.
The evaluator that checks if the predicted answers matches any of the ground truth answers exactly.
The result is a number from 0.0 to 1.0, it represents the proportion of predicted answers
that matched one of the ground truth answers.
There can be multiple ground truth answers and multiple predicted answers as input.
Usage example:
```python
from haystack.components.evaluators import AnswerExactMatchEvaluator
evaluator = AnswerExactMatchEvaluator()
result = evaluator.run(
ground_truth_answers=["Berlin", "Paris"],
predicted_answers=["Berlin", "Lyon"],
)
print(result["individual_scores"])
# [1, 0]
print(result["score"])
# 0.5
```
"""
@component.output_types(individual_scores=list[int], score=float)
def run(self, ground_truth_answers: list[str], predicted_answers: list[str]) -> dict[str, Any]:
"""
Run the AnswerExactMatchEvaluator on the given inputs.
The `ground_truth_answers` and `retrieved_answers` must have the same length.
:param ground_truth_answers:
A list of expected answers.
:param predicted_answers:
A list of predicted answers.
:returns:
A dictionary with the following outputs:
- `individual_scores` - A list of 0s and 1s, where 1 means that the predicted answer matched one of the
ground truth.
- `score` - A number from 0.0 to 1.0 that represents the proportion of questions where any predicted
answer matched one of the ground truth answers.
"""
if not len(ground_truth_answers) == len(predicted_answers):
raise ValueError("The length of ground_truth_answers and predicted_answers must be the same.")
matches = []
for truth, extracted in zip(ground_truth_answers, predicted_answers, strict=True):
if truth == extracted:
matches.append(1)
else:
matches.append(0)
# The proportion of questions where any predicted answer matched one of the ground truth answers
average = sum(matches) / len(predicted_answers)
return {"individual_scores": matches, "score": average}
@@ -0,0 +1,257 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
from statistics import mean
from typing import Any
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.evaluators.llm_evaluator import LLMEvaluator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict
from haystack.utils import deserialize_chatgenerator_inplace
logger = logging.getLogger(__name__)
# Private global variable for default examples to include in the prompt if the user does not provide any examples
_DEFAULT_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."]},
},
]
@component
class ContextRelevanceEvaluator(LLMEvaluator):
"""
Evaluator that checks if a provided context is relevant to the question.
An LLM breaks up a context into multiple statements and checks whether each statement
is relevant for answering a question.
The score for each context is either binary score of 1 or 0, where 1 indicates that the context is relevant
to the question and 0 indicates that the context is not relevant.
The evaluator also provides the relevant statements from the context and an average score over all the provided
input questions contexts pairs.
Usage example:
```python
from haystack.components.evaluators import ContextRelevanceEvaluator
questions = ["Who created the Python language?", "Why does Java needs a JVM?", "Is C++ better than Python?"]
contexts = [
[(
"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."
)],
[(
"Java is a high-level, class-based, object-oriented programming language that is designed to have as few "
"implementation dependencies as possible. The JVM has two primary functions: to allow Java programs to run"
"on any device or operating system (known as the 'write once, run anywhere' principle), and to manage and"
"optimize program memory."
)],
[(
"C++ is a general-purpose programming language created by Bjarne Stroustrup as an extension of the C "
"programming language."
)],
]
evaluator = ContextRelevanceEvaluator()
result = evaluator.run(questions=questions, contexts=contexts)
print(result["score"])
# 0.67
print(result["individual_scores"])
# [1,1,0]
print(result["results"])
# [{
# 'relevant_statements': ['Python, created by Guido van Rossum in the late 1980s.'],
# 'score': 1.0
# },
# {
# 'relevant_statements': ['The JVM has two primary functions: to allow Java programs to run on any device or
# operating system (known as the "write once, run anywhere" principle), and to manage and
# optimize program memory'],
# 'score': 1.0
# },
# {
# 'relevant_statements': [],
# 'score': 0.0
# }]
```
"""
def __init__(
self,
examples: list[dict[str, Any]] | None = None,
progress_bar: bool = True,
raise_on_failure: bool = True,
chat_generator: ChatGenerator | None = None,
) -> None:
"""
Creates an instance of ContextRelevanceEvaluator.
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
:param examples:
Optional few-shot examples conforming to the expected input and output format of ContextRelevanceEvaluator.
Default examples will be used if none are provided.
Each example must be a dictionary with keys "inputs" and "outputs".
"inputs" must be a dictionary with keys "questions" and "contexts".
"outputs" must be a dictionary with "relevant_statements".
Expected format:
```python
[{
"inputs": {
"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."],
},
"outputs": {
"relevant_statements": ["Rome is the capital of Italy."],
},
}]
```
:param progress_bar:
Whether to show a progress bar during the evaluation.
:param raise_on_failure:
Whether to raise an exception if the API call fails.
:param chat_generator:
a ChatGenerator instance which represents the LLM.
In order for the component to work, the LLM should be configured to return a JSON object. For example,
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
`generation_kwargs`.
"""
self.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: []"
)
self.inputs = [("questions", list[str]), ("contexts", list[list[str]])]
self.outputs = ["relevant_statements"]
self.examples = examples or _DEFAULT_EXAMPLES
super(ContextRelevanceEvaluator, self).__init__( # noqa: UP008
instructions=self.instructions,
inputs=self.inputs,
outputs=self.outputs,
examples=self.examples,
chat_generator=chat_generator,
raise_on_failure=raise_on_failure,
progress_bar=progress_bar,
)
@component.output_types(score=float, results=list[dict[str, Any]])
def run(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator.
:param questions:
A list of questions.
:param contexts:
A list of lists of contexts. Each list of contexts corresponds to one question.
:returns:
A dictionary with the following outputs:
- `score`: Mean context relevance score over all the provided input questions.
- `results`: A list of dictionaries with `relevant_statements` and `score` for each input context.
"""
result = super(ContextRelevanceEvaluator, self).run(**inputs) # noqa: UP008
# Post-process the raw results to calculate relevance metrics and scores
return self._postprocess_results(result)
@component.output_types(score=float, results=list[dict[str, Any]])
async def run_async(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator asynchronously.
:param questions:
A list of questions.
:param contexts:
A list of lists of contexts. Each list of contexts corresponds to one question.
:returns:
A dictionary with the following outputs:
- `score`: Mean context relevance score over all the provided input questions.
- `results`: A list of dictionaries with `relevant_statements` and `score` for each input context.
"""
result = await super(ContextRelevanceEvaluator, self).run_async(**inputs) # noqa: UP008
# Post-process the raw results to calculate relevance metrics and scores
return self._postprocess_results(result)
def _postprocess_results(self, result: dict[str, Any]) -> dict[str, Any]:
"""
Post-processes raw LLM evaluator outputs to compute context relevance scores.
Calculates binary scores based on whether relevant statements were found,
averages the scores across all successful queries, and updates the result payload.
:param result:
The raw evaluation dictionary from the base LLM evaluator.
:returns:
The updated dictionary containing final scores and tracking metrics.
"""
for idx, res in enumerate(result["results"]):
if res is None:
result["results"][idx] = {"relevant_statements": [], "score": float("nan")}
continue
if len(res["relevant_statements"]) > 0:
res["score"] = 1
else:
res["score"] = 0
# calculate average context relevance score over all queries
scores = [res["score"] for res in result["results"]]
valid_scores = [s for s in scores if not math.isnan(s)]
skipped = len(scores) - len(valid_scores)
if skipped:
logger.warning("{skipped} query(s) failed and were excluded from the score.", skipped=skipped)
result["score"] = mean(valid_scores) if valid_scores else float("nan")
result["individual_scores"] = scores # useful for the EvaluationRunResult
return result
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
A dictionary with serialized data.
"""
return default_to_dict(
self,
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
examples=self.examples,
progress_bar=self.progress_bar,
raise_on_failure=self.raise_on_failure,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ContextRelevanceEvaluator":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
if data["init_parameters"].get("chat_generator"):
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
return default_from_dict(cls, data)
@@ -0,0 +1,136 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack import Document, component, default_to_dict
@component
class DocumentMAPEvaluator:
"""
A Mean Average Precision (MAP) evaluator for documents.
Evaluator that calculates the mean average precision of the retrieved documents, a metric
that measures how high retrieved documents are ranked.
Each question can have multiple ground truth documents and multiple retrieved documents.
`DocumentMAPEvaluator` doesn't normalize its inputs, the `DocumentCleaner` component
should be used to clean and normalize the documents before passing them to this evaluator.
Usage example:
```python
from haystack import Document
from haystack.components.evaluators import DocumentMAPEvaluator
evaluator = DocumentMAPEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
],
)
print(result["individual_scores"])
# [1.0, 0.8333333333333333]
print(result["score"])
# 0.9166666666666666
```
"""
def __init__(self, document_comparison_field: str = "content") -> None:
"""
Create a DocumentMAPEvaluator component.
:param document_comparison_field:
The Document field to use for comparison. Possible options:
- `"content"`: uses `doc.content`
- `"id"`: uses `doc.id`
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
(e.g. `"meta.file_id"`, `"meta.page_number"`)
Nested keys are supported (e.g. `"meta.source.url"`).
"""
self.document_comparison_field = document_comparison_field
def _get_comparison_value(self, doc: Document) -> Any:
"""
Extract the comparison value from a document based on the configured field.
"""
if self.document_comparison_field == "content":
return doc.content
if self.document_comparison_field == "id":
return doc.id
if self.document_comparison_field.startswith("meta."):
parts = self.document_comparison_field[5:].split(".")
value = doc.meta
for part in parts:
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
msg = (
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
"Use 'content', 'id', or 'meta.<key>'."
)
raise ValueError(msg)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
# Refer to https://www.pinecone.io/learn/offline-evaluation/ for the algorithm.
@component.output_types(score=float, individual_scores=list[float])
def run(
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
) -> dict[str, Any]:
"""
Run the DocumentMAPEvaluator on the given inputs.
All lists must have the same length.
:param ground_truth_documents:
A list of expected documents for each question.
:param retrieved_documents:
A list of retrieved documents for each question.
:returns:
A dictionary with the following outputs:
- `score` - The average of calculated scores.
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents how high retrieved documents
are ranked.
"""
if len(ground_truth_documents) != len(retrieved_documents):
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
raise ValueError(msg)
individual_scores = []
for ground_truth, retrieved in zip(ground_truth_documents, retrieved_documents, strict=True):
average_precision = 0.0
average_precision_numerator = 0.0
relevant_documents = 0
ground_truth_values = [val for doc in ground_truth if (val := self._get_comparison_value(doc)) is not None]
for rank, retrieved_document in enumerate(retrieved):
retrieved_value = self._get_comparison_value(retrieved_document)
if retrieved_value is None:
continue
if retrieved_value in ground_truth_values:
relevant_documents += 1
average_precision_numerator += relevant_documents / (rank + 1)
if relevant_documents > 0:
average_precision = average_precision_numerator / relevant_documents
individual_scores.append(average_precision)
score = sum(individual_scores) / len(ground_truth_documents)
return {"score": score, "individual_scores": individual_scores}
@@ -0,0 +1,130 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack import Document, component, default_to_dict
@component
class DocumentMRREvaluator:
"""
Evaluator that calculates the mean reciprocal rank of the retrieved documents.
MRR measures how high the first retrieved document is ranked.
Each question can have multiple ground truth documents and multiple retrieved documents.
`DocumentMRREvaluator` doesn't normalize its inputs, the `DocumentCleaner` component
should be used to clean and normalize the documents before passing them to this evaluator.
Usage example:
```python
from haystack import Document
from haystack.components.evaluators import DocumentMRREvaluator
evaluator = DocumentMRREvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
],
)
print(result["individual_scores"])
# [1.0, 1.0]
print(result["score"])
# 1.0
```
"""
def __init__(self, document_comparison_field: str = "content") -> None:
"""
Create a DocumentMRREvaluator component.
:param document_comparison_field:
The Document field to use for comparison. Possible options:
- `"content"`: uses `doc.content`
- `"id"`: uses `doc.id`
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
(e.g. `"meta.file_id"`, `"meta.page_number"`)
Nested keys are supported (e.g. `"meta.source.url"`).
"""
self.document_comparison_field = document_comparison_field
def _get_comparison_value(self, doc: Document) -> Any:
"""
Extract the comparison value from a document based on the configured field.
"""
if self.document_comparison_field == "content":
return doc.content
if self.document_comparison_field == "id":
return doc.id
if self.document_comparison_field.startswith("meta."):
parts = self.document_comparison_field[5:].split(".")
value = doc.meta
for part in parts:
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
msg = (
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
"Use 'content', 'id', or 'meta.<key>'."
)
raise ValueError(msg)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
# Refer to https://www.pinecone.io/learn/offline-evaluation/ for the algorithm.
@component.output_types(score=float, individual_scores=list[float])
def run(
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
) -> dict[str, Any]:
"""
Run the DocumentMRREvaluator on the given inputs.
`ground_truth_documents` and `retrieved_documents` must have the same length.
:param ground_truth_documents:
A list of expected documents for each question.
:param retrieved_documents:
A list of retrieved documents for each question.
:returns:
A dictionary with the following outputs:
- `score` - The average of calculated scores.
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents how high the first retrieved
document is ranked.
"""
if len(ground_truth_documents) != len(retrieved_documents):
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
raise ValueError(msg)
individual_scores = []
for ground_truth, retrieved in zip(ground_truth_documents, retrieved_documents, strict=True):
reciprocal_rank = 0.0
ground_truth_values = [val for doc in ground_truth if (val := self._get_comparison_value(doc)) is not None]
for rank, retrieved_document in enumerate(retrieved):
retrieved_value = self._get_comparison_value(retrieved_document)
if retrieved_value is None:
continue
if retrieved_value in ground_truth_values:
reciprocal_rank = 1 / (rank + 1)
break
individual_scores.append(reciprocal_rank)
score = sum(individual_scores) / len(ground_truth_documents)
return {"score": score, "individual_scores": individual_scores}
@@ -0,0 +1,193 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from math import log2
from typing import Any
from haystack import Document, component, default_to_dict
@component
class DocumentNDCGEvaluator:
"""
Evaluator that calculates the normalized discounted cumulative gain (NDCG) of retrieved documents.
Each question can have multiple ground truth documents and multiple retrieved documents.
If the ground truth documents have relevance scores, the NDCG calculation uses these scores.
Otherwise, it assumes binary relevance of all ground truth documents.
Usage example:
```python
from haystack import Document
from haystack.components.evaluators import DocumentNDCGEvaluator
evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
ground_truth_documents=[[Document(content="France", score=1.0), Document(content="Paris", score=0.5)]],
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
)
print(result["individual_scores"])
# [0.8869]
print(result["score"])
# 0.8869
```
"""
def __init__(self, document_comparison_field: str = "content") -> None:
"""
Create a DocumentNDCGEvaluator component.
:param document_comparison_field:
The Document field to use for comparison. Possible options:
- `"content"`: uses `doc.content`
- `"id"`: uses `doc.id`
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
(e.g. `"meta.file_id"`, `"meta.page_number"`)
Nested keys are supported (e.g. `"meta.source.url"`).
"""
self.document_comparison_field = document_comparison_field
def _get_comparison_value(self, doc: Document) -> Any:
"""
Extract the comparison value from a document based on the configured field.
"""
if self.document_comparison_field == "content":
return doc.content
if self.document_comparison_field == "id":
return doc.id
if self.document_comparison_field.startswith("meta."):
parts = self.document_comparison_field[5:].split(".")
value = doc.meta
for part in parts:
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
msg = (
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
"Use 'content', 'id', or 'meta.<key>'."
)
raise ValueError(msg)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
@component.output_types(score=float, individual_scores=list[float])
def run(
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
) -> dict[str, Any]:
"""
Run the DocumentNDCGEvaluator on the given inputs.
`ground_truth_documents` and `retrieved_documents` must have the same length.
The list items within `ground_truth_documents` and `retrieved_documents` can differ in length.
:param ground_truth_documents:
Lists of expected documents, one list per question. Binary relevance is used if documents have no scores.
:param retrieved_documents:
Lists of retrieved documents, one list per question.
:returns:
A dictionary with the following outputs:
- `score` - The average of calculated scores.
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents the NDCG for each question.
"""
self.validate_inputs(ground_truth_documents, retrieved_documents)
individual_scores = []
for gt_docs, ret_docs in zip(ground_truth_documents, retrieved_documents, strict=True):
dcg = self.calculate_dcg(gt_docs, ret_docs)
idcg = self.calculate_idcg(gt_docs)
ndcg = dcg / idcg if idcg > 0 else 0
individual_scores.append(ndcg)
score = sum(individual_scores) / len(ground_truth_documents)
return {"score": score, "individual_scores": individual_scores}
@staticmethod
def validate_inputs(gt_docs: list[list[Document]], ret_docs: list[list[Document]]) -> None:
"""
Validate the input parameters.
:param gt_docs:
The ground_truth_documents to validate.
:param ret_docs:
The retrieved_documents to validate.
:raises ValueError:
If the ground_truth_documents or the retrieved_documents are an empty list.
If the length of ground_truth_documents and retrieved_documents differs.
If any list of documents in ground_truth_documents contains a mix of documents with and without a score.
"""
if len(gt_docs) == 0 or len(ret_docs) == 0:
msg = "ground_truth_documents and retrieved_documents must be provided."
raise ValueError(msg)
if len(gt_docs) != len(ret_docs):
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
raise ValueError(msg)
for docs in gt_docs:
if any(doc.score is not None for doc in docs) and any(doc.score is None for doc in docs):
msg = "Either none or all documents in each list of ground_truth_documents must have a score."
raise ValueError(msg)
def calculate_dcg(self, gt_docs: list[Document], ret_docs: list[Document]) -> float:
"""
Calculate the discounted cumulative gain (DCG) of the retrieved documents.
:param gt_docs:
The ground truth documents.
:param ret_docs:
The retrieved documents.
:returns:
The discounted cumulative gain (DCG) of the retrieved
documents based on the ground truth documents.
"""
dcg = 0.0
# Build lookup from comparison value -> relevance score, skipping documents
# whose comparison value cannot be determined (e.g. missing meta key)
relevant_value_to_score: dict[Any, float] = {}
for doc in gt_docs:
value = self._get_comparison_value(doc)
if value is not None:
relevant_value_to_score[value] = doc.score if doc.score is not None else 1
for i, doc in enumerate(ret_docs):
value = self._get_comparison_value(doc)
if value is not None and value in relevant_value_to_score:
dcg += relevant_value_to_score[value] / log2(i + 2) # i + 2 because i is 0-indexed
return dcg
def calculate_idcg(self, gt_docs: list[Document]) -> float:
"""
Calculate the ideal discounted cumulative gain (IDCG) of the ground truth documents.
Ground truth documents whose comparison value cannot be determined (e.g. missing meta key)
are excluded, since they can never be matched in `calculate_dcg` either. Including them here
would inflate the IDCG and make it impossible for NDCG to reach 1.0 for a perfect retrieval.
:param gt_docs:
The ground truth documents.
:returns:
The ideal discounted cumulative gain (IDCG) of the ground truth documents.
"""
# Filter out documents that cannot be matched, consistent with calculate_dcg
matchable_docs = [doc for doc in gt_docs if self._get_comparison_value(doc) is not None]
idcg = 0.0
for i, doc in enumerate(
sorted(matchable_docs, key=lambda x: x.score if x.score is not None else 1, reverse=True)
):
# If the document has a score, use it; otherwise, use 1 for binary relevance.
relevance = doc.score if doc.score is not None else 1
idcg += relevance / log2(i + 2) # i + 2 because i is 0-indexed
return idcg
@@ -0,0 +1,181 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from enum import Enum
from typing import Any
from haystack import component, default_to_dict, logging
from haystack.dataclasses import Document
logger = logging.getLogger(__name__)
class RecallMode(Enum):
"""
Enum for the mode to use for calculating the recall score.
"""
# Score is based on whether any document is retrieved.
SINGLE_HIT = "single_hit"
# Score is based on how many documents were retrieved.
MULTI_HIT = "multi_hit"
def __str__(self) -> str:
return self.value
@staticmethod
def from_str(string: str) -> "RecallMode":
"""
Convert a string to a RecallMode enum.
"""
enum_map = {e.value: e for e in RecallMode}
mode = enum_map.get(string)
if mode is None:
msg = f"Unknown recall mode '{string}'. Supported modes are: {list(enum_map.keys())}"
raise ValueError(msg)
return mode
@component
class DocumentRecallEvaluator:
"""
Evaluator that calculates the Recall score for a list of documents.
Returns both a list of scores for each question and the average.
There can be multiple ground truth documents and multiple predicted documents as input.
Usage example:
```python
from haystack import Document
from haystack.components.evaluators import DocumentRecallEvaluator
evaluator = DocumentRecallEvaluator()
result = evaluator.run(
ground_truth_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="9th")],
],
retrieved_documents=[
[Document(content="France")],
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
],
)
print(result["individual_scores"])
# [1.0, 1.0]
print(result["score"])
# 1.0
```
"""
def __init__(
self, mode: str | RecallMode = RecallMode.SINGLE_HIT, document_comparison_field: str = "content"
) -> None:
"""
Create a DocumentRecallEvaluator component.
:param mode:
Mode to use for calculating the recall score.
:param document_comparison_field:
The Document field to use for comparison. Possible options:
- `"content"`: uses `doc.content`
- `"id"`: uses `doc.id`
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
(e.g. `"meta.file_id"`, `"meta.page_number"`)
Nested keys are supported (e.g. `"meta.source.url"`).
"""
if isinstance(mode, str):
mode = RecallMode.from_str(mode)
self.mode = mode
self.document_comparison_field = document_comparison_field
def _get_comparison_value(self, doc: Document) -> Any:
"""
Extract the comparison value from a document based on the configured field.
"""
if self.document_comparison_field == "content":
return doc.content
if self.document_comparison_field == "id":
return doc.id
if self.document_comparison_field.startswith("meta."):
parts = self.document_comparison_field[5:].split(".")
value = doc.meta
for part in parts:
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
msg = (
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
"Use 'content', 'id', or 'meta.<key>'."
)
raise ValueError(msg)
def _recall_single_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
return float(len(retrieved_ground_truths) > 0)
def _recall_multi_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
if not unique_truths or unique_truths <= {"", None}:
logger.warning(
"There are no ground truth documents or none of them contain a valid comparison value. "
"Score will be set to 0."
)
return 0.0
if not unique_retrievals or unique_retrievals <= {"", None}:
logger.warning(
"There are no retrieved documents or none of them contain a valid comparison value. "
"Score will be set to 0."
)
return 0.0
return len(retrieved_ground_truths) / len(unique_truths)
@component.output_types(score=float, individual_scores=list[float])
def run(
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
) -> dict[str, Any]:
"""
Run the DocumentRecallEvaluator on the given inputs.
`ground_truth_documents` and `retrieved_documents` must have the same length.
:param ground_truth_documents:
A list of expected documents for each question.
:param retrieved_documents:
A list of retrieved documents for each question.
A dictionary with the following outputs:
- `score` - The average of calculated scores.
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents the proportion of matching
documents retrieved. If the mode is `single_hit`, the individual scores are 0 or 1.
"""
if len(ground_truth_documents) != len(retrieved_documents):
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
raise ValueError(msg)
if self.mode == RecallMode.SINGLE_HIT:
mode_function = self._recall_single_hit
elif self.mode == RecallMode.MULTI_HIT:
mode_function = self._recall_multi_hit
scores = [mode_function(gt, ret) for gt, ret in zip(ground_truth_documents, retrieved_documents, strict=True)]
return {"score": sum(scores) / len(retrieved_documents), "individual_scores": scores}
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(self, mode=str(self.mode), document_comparison_field=self.document_comparison_field)
@@ -0,0 +1,255 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import math
from typing import Any
from numpy import mean as np_mean
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.evaluators.llm_evaluator import LLMEvaluator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict
from haystack.utils import deserialize_chatgenerator_inplace
logger = logging.getLogger(__name__)
# Default examples to include in the prompt if the user does not provide any examples
_DEFAULT_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],
},
},
]
@component
class FaithfulnessEvaluator(LLMEvaluator):
"""
Evaluator that checks if a generated answer can be inferred from the provided contexts.
An LLM separates the answer into multiple statements and checks whether the statement can be inferred from the
context or not. The final score for the full answer is a number from 0.0 to 1.0. It represents the proportion of
statements that can be inferred from the provided contexts.
Usage example:
```python
from haystack.components.evaluators import FaithfulnessEvaluator
questions = ["Who created the Python language?"]
contexts = [
[(
"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 = [
"Python is a high-level general-purpose programming language that was created by George Lucas."
]
evaluator = FaithfulnessEvaluator()
result = evaluator.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
print(result["individual_scores"])
# [0.5]
print(result["score"])
# 0.5
print(result["results"])
# [{'statements': ['Python is a high-level general-purpose programming language.',
# 'Python was created by George Lucas.'], 'statement_scores': [1, 0], 'score': 0.5}]
```
"""
def __init__(
self,
examples: list[dict[str, Any]] | None = None,
progress_bar: bool = True,
raise_on_failure: bool = True,
chat_generator: ChatGenerator | None = None,
) -> None:
"""
Creates an instance of FaithfulnessEvaluator.
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
:param examples:
Optional few-shot examples conforming to the expected input and output format of FaithfulnessEvaluator.
Default examples will be used if none are provided.
Each example must be a dictionary with keys "inputs" and "outputs".
"inputs" must be a dictionary with keys "questions", "contexts", and "predicted_answers".
"outputs" must be a dictionary with "statements" and "statement_scores".
Expected format:
```python
[{
"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],
},
}]
```
:param progress_bar:
Whether to show a progress bar during the evaluation.
:param raise_on_failure:
Whether to raise an exception if the API call fails.
:param chat_generator:
a ChatGenerator instance which represents the LLM.
In order for the component to work, the LLM should be configured to return a JSON object. For example,
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
`generation_kwargs`.
"""
self.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."
)
self.inputs = [("questions", list[str]), ("contexts", list[list[str]]), ("predicted_answers", list[str])]
self.outputs = ["statements", "statement_scores"]
self.examples = examples or _DEFAULT_EXAMPLES
super(FaithfulnessEvaluator, self).__init__( # noqa: UP008
instructions=self.instructions,
inputs=self.inputs,
outputs=self.outputs,
examples=self.examples,
chat_generator=chat_generator,
raise_on_failure=raise_on_failure,
progress_bar=progress_bar,
)
@component.output_types(individual_scores=list[float], score=float, results=list[dict[str, Any]])
def run(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator.
:param questions:
A list of questions.
:param contexts:
A nested list of contexts that correspond to the questions.
:param predicted_answers:
A list of predicted answers.
:returns:
A dictionary with the following outputs:
- `score`: Mean faithfulness score over all the provided input answers.
- `individual_scores`: A list of faithfulness scores for each input answer.
- `results`: A list of dictionaries with `statements` and `statement_scores` for each input answer.
"""
result = super(FaithfulnessEvaluator, self).run(**inputs) # noqa: UP008
# Post-process the raw results to calculate relevance metrics and scores
return self._postprocess_results(result)
@component.output_types(individual_scores=list[float], score=float, results=list[dict[str, Any]])
async def run_async(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator asynchronously.
:param questions:
A list of questions.
:param contexts:
A nested list of contexts that correspond to the questions.
:param predicted_answers:
A list of predicted answers.
:returns:
A dictionary with the following outputs:
- `score`: Mean faithfulness score over all the provided input answers.
- `individual_scores`: A list of faithfulness scores for each input answer.
- `results`: A list of dictionaries with `statements` and `statement_scores` for each input answer.
"""
result = await super(FaithfulnessEvaluator, self).run_async(**inputs) # noqa: UP008
# Post-process the raw results to calculate relevance metrics and scores
return self._postprocess_results(result)
def _postprocess_results(self, result: dict[str, Any]) -> dict[str, Any]:
"""
Post-processes raw LLM evaluator outputs to compute faithfulness scores.
Calculates statement-level score averages, computes the overall mean faithfulness
score across successful queries, and updates the result payload.
:param result:
The raw evaluation dictionary from the base LLM evaluator.
:returns:
The updated dictionary containing final scores and tracking metrics.
"""
# calculate average statement faithfulness score per query
for idx, res in enumerate(result["results"]):
if res is None:
result["results"][idx] = {"statements": [], "statement_scores": [], "score": float("nan")}
continue
if not res["statements"]:
res["score"] = 0
else:
res["score"] = np_mean(res["statement_scores"])
# calculate average answer faithfulness score over all queries
scores = [res["score"] for res in result["results"]]
valid_scores = [s for s in scores if not math.isnan(s)]
skipped = len(scores) - len(valid_scores)
if skipped:
logger.warning("{skipped} query(s) failed and were excluded from the score.", skipped=skipped)
result["score"] = np_mean(valid_scores) if valid_scores else float("nan")
result["individual_scores"] = scores
return result
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
A dictionary with serialized data.
"""
return default_to_dict(
self,
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
examples=self.examples,
progress_bar=self.progress_bar,
raise_on_failure=self.raise_on_failure,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "FaithfulnessEvaluator":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
if data["init_parameters"].get("chat_generator"):
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
return default_from_dict(cls, data)
@@ -0,0 +1,462 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import json
from typing import Any
from tqdm import tqdm
from tqdm.asyncio import tqdm as async_tqdm
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.builders import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type
from haystack.utils.misc import _parse_dict_from_json
logger = logging.getLogger(__name__)
@component
class LLMEvaluator:
"""
Uses an LLM to evaluate inputs based on a prompt containing instructions and examples.
The default API requires an OpenAI API key to be provided as an environment variable "OPENAI_API_KEY".
The inputs are lists that are user-defined depending on the desired metric.
The output is a dictionary with a key `results` containing a list of evaluation results.
Each result is a dictionary with user-defined keys and values of either 0 for FALSE or 1 for TRUE respectively.
Usage example:
```python
from haystack.components.evaluators import LLMEvaluator
evaluator = LLMEvaluator(
instructions="Is this answer problematic for children?",
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}},
],
)
predicted_answers = [
"Football is the most popular sport with around 4 billion followers worldwide",
"Python language was created by Guido van Rossum.",
]
results = evaluator.run(predicted_answers=predicted_answers)
print(results)
# {'results': [{'score': 0}, {'score': 0}]}
```
"""
def __init__(
self,
instructions: str,
inputs: list[tuple[str, type[list]]],
outputs: list[str],
examples: list[dict[str, Any]],
progress_bar: bool = True,
*,
raise_on_failure: bool = True,
chat_generator: ChatGenerator | None = None,
) -> None:
"""
Creates an instance of LLMEvaluator.
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
:param instructions:
The prompt instructions to use for evaluation.
Should be a question about the inputs that can be answered with yes or no.
:param inputs:
The inputs that the component expects as incoming connections and that it evaluates.
Each input is a tuple of an input name and input type. Input types must be lists.
:param outputs:
Output names of the evaluation results. They correspond to keys in the output dictionary.
:param examples:
Few-shot examples conforming to the expected input and output format as defined in the `inputs` and
`outputs` parameters.
Each example is a dictionary with keys "inputs" and "outputs"
They contain the input and output as dictionaries respectively.
:param raise_on_failure:
If True, the component will raise an exception on an unsuccessful API call.
:param progress_bar:
Whether to show a progress bar during the evaluation.
:param chat_generator:
a ChatGenerator instance which represents the LLM.
In order for the component to work, the LLM should be configured to return a JSON object. For example,
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
`generation_kwargs`.
"""
self.validate_init_parameters(inputs, outputs, examples)
component.set_input_types(self, **dict(inputs))
self.raise_on_failure = raise_on_failure
self.instructions = instructions
self.inputs = inputs
self.outputs = outputs
self.examples = examples
self.progress_bar = progress_bar
template = self.prepare_template()
self.builder = PromptBuilder(template=template)
if chat_generator is not None:
self._chat_generator = chat_generator
else:
generation_kwargs = {"response_format": {"type": "json_object"}, "seed": 42}
self._chat_generator = OpenAIChatGenerator(generation_kwargs=generation_kwargs)
def warm_up(self) -> None:
"""
Warm up the underlying chat generator.
"""
if hasattr(self._chat_generator, "warm_up"):
self._chat_generator.warm_up()
async def warm_up_async(self) -> None:
"""
Warm up the underlying chat generator on the serving event loop.
"""
if hasattr(self._chat_generator, "warm_up_async"):
await self._chat_generator.warm_up_async()
elif hasattr(self._chat_generator, "warm_up"):
self._chat_generator.warm_up()
def close(self) -> None:
"""
Release the underlying chat generator's resources.
"""
if hasattr(self._chat_generator, "close"):
self._chat_generator.close()
async def close_async(self) -> None:
"""
Release the underlying chat generator's async resources.
"""
if hasattr(self._chat_generator, "close_async"):
await self._chat_generator.close_async()
elif hasattr(self._chat_generator, "close"):
self._chat_generator.close()
@staticmethod
def validate_init_parameters(
inputs: list[tuple[str, type[list]]], outputs: list[str], examples: list[dict[str, Any]]
) -> None:
"""
Validate the init parameters.
:param inputs:
The inputs to validate.
:param outputs:
The outputs to validate.
:param examples:
The examples to validate.
:raises ValueError:
If the inputs are not a list of tuples with a string and a type of list.
If the outputs are not a list of strings.
If the examples are not a list of dictionaries.
If any example does not have keys "inputs" and "outputs" with values that are dictionaries with string keys.
"""
# Validate inputs
if (
not isinstance(inputs, list)
or not all(isinstance(_input, tuple) for _input in inputs)
or not all(isinstance(_input[0], str) and _input[1] is not list and len(_input) == 2 for _input in inputs)
):
msg = (
f"LLM evaluator expects inputs to be a list of tuples. Each tuple must contain an input name and "
f"type of list but received {inputs}."
)
raise ValueError(msg)
# Validate outputs
if not isinstance(outputs, list) or not all(isinstance(output, str) for output in outputs):
msg = f"LLM evaluator expects outputs to be a list of str but received {outputs}."
raise ValueError(msg)
# Validate examples are lists of dicts
if not isinstance(examples, list) or not all(isinstance(example, dict) for example in examples):
msg = f"LLM evaluator expects examples to be a list of dictionaries but received {examples}."
raise ValueError(msg)
# Validate each example
for example in examples:
if (
{"inputs", "outputs"} != example.keys()
or not all(isinstance(example[param], dict) for param in ["inputs", "outputs"])
or not all(isinstance(key, str) for param in ["inputs", "outputs"] for key in example[param])
):
msg = (
f"LLM evaluator expects each example to have keys `inputs` and `outputs` with values that are "
f"dictionaries with str keys but received {example}."
)
raise ValueError(msg)
@component.output_types(results=list[dict[str, Any]])
def run(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator.
:param inputs:
The input values to evaluate. The keys are the input names and the values are lists of input values.
:returns:
A dictionary with a `results` entry that contains a list of results.
Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator
and the evaluation results as the values. If an exception occurs for a particular input value, the result
will be `None` for that entry.
If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included
in the output dictionary, under the key "meta".
:raises ValueError:
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
"""
self.warm_up()
self.validate_input_parameters(dict(self.inputs), inputs)
# inputs is a dictionary with keys being input names and values being a list of input values
# We need to iterate through the lists in parallel for all keys of the dictionary
input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True))
list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values]
results: list[dict[str, Any] | None] = []
metadata = []
errors = 0
for input_names_to_values in tqdm(list_of_input_names_to_values, disable=not self.progress_bar):
prompt = self.builder.run(**input_names_to_values)
messages = [ChatMessage.from_user(prompt["prompt"])]
try:
result = self._chat_generator.run(messages=messages)
except Exception as e:
if self.raise_on_failure:
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e)
results.append(None)
errors += 1
continue
parsed_result = _parse_dict_from_json(
result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure
)
if parsed_result is None:
results.append(None)
errors += 1
else:
results.append(parsed_result)
if result["replies"][0].meta:
metadata.append(result["replies"][0].meta)
if errors > 0:
logger.warning(
"LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.",
errors=errors,
len=len(list_of_input_names_to_values),
)
return {"results": results, "meta": metadata or None}
@component.output_types(results=list[dict[str, Any]])
async def run_async(self, **inputs: Any) -> dict[str, Any]:
"""
Run the LLM evaluator asynchronously
:param inputs:
The input values to evaluate. The keys are the input names and the values are lists of input values.
:returns:
A dictionary with a `results` entry that contains a list of results.
Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator
and the evaluation results as the values. If an exception occurs for a particular input value, the result
will be `None` for that entry.
If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included
in the output dictionary, under the key "meta".
:raises TypeError:
If the chat generator does not support async execution.
:raises ValueError:
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
"""
await self.warm_up_async()
self.validate_input_parameters(dict(self.inputs), inputs)
# inputs is a dictionary with keys being input names and values being a list of input values
# We need to iterate through the lists in parallel for all keys of the dictionary
input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True))
list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values]
results: list[dict[str, Any] | None] = []
metadata = []
errors = 0
generator_has_async = hasattr(self._chat_generator, "run_async")
for input_names_to_values in async_tqdm(list_of_input_names_to_values, disable=not self.progress_bar):
prompt = self.builder.run(**input_names_to_values)
messages = [ChatMessage.from_user(prompt["prompt"])]
try:
if generator_has_async:
result = await self._chat_generator.run_async(messages=messages) # type: ignore[attr-defined]
else:
logger.debug(
"{generator_type} does not implement 'run_async'."
" Running the synchronous 'run' method in a thread to avoid blocking the event loop.",
generator_type=type(self._chat_generator).__name__,
)
result = await asyncio.to_thread(self._chat_generator.run, messages=messages)
except Exception as e:
if self.raise_on_failure:
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e)
results.append(None)
errors += 1
continue
parsed_result = _parse_dict_from_json(
result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure
)
if parsed_result is None:
results.append(None)
errors += 1
else:
results.append(parsed_result)
if result["replies"][0].meta:
metadata.append(result["replies"][0].meta)
if errors > 0:
logger.warning(
"LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.",
errors=errors,
len=len(list_of_input_names_to_values),
)
return {"results": results, "meta": metadata or None}
def prepare_template(self) -> str:
"""
Prepare the prompt template.
Combine instructions, inputs, outputs, and examples into one prompt template with the following format:
Instructions:
`<instructions>`
Generate the response in JSON format with the following keys:
`<list of output keys>`
Consider the instructions and the examples below to determine those values.
Examples:
`<examples>`
Inputs:
`<inputs>`
Outputs:
:returns:
The prompt template.
"""
inputs_section = (
"{" + ", ".join([f'"{input_socket[0]}": {{{{ {input_socket[0]} }}}}' for input_socket in self.inputs]) + "}"
)
examples_section = "\n".join(
[
"Inputs:\n" + json.dumps(example["inputs"]) + "\nOutputs:\n" + json.dumps(example["outputs"])
for example in self.examples
]
)
return (
f"Instructions:\n"
f"{self.instructions}\n\n"
f"Generate the response in JSON format with the following keys:\n"
f"{json.dumps(self.outputs)}\n"
f"Consider the instructions and the examples below to determine those values.\n\n"
f"Examples:\n"
f"{examples_section}\n\n"
f"Inputs:\n"
f"{inputs_section}\n"
f"Outputs:\n"
)
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
# Since we cannot currently serialize tuples, convert the inputs to a list.
inputs = [[name, serialize_type(type_)] for name, type_ in self.inputs]
return default_to_dict(
self,
instructions=self.instructions,
inputs=inputs,
outputs=self.outputs,
examples=self.examples,
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
progress_bar=self.progress_bar,
raise_on_failure=self.raise_on_failure,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LLMEvaluator":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
data["init_parameters"]["inputs"] = [
(name, deserialize_type(type_)) for name, type_ in data["init_parameters"]["inputs"]
]
if data["init_parameters"].get("chat_generator"):
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
return default_from_dict(cls, data)
@staticmethod
def validate_input_parameters(expected: dict[str, Any], received: dict[str, Any]) -> None:
"""
Validate the input parameters.
:param expected:
The expected input parameters.
:param received:
The received input parameters.
:raises ValueError:
If not all expected inputs are present in the received inputs
If the received inputs are not lists or have different lengths
"""
# Validate that all expected inputs are present in the received inputs
for param in expected:
if param not in received:
msg = f"LLM evaluator expected input parameter '{param}' but received only {received.keys()}."
raise ValueError(msg)
# Validate that all received inputs are lists
if not all(isinstance(_input, list) for _input in received.values()):
msg = (
"LLM evaluator expects all input values to be lists but received "
f"{[type(_input) for _input in received.values()]}."
)
raise ValueError(msg)
# Validate that all received inputs are of the same length
inputs = received.values()
length = len(next(iter(inputs)))
if not all(len(_input) == length for _input in inputs):
msg = (
f"LLM evaluator expects all input lists to have the same length but received {inputs} with lengths "
f"{[len(_input) for _input in inputs]}."
)
raise ValueError(msg)
@@ -0,0 +1,188 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from numpy import mean as np_mean
from haystack import component, default_from_dict, default_to_dict
from haystack.lazy_imports import LazyImport
from haystack.utils import ComponentDevice, expit
from haystack.utils.auth import Secret
with LazyImport(message="Run 'pip install \"sentence-transformers>=5.0.0\"'") as sas_import:
from sentence_transformers import CrossEncoder, SentenceTransformer, util
from transformers import AutoConfig
@component
class SASEvaluator:
"""
SASEvaluator computes the Semantic Answer Similarity (SAS) between a list of predictions and a one of ground truths.
It's usually used in Retrieval Augmented Generation (RAG) pipelines to evaluate the quality of the generated
answers. The SAS is computed using a pre-trained model from the Hugging Face model hub. The model can be either a
Bi-Encoder or a Cross-Encoder. The choice of the model is based on the `model` parameter.
Usage example:
```python
from haystack.components.evaluators.sas_evaluator import SASEvaluator
evaluator = SASEvaluator(model="cross-encoder/ms-marco-MiniLM-L-6-v2")
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
)
print(result["score"])
# 0.9999673763910929
print(result["individual_scores"])
# [0.9999765157699585, 0.999968409538269, 0.9999572038650513]
```
"""
def __init__(
self,
model: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
batch_size: int = 32,
device: ComponentDevice | None = None,
token: Secret = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
) -> None:
"""
Creates a new instance of SASEvaluator.
:param model:
SentenceTransformers semantic textual similarity model, should be path or string pointing to a downloadable
model.
:param batch_size:
Number of prediction-label pairs to encode at once.
:param device:
The device on which the model is loaded. If `None`, the default device is automatically selected.
:param token:
The Hugging Face token for HTTP bearer authorization.
You can find your HF token in your [account settings](https://huggingface.co/settings/tokens)
"""
sas_import.check()
self._model = model
self._batch_size = batch_size
self._device = device
self._token = token
self._similarity_model: SentenceTransformer | CrossEncoder | None = None
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
return default_to_dict(
self, model=self._model, batch_size=self._batch_size, device=self._device, token=self._token
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SASEvaluator":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
return default_from_dict(cls, data)
def warm_up(self) -> None:
"""
Initializes the component.
"""
if self._similarity_model:
return
token = self._token.resolve_value() if self._token else None
config = AutoConfig.from_pretrained(self._model, use_auth_token=token)
cross_encoder_used = False
if config.architectures:
cross_encoder_used = any(arch.endswith("ForSequenceClassification") for arch in config.architectures)
device = ComponentDevice.resolve_device(self._device).to_torch_str()
# Based on the Model string we can load either Bi-Encoders or Cross Encoders.
# Similarity computation changes for both approaches
if cross_encoder_used:
self._similarity_model = CrossEncoder(self._model, device=device, token=token)
else:
self._similarity_model = SentenceTransformer(self._model, device=device, token=token)
@component.output_types(score=float, individual_scores=list[float])
def run(self, ground_truth_answers: list[str], predicted_answers: list[str]) -> dict[str, float | list[float]]:
"""
SASEvaluator component run method.
Run the SASEvaluator to compute the Semantic Answer Similarity (SAS) between a list of predicted answers
and a list of ground truth answers. Both must be list of strings of same length.
:param ground_truth_answers:
A list of expected answers for each question.
:param predicted_answers:
A list of generated answers for each question.
:returns:
A dictionary with the following outputs:
- `score`: Mean SAS score over all the predictions/ground-truth pairs.
- `individual_scores`: A list of similarity scores for each prediction/ground-truth pair.
"""
if len(ground_truth_answers) != len(predicted_answers):
raise ValueError("The number of predictions and labels must be the same.")
if any(answer is None for answer in predicted_answers):
raise ValueError("Predicted answers must not contain None values.")
if len(predicted_answers) == 0:
return {"score": 0.0, "individual_scores": [0.0]}
if not self._similarity_model:
self.warm_up()
if isinstance(self._similarity_model, CrossEncoder):
# For Cross Encoders we create a list of pairs of predictions and labels
sentence_pairs = list(zip(predicted_answers, ground_truth_answers, strict=True))
similarity_scores = self._similarity_model.predict(
sentence_pairs, batch_size=self._batch_size, convert_to_numpy=True
)
# All Cross Encoders do not return a set of logits scores that are normalized
# We normalize scores if they are larger than 1
if (similarity_scores > 1).any():
similarity_scores = expit(similarity_scores)
# Convert scores to list of floats from numpy array
similarity_scores = similarity_scores.tolist()
elif isinstance(self._similarity_model, SentenceTransformer):
# For Bi-encoders we create embeddings separately for predictions and labels
predictions_embeddings = self._similarity_model.encode(
predicted_answers, batch_size=self._batch_size, convert_to_tensor=True
)
label_embeddings = self._similarity_model.encode(
ground_truth_answers, batch_size=self._batch_size, convert_to_tensor=True
)
# Compute cosine-similarities
similarity_scores = [
float(util.cos_sim(pred_embedding, label_embedding).cpu().squeeze().numpy())
for pred_embedding, label_embedding in zip(predictions_embeddings, label_embeddings, strict=True)
]
sas_score = np_mean(similarity_scores)
return {"score": sas_score, "individual_scores": similarity_scores}