chore: import upstream snapshot with attribution
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# 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_joiner": ["AnswerJoiner"],
|
||||
"branch": ["BranchJoiner"],
|
||||
"document_joiner": ["DocumentJoiner"],
|
||||
"list_joiner": ["ListJoiner"],
|
||||
"string_joiner": ["StringJoiner"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .answer_joiner import AnswerJoiner as AnswerJoiner
|
||||
from .branch import BranchJoiner as BranchJoiner
|
||||
from .document_joiner import DocumentJoiner as DocumentJoiner
|
||||
from .list_joiner import ListJoiner as ListJoiner
|
||||
from .string_joiner import StringJoiner as StringJoiner
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from math import inf
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.dataclasses.answer import ExtractedAnswer, GeneratedAnswer
|
||||
|
||||
AnswerType = GeneratedAnswer | ExtractedAnswer
|
||||
|
||||
|
||||
class JoinMode(Enum):
|
||||
"""
|
||||
Enum for AnswerJoiner join modes.
|
||||
"""
|
||||
|
||||
CONCATENATE = "concatenate"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "JoinMode":
|
||||
"""
|
||||
Convert a string to a JoinMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in JoinMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown join mode '{string}'. Supported modes in AnswerJoiner are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class AnswerJoiner:
|
||||
"""
|
||||
Merges multiple lists of `Answer` objects into a single list.
|
||||
|
||||
Use this component to combine answers from different Generators into a single list.
|
||||
Currently, the component supports only one join mode: `CONCATENATE`.
|
||||
This mode concatenates multiple lists of answers into a single list.
|
||||
|
||||
### Usage example
|
||||
|
||||
In this example, AnswerJoiner merges answers from two different Generators:
|
||||
|
||||
```python
|
||||
from haystack.components.builders import AnswerBuilder
|
||||
from haystack.components.joiners import AnswerJoiner
|
||||
|
||||
from haystack.core.pipeline import Pipeline
|
||||
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
|
||||
query = "What's Natural Language Processing?"
|
||||
messages = [ChatMessage.from_system("You are a helpful, respectful and honest assistant. Be super concise."),
|
||||
ChatMessage.from_user(query)]
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("llm_1", OpenAIChatGenerator())
|
||||
pipe.add_component("llm_2", OpenAIChatGenerator())
|
||||
pipe.add_component("aba", AnswerBuilder())
|
||||
pipe.add_component("abb", AnswerBuilder())
|
||||
pipe.add_component("joiner", AnswerJoiner())
|
||||
|
||||
pipe.connect("llm_1.replies", "aba")
|
||||
pipe.connect("llm_2.replies", "abb")
|
||||
pipe.connect("aba.answers", "joiner")
|
||||
pipe.connect("abb.answers", "joiner")
|
||||
|
||||
results = pipe.run(data={"llm_1": {"messages": messages},
|
||||
"llm_2": {"messages": messages},
|
||||
"aba": {"query": query},
|
||||
"abb": {"query": query}})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, join_mode: str | JoinMode = JoinMode.CONCATENATE, top_k: int | None = None, sort_by_score: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Creates an AnswerJoiner component.
|
||||
|
||||
:param join_mode:
|
||||
Specifies the join mode to use. Available modes:
|
||||
- `concatenate`: Concatenates multiple lists of Answers into a single list.
|
||||
:param top_k:
|
||||
The maximum number of Answers to return.
|
||||
:param sort_by_score:
|
||||
If `True`, sorts the documents by score in descending order.
|
||||
If a document has no score, it is handled as if its score is -infinity.
|
||||
"""
|
||||
if isinstance(join_mode, str):
|
||||
join_mode = JoinMode.from_str(join_mode)
|
||||
join_mode_functions: dict[JoinMode, Callable[[list[list[AnswerType]]], list[AnswerType]]] = {
|
||||
JoinMode.CONCATENATE: self._concatenate
|
||||
}
|
||||
self.join_mode_function: Callable[[list[list[AnswerType]]], list[AnswerType]] = join_mode_functions[join_mode]
|
||||
self.join_mode = join_mode
|
||||
self.top_k = top_k
|
||||
self.sort_by_score = sort_by_score
|
||||
|
||||
@component.output_types(answers=list[AnswerType])
|
||||
def run(self, answers: Variadic[list[AnswerType]], top_k: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Joins multiple lists of Answers into a single list depending on the `join_mode` parameter.
|
||||
|
||||
:param answers:
|
||||
Nested list of Answers to be merged.
|
||||
|
||||
:param top_k:
|
||||
The maximum number of Answers to return. Overrides the instance's `top_k` if provided.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `answers`: Merged list of Answers
|
||||
"""
|
||||
answers_list = list(answers)
|
||||
join_function = self.join_mode_function
|
||||
output_answers: list[AnswerType] = join_function(answers_list)
|
||||
|
||||
if self.sort_by_score:
|
||||
output_answers = sorted(
|
||||
output_answers,
|
||||
key=lambda answer: score if (score := getattr(answer, "score", None)) is not None else -inf,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
top_k = top_k or self.top_k
|
||||
if top_k:
|
||||
output_answers = output_answers[:top_k]
|
||||
return {"answers": output_answers}
|
||||
|
||||
def _concatenate(self, answer_lists: list[list[AnswerType]]) -> list[AnswerType]:
|
||||
"""
|
||||
Concatenate multiple lists of Answers, flattening them into a single list and sorting by score.
|
||||
|
||||
:param answer_lists: List of lists of Answers to be flattened.
|
||||
"""
|
||||
return list(itertools.chain.from_iterable(answer_lists))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, join_mode=str(self.join_mode), top_k=self.top_k, sort_by_score=self.sort_by_score)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AnswerJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,129 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import GreedyVariadic
|
||||
from haystack.utils import deserialize_type, serialize_type
|
||||
|
||||
|
||||
@component
|
||||
class BranchJoiner:
|
||||
"""
|
||||
A component that merges multiple input branches of a pipeline into a single output stream.
|
||||
|
||||
`BranchJoiner` receives multiple inputs of the same data type and forwards the first received value
|
||||
to its output. This is useful for scenarios where multiple branches need to converge before proceeding.
|
||||
|
||||
### Common Use Cases:
|
||||
- **Loop Handling:** `BranchJoiner` helps close loops in pipelines. For example, if a pipeline component validates
|
||||
or modifies incoming data and produces an error-handling branch, `BranchJoiner` can merge both branches and send
|
||||
(or resend in the case of a loop) the data to the component that evaluates errors. See "Usage example" below.
|
||||
|
||||
- **Decision-Based Merging:** `BranchJoiner` reconciles branches coming from Router components (such as
|
||||
`ConditionalRouter`, `TextLanguageRouter`). Suppose a `TextLanguageRouter` directs user queries to different
|
||||
Retrievers based on the detected language. Each Retriever processes its assigned query and passes the results
|
||||
to `BranchJoiner`, which consolidates them into a single output before passing them to the next component, such
|
||||
as a `PromptBuilder`.
|
||||
|
||||
### Example Usage:
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack import Pipeline
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.joiners import BranchJoiner
|
||||
from haystack.components.validators import JsonSchemaValidator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
# Define a schema for validation
|
||||
person_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": {"type": "string", "pattern": "^[A-Z][a-z]+$"},
|
||||
"last_name": {"type": "string", "pattern": "^[A-Z][a-z]+$"},
|
||||
"nationality": {"type": "string", "enum": ["Italian", "Portuguese", "American"]},
|
||||
},
|
||||
"required": ["first_name", "last_name", "nationality"]
|
||||
}
|
||||
|
||||
# Initialize a pipeline
|
||||
pipe = Pipeline()
|
||||
|
||||
# Add components to the pipeline
|
||||
pipe.add_component("joiner", BranchJoiner(list[ChatMessage]))
|
||||
pipe.add_component("generator", OpenAIChatGenerator(model="gpt-4.1-mini"))
|
||||
pipe.add_component("validator", JsonSchemaValidator(json_schema=person_schema))
|
||||
|
||||
# And connect them
|
||||
pipe.connect("joiner", "generator")
|
||||
pipe.connect("generator.replies", "validator.messages")
|
||||
pipe.connect("validator.validation_error", "joiner")
|
||||
|
||||
result = pipe.run(
|
||||
data={
|
||||
"generator": {"generation_kwargs": {"response_format": {"type": "json_object"}}},
|
||||
"joiner": {"value": [ChatMessage.from_user("Create json from Peter Parker")]}}
|
||||
)
|
||||
|
||||
print(json.loads(result["validator"]["validated"][0].text))
|
||||
|
||||
|
||||
# >> {'first_name': 'Peter', 'last_name': 'Parker', 'nationality': 'American', 'name': 'Spider-Man', 'occupation':
|
||||
# >> 'Superhero', 'age': 23, 'location': 'New York City'}
|
||||
```
|
||||
|
||||
Note that `BranchJoiner` can manage only one data type at a time. In this case, `BranchJoiner` is created for
|
||||
passing `list[ChatMessage]`. This determines the type of data that `BranchJoiner` will receive from the upstream
|
||||
connected components and also the type of data that `BranchJoiner` will send through its output.
|
||||
|
||||
In the code example, `BranchJoiner` receives a looped back `list[ChatMessage]` from the `JsonSchemaValidator` and
|
||||
sends it down to the `OpenAIChatGenerator` for re-generation. We can have multiple loopback connections in the
|
||||
pipeline. In this instance, the downstream component is only one (the `OpenAIChatGenerator`), but the pipeline could
|
||||
have more than one downstream component.
|
||||
"""
|
||||
|
||||
def __init__(self, type_: type) -> None:
|
||||
"""
|
||||
Creates a `BranchJoiner` component.
|
||||
|
||||
:param type_: The expected data type of inputs and outputs.
|
||||
"""
|
||||
self.type_ = type_
|
||||
component.set_input_types(self, value=GreedyVariadic[type_]) # type: ignore
|
||||
component.set_output_types(self, value=type_)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component into a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, type_=serialize_type(self.type_))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "BranchJoiner":
|
||||
"""
|
||||
Deserializes a `BranchJoiner` instance from a dictionary.
|
||||
|
||||
:param data: The dictionary containing serialized component data.
|
||||
:returns:
|
||||
A deserialized `BranchJoiner` instance.
|
||||
"""
|
||||
data["init_parameters"]["type_"] = deserialize_type(data["init_parameters"]["type_"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Executes the `BranchJoiner`, selecting the first available input value and passing it downstream.
|
||||
|
||||
:param **kwargs: The input data. Must be of the type declared by `type_` during initialization.
|
||||
:returns:
|
||||
A dictionary with a single key `value`, containing the first input received.
|
||||
"""
|
||||
if (inputs_count := len(kwargs["value"])) != 1:
|
||||
raise ValueError(f"BranchJoiner expects only one input, but {inputs_count} were received.")
|
||||
return {"value": kwargs["value"][0]}
|
||||
@@ -0,0 +1,271 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import replace
|
||||
from enum import Enum
|
||||
from math import inf
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.utils.misc import _reciprocal_rank_fusion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JoinMode(Enum):
|
||||
"""
|
||||
Enum for join mode.
|
||||
"""
|
||||
|
||||
CONCATENATE = "concatenate"
|
||||
MERGE = "merge"
|
||||
RECIPROCAL_RANK_FUSION = "reciprocal_rank_fusion"
|
||||
DISTRIBUTION_BASED_RANK_FUSION = "distribution_based_rank_fusion"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "JoinMode":
|
||||
"""
|
||||
Convert a string to a JoinMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in JoinMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown join mode '{string}'. Supported modes in DocumentJoiner are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class DocumentJoiner:
|
||||
"""
|
||||
Joins multiple lists of documents into a single list.
|
||||
|
||||
It supports different join modes:
|
||||
- concatenate: Keeps the highest-scored document in case of duplicates.
|
||||
- merge: Calculates a weighted sum of scores for duplicates and merges them.
|
||||
- reciprocal_rank_fusion: Merges and assigns scores based on reciprocal rank fusion.
|
||||
- distribution_based_rank_fusion: Merges and assigns scores based on scores distribution in each Retriever.
|
||||
|
||||
### Usage example:
|
||||
|
||||
```python
|
||||
from haystack import Pipeline, Document
|
||||
from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder
|
||||
from haystack.components.joiners import DocumentJoiner
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever
|
||||
from haystack.components.retrievers import InMemoryEmbeddingRetriever
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
|
||||
document_store = InMemoryDocumentStore()
|
||||
docs = [Document(content="Paris"), Document(content="Berlin"), Document(content="London")]
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
docs_embeddings = embedder.run(docs)
|
||||
document_store.write_documents(docs_embeddings['documents'])
|
||||
|
||||
p = Pipeline()
|
||||
p.add_component(instance=InMemoryBM25Retriever(document_store=document_store), name="bm25_retriever")
|
||||
p.add_component(
|
||||
instance=OpenAITextEmbedder(),
|
||||
name="text_embedder",
|
||||
)
|
||||
p.add_component(instance=InMemoryEmbeddingRetriever(document_store=document_store), name="embedding_retriever")
|
||||
p.add_component(instance=DocumentJoiner(), name="joiner")
|
||||
p.connect("bm25_retriever", "joiner")
|
||||
p.connect("embedding_retriever", "joiner")
|
||||
p.connect("text_embedder", "embedding_retriever")
|
||||
query = "What is the capital of France?"
|
||||
p.run(data={"query": query, "text": query, "top_k": 1})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
join_mode: str | JoinMode = JoinMode.CONCATENATE,
|
||||
weights: list[float] | None = None,
|
||||
top_k: int | None = None,
|
||||
sort_by_score: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a DocumentJoiner component.
|
||||
|
||||
:param join_mode:
|
||||
Specifies the join mode to use. Available modes:
|
||||
- `concatenate`: Keeps the highest-scored document in case of duplicates.
|
||||
- `merge`: Calculates a weighted sum of scores for duplicates and merges them.
|
||||
- `reciprocal_rank_fusion`: Merges and assigns scores based on reciprocal rank fusion.
|
||||
- `distribution_based_rank_fusion`: Merges and assigns scores based on scores
|
||||
distribution in each Retriever.
|
||||
:param weights:
|
||||
Assign importance to each list of documents to influence how they're joined.
|
||||
This parameter is ignored for
|
||||
`concatenate` or `distribution_based_rank_fusion` join modes.
|
||||
Weight for each list of documents must match the number of inputs.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param sort_by_score:
|
||||
If `True`, sorts the documents by score in descending order.
|
||||
If a document has no score, it is handled as if its score is -infinity.
|
||||
"""
|
||||
if isinstance(join_mode, str):
|
||||
join_mode = JoinMode.from_str(join_mode)
|
||||
join_mode_functions = {
|
||||
JoinMode.CONCATENATE: DocumentJoiner._concatenate,
|
||||
JoinMode.MERGE: self._merge,
|
||||
JoinMode.RECIPROCAL_RANK_FUSION: self._rrf,
|
||||
JoinMode.DISTRIBUTION_BASED_RANK_FUSION: DocumentJoiner._distribution_based_rank_fusion,
|
||||
}
|
||||
self.join_mode_function = join_mode_functions[join_mode]
|
||||
self.join_mode = join_mode
|
||||
if weights:
|
||||
weight_sum = sum(weights)
|
||||
if weight_sum == 0:
|
||||
raise ValueError("The provided `weights` must not sum to zero.")
|
||||
self.weights: list[float] | None = [float(i) / weight_sum for i in weights]
|
||||
else:
|
||||
self.weights = None
|
||||
self.top_k = top_k
|
||||
self.sort_by_score = sort_by_score
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: Variadic[list[Document]], top_k: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Joins multiple lists of Documents into a single list depending on the `join_mode` parameter.
|
||||
|
||||
:param documents:
|
||||
List of list of documents to be merged.
|
||||
:param top_k:
|
||||
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Merged list of Documents
|
||||
"""
|
||||
documents = list(documents)
|
||||
output_documents = self.join_mode_function(documents)
|
||||
|
||||
if self.sort_by_score:
|
||||
output_documents = sorted(
|
||||
output_documents, key=lambda doc: doc.score if doc.score is not None else -inf, reverse=True
|
||||
)
|
||||
if any(doc.score is None for doc in output_documents):
|
||||
logger.info(
|
||||
"Some of the Documents DocumentJoiner got have score=None. It was configured to sort Documents by "
|
||||
"score, so those with score=None were sorted as if they had a score of -infinity."
|
||||
)
|
||||
|
||||
if top_k:
|
||||
output_documents = output_documents[:top_k]
|
||||
elif self.top_k:
|
||||
output_documents = output_documents[: self.top_k]
|
||||
|
||||
return {"documents": output_documents}
|
||||
|
||||
@staticmethod
|
||||
def _concatenate(document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Concatenate multiple lists of Documents and return only the Document with the highest score for duplicates.
|
||||
"""
|
||||
output = []
|
||||
docs_per_id = defaultdict(list)
|
||||
for doc in itertools.chain.from_iterable(document_lists):
|
||||
docs_per_id[doc.id].append(doc)
|
||||
for docs in docs_per_id.values():
|
||||
doc_with_best_score = max(docs, key=lambda doc: doc.score if doc.score is not None else -inf)
|
||||
output.append(doc_with_best_score)
|
||||
return output
|
||||
|
||||
def _merge(self, document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and calculate a weighted sum of the scores of duplicate Documents.
|
||||
"""
|
||||
# This check prevents a division by zero when no documents are passed
|
||||
if not document_lists:
|
||||
return []
|
||||
|
||||
scores_map: dict = defaultdict(int)
|
||||
documents_map = {}
|
||||
weights = self.weights if self.weights else [1 / len(document_lists)] * len(document_lists)
|
||||
|
||||
for documents, weight in zip(document_lists, weights, strict=True):
|
||||
for doc in documents:
|
||||
scores_map[doc.id] += (doc.score if doc.score is not None else 0) * weight
|
||||
documents_map[doc.id] = doc
|
||||
|
||||
return [replace(doc, score=scores_map[doc.id]) for doc in documents_map.values()]
|
||||
|
||||
def _rrf(self, document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and assign scores based on reciprocal rank fusion.
|
||||
"""
|
||||
return _reciprocal_rank_fusion(document_lists, weights=self.weights)
|
||||
|
||||
@staticmethod
|
||||
def _distribution_based_rank_fusion(document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and assign scores based on Distribution-Based Score Fusion.
|
||||
|
||||
(https://medium.com/plain-simple-software/distribution-based-score-fusion-dbsf-a-new-approach-to-vector-search-ranking-f87c37488b18)
|
||||
If a Document is in more than one retriever, the one with the highest score is used.
|
||||
"""
|
||||
rescaled_lists: list[list[Document]] = []
|
||||
for documents in document_lists:
|
||||
if len(documents) == 0:
|
||||
rescaled_lists.append(documents)
|
||||
continue
|
||||
|
||||
scores_list = [doc.score if doc.score is not None else 0 for doc in documents]
|
||||
|
||||
mean_score = sum(scores_list) / len(scores_list)
|
||||
std_dev = (sum((x - mean_score) ** 2 for x in scores_list) / len(scores_list)) ** 0.5
|
||||
min_score = mean_score - 3 * std_dev
|
||||
max_score = mean_score + 3 * std_dev
|
||||
delta_score = max_score - min_score
|
||||
|
||||
# if all docs have the same score delta_score is 0, the docs are uninformative for the query
|
||||
rescaled_lists.append(
|
||||
[
|
||||
replace(
|
||||
doc,
|
||||
score=((doc.score if doc.score is not None else 0) - min_score) / delta_score
|
||||
if delta_score != 0.0
|
||||
else 0.0,
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
)
|
||||
|
||||
return DocumentJoiner._concatenate(document_lists=rescaled_lists)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
join_mode=str(self.join_mode),
|
||||
weights=self.weights,
|
||||
top_k=self.top_k,
|
||||
sort_by_score=self.sort_by_score,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "DocumentJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.utils import deserialize_type, serialize_type
|
||||
|
||||
|
||||
@component
|
||||
class ListJoiner:
|
||||
"""
|
||||
A component that joins multiple lists into a single flat list.
|
||||
|
||||
The ListJoiner receives multiple lists of the same type and concatenates them into a single flat list.
|
||||
The output order respects the pipeline's execution sequence, with earlier inputs being added first.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.builders import ChatPromptBuilder
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack import Pipeline
|
||||
from haystack.components.joiners import ListJoiner
|
||||
|
||||
|
||||
user_message = [ChatMessage.from_user("Give a brief answer the following question: {{query}}")]
|
||||
|
||||
feedback_prompt = \"""
|
||||
You are given a question and an answer.
|
||||
Your task is to provide a score and a brief feedback on the answer.
|
||||
Question: {{query}}
|
||||
Answer: {{response}}
|
||||
\"""
|
||||
feedback_message = [ChatMessage.from_system(feedback_prompt)]
|
||||
|
||||
prompt_builder = ChatPromptBuilder(template=user_message)
|
||||
feedback_prompt_builder = ChatPromptBuilder(template=feedback_message)
|
||||
llm = OpenAIChatGenerator()
|
||||
feedback_llm = OpenAIChatGenerator()
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("prompt_builder", prompt_builder)
|
||||
pipe.add_component("llm", llm)
|
||||
pipe.add_component("feedback_prompt_builder", feedback_prompt_builder)
|
||||
pipe.add_component("feedback_llm", feedback_llm)
|
||||
pipe.add_component("list_joiner", ListJoiner(list[ChatMessage]))
|
||||
|
||||
pipe.connect("prompt_builder.prompt", "llm.messages")
|
||||
pipe.connect("prompt_builder.prompt", "list_joiner")
|
||||
pipe.connect("llm.replies", "list_joiner")
|
||||
pipe.connect("llm.replies", "feedback_prompt_builder.response")
|
||||
pipe.connect("feedback_prompt_builder.prompt", "feedback_llm.messages")
|
||||
pipe.connect("feedback_llm.replies", "list_joiner")
|
||||
|
||||
query = "What is nuclear physics?"
|
||||
ans = pipe.run(data={"prompt_builder": {"template_variables":{"query": query}},
|
||||
"feedback_prompt_builder": {"template_variables":{"query": query}}})
|
||||
|
||||
print(ans["list_joiner"]["values"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, list_type_: type | None = None) -> None:
|
||||
"""
|
||||
Creates a ListJoiner component.
|
||||
|
||||
:param list_type_: The expected type of the lists this component will join (e.g., list[ChatMessage]).
|
||||
If specified, all input lists must conform to this type. If None, the component defaults to handling
|
||||
lists of any type including mixed types.
|
||||
"""
|
||||
self.list_type_ = list_type_
|
||||
if list_type_ is not None:
|
||||
component.set_output_types(self, values=list_type_)
|
||||
else:
|
||||
component.set_output_types(self, values=list[Any])
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns: Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, list_type_=serialize_type(self.list_type_) if self.list_type_ is not None else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ListJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data: Dictionary to deserialize from.
|
||||
:returns: Deserialized component.
|
||||
"""
|
||||
init_parameters = data.get("init_parameters")
|
||||
if init_parameters is not None and init_parameters.get("list_type_") is not None:
|
||||
data["init_parameters"]["list_type_"] = deserialize_type(data["init_parameters"]["list_type_"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def run(self, values: Variadic[list[Any]]) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Joins multiple lists into a single flat list.
|
||||
|
||||
:param values: The list to be joined.
|
||||
:returns: Dictionary with 'values' key containing the joined list.
|
||||
"""
|
||||
result = list(chain(*values))
|
||||
return {"values": result}
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
from haystack import component
|
||||
from haystack.core.component.types import Variadic
|
||||
|
||||
|
||||
@component
|
||||
class StringJoiner:
|
||||
"""
|
||||
Component to join strings from different components to a list of strings.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.joiners import StringJoiner
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.core.pipeline import Pipeline
|
||||
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
string_1 = "What's Natural Language Processing?"
|
||||
string_2 = "What is life?"
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("prompt_builder_1", PromptBuilder("Builder 1: {{query}}"))
|
||||
pipeline.add_component("prompt_builder_2", PromptBuilder("Builder 2: {{query}}"))
|
||||
pipeline.add_component("string_joiner", StringJoiner())
|
||||
|
||||
pipeline.connect("prompt_builder_1.prompt", "string_joiner.strings")
|
||||
pipeline.connect("prompt_builder_2.prompt", "string_joiner.strings")
|
||||
|
||||
print(pipeline.run(data={"prompt_builder_1": {"query": string_1}, "prompt_builder_2": {"query": string_2}}))
|
||||
|
||||
# >> {"string_joiner": {"strings": ["Builder 1: What's Natural Language Processing?", "Builder 2: What is life?"]}}
|
||||
```
|
||||
"""
|
||||
|
||||
@component.output_types(strings=list[str])
|
||||
def run(self, strings: Variadic[str]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Joins strings into a list of strings
|
||||
|
||||
:param strings:
|
||||
strings from different components
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `strings`: Merged list of strings
|
||||
"""
|
||||
|
||||
out_strings = list(strings)
|
||||
return {"strings": out_strings}
|
||||
Reference in New Issue
Block a user