Coverage for haystack/components/joiners/answer_joiner.py: 98%

57 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-09 14:34 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import itertools 

6from collections.abc import Callable 

7from enum import Enum 

8from math import inf 

9from typing import Any 

10 

11from haystack import component, default_from_dict, default_to_dict, logging 

12from haystack.core.component.types import Variadic 

13from haystack.dataclasses.answer import ExtractedAnswer, GeneratedAnswer 

14 

15logger = logging.getLogger(__name__) 

16 

17AnswerType = GeneratedAnswer | ExtractedAnswer 

18 

19 

20class JoinMode(Enum): 

21 """ 

22 Enum for AnswerJoiner join modes. 

23 """ 

24 

25 CONCATENATE = "concatenate" 

26 

27 def __str__(self) -> str: 

28 return self.value 

29 

30 @staticmethod 

31 def from_str(string: str) -> "JoinMode": 

32 """ 

33 Convert a string to a JoinMode enum. 

34 """ 

35 enum_map = {e.value: e for e in JoinMode} 

36 mode = enum_map.get(string) 

37 if mode is None: 

38 msg = f"Unknown join mode '{string}'. Supported modes in AnswerJoiner are: {list(enum_map.keys())}" 

39 raise ValueError(msg) 

40 return mode 

41 

42 

43@component 

44class AnswerJoiner: 

45 """ 

46 Merges multiple lists of `Answer` objects into a single list. 

47 

48 Use this component to combine answers from different Generators into a single list. 

49 Currently, the component supports only one join mode: `CONCATENATE`. 

50 This mode concatenates multiple lists of answers into a single list. 

51 

52 ### Usage example 

53 

54 In this example, AnswerJoiner merges answers from two different Generators: 

55 

56 ```python 

57 from haystack.components.builders import AnswerBuilder 

58 from haystack.components.joiners import AnswerJoiner 

59 

60 from haystack.core.pipeline import Pipeline 

61 

62 from haystack.components.generators.chat import OpenAIResponsesChatGenerator 

63 from haystack.dataclasses import ChatMessage 

64 

65 

66 query = "What's Natural Language Processing?" 

67 messages = [ChatMessage.from_system("You are a helpful, respectful and honest assistant. Be super concise."), 

68 ChatMessage.from_user(query)] 

69 

70 pipe = Pipeline() 

71 pipe.add_component("llm_1", OpenAIResponsesChatGenerator()) 

72 pipe.add_component("llm_2", OpenAIResponsesChatGenerator()) 

73 pipe.add_component("aba", AnswerBuilder()) 

74 pipe.add_component("abb", AnswerBuilder()) 

75 pipe.add_component("joiner", AnswerJoiner()) 

76 

77 pipe.connect("llm_1.replies", "aba") 

78 pipe.connect("llm_2.replies", "abb") 

79 pipe.connect("aba.answers", "joiner") 

80 pipe.connect("abb.answers", "joiner") 

81 

82 results = pipe.run(data={"llm_1": {"messages": messages}, 

83 "llm_2": {"messages": messages}, 

84 "aba": {"query": query}, 

85 "abb": {"query": query}}) 

86 ``` 

87 """ 

88 

89 def __init__( 

90 self, join_mode: str | JoinMode = JoinMode.CONCATENATE, top_k: int | None = None, sort_by_score: bool = False 

91 ) -> None: 

92 """ 

93 Creates an AnswerJoiner component. 

94 

95 :param join_mode: 

96 Specifies the join mode to use. Available modes: 

97 - `concatenate`: Concatenates multiple lists of Answers into a single list. 

98 :param top_k: 

99 The maximum number of Answers to return. Must be `None` or greater than 0. 

100 :param sort_by_score: 

101 If `True`, sorts the answers by score in descending order. 

102 If an answer has no score, it is handled as if its score is -infinity. 

103 

104 :raises ValueError: 

105 If `top_k` is not `None` and is less than or equal to 0. 

106 """ 

107 if top_k is not None and top_k <= 0: 

108 raise ValueError("top_k must be greater than 0.") 

109 if isinstance(join_mode, str): 

110 join_mode = JoinMode.from_str(join_mode) 

111 join_mode_functions: dict[JoinMode, Callable[[list[list[AnswerType]]], list[AnswerType]]] = { 

112 JoinMode.CONCATENATE: self._concatenate 

113 } 

114 self.join_mode_function: Callable[[list[list[AnswerType]]], list[AnswerType]] = join_mode_functions[join_mode] 

115 self.join_mode = join_mode 

116 self.top_k = top_k 

117 self.sort_by_score = sort_by_score 

118 

119 @component.output_types(answers=list[AnswerType]) 

120 def run(self, answers: Variadic[list[AnswerType]], top_k: int | None = None) -> dict[str, Any]: 

121 """ 

122 Joins multiple lists of Answers into a single list depending on the `join_mode` parameter. 

123 

124 If the instance was created with `sort_by_score=True`, the merged Answers are sorted by 

125 score in descending order before `top_k` is applied; Answers without a score are handled 

126 as if their score were -infinity. Otherwise, the input order is preserved. 

127 

128 :param answers: 

129 Nested list of Answers to be merged. 

130 

131 :param top_k: 

132 The maximum number of Answers to return. Overrides the instance's `top_k` if provided. 

133 A value of 0 returns no answers. Must not be negative. 

134 

135 :returns: 

136 A dictionary with the following keys: 

137 - `answers`: Merged list of Answers, sorted by score if `sort_by_score` was set to 

138 `True` on the instance, otherwise in input order 

139 

140 :raises ValueError: 

141 If `top_k` is negative. 

142 """ 

143 answers_list = list(answers) 

144 join_function = self.join_mode_function 

145 output_answers: list[AnswerType] = join_function(answers_list) 

146 

147 if self.sort_by_score: 

148 output_answers = sorted( 

149 output_answers, 

150 key=lambda answer: score if (score := getattr(answer, "score", None)) is not None else -inf, 

151 reverse=True, 

152 ) 

153 if any(getattr(answer, "score", None) is None for answer in output_answers): 

154 logger.info( 

155 "Some of the Answers AnswerJoiner got have score=None. It was configured to sort Answers by " 

156 "score, so those with score=None were sorted as if they had a score of -infinity." 

157 ) 

158 

159 if top_k is not None: 

160 if top_k < 0: 

161 raise ValueError("top_k must not be negative.") 

162 output_answers = output_answers[:top_k] 

163 elif self.top_k is not None: 

164 output_answers = output_answers[: self.top_k] 

165 return {"answers": output_answers} 

166 

167 def _concatenate(self, answer_lists: list[list[AnswerType]]) -> list[AnswerType]: 

168 """ 

169 Concatenate multiple lists of Answers, flattening them into a single list. 

170 

171 :param answer_lists: List of lists of Answers to be flattened. 

172 """ 

173 return list(itertools.chain.from_iterable(answer_lists)) 

174 

175 def to_dict(self) -> dict[str, Any]: 

176 """ 

177 Serializes the component to a dictionary. 

178 

179 :returns: 

180 Dictionary with serialized data. 

181 """ 

182 return default_to_dict(self, join_mode=str(self.join_mode), top_k=self.top_k, sort_by_score=self.sort_by_score) 

183 

184 @classmethod 

185 def from_dict(cls, data: dict[str, Any]) -> "AnswerJoiner": 

186 """ 

187 Deserializes the component from a dictionary. 

188 

189 :param data: 

190 The dictionary to deserialize from. 

191 :returns: 

192 The deserialized component. 

193 """ 

194 return default_from_dict(cls, data)