Coverage for haystack/components/joiners/document_joiner.py: 100%

101 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

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

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import itertools 

6from collections import defaultdict 

7from dataclasses import replace 

8from enum import Enum 

9from math import inf 

10from typing import Any 

11 

12from haystack import Document, component, default_from_dict, default_to_dict, logging 

13from haystack.core.component.types import Variadic 

14from haystack.utils.misc import _reciprocal_rank_fusion 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class JoinMode(Enum): 

20 """ 

21 Enum for join mode. 

22 """ 

23 

24 CONCATENATE = "concatenate" 

25 MERGE = "merge" 

26 RECIPROCAL_RANK_FUSION = "reciprocal_rank_fusion" 

27 DISTRIBUTION_BASED_RANK_FUSION = "distribution_based_rank_fusion" 

28 

29 def __str__(self) -> str: 

30 return self.value 

31 

32 @staticmethod 

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

34 """ 

35 Convert a string to a JoinMode enum. 

36 """ 

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

38 mode = enum_map.get(string) 

39 if mode is None: 

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

41 raise ValueError(msg) 

42 return mode 

43 

44 

45@component 

46class DocumentJoiner: 

47 """ 

48 Joins multiple lists of documents into a single list. 

49 

50 It supports different join modes: 

51 - concatenate: Keeps the highest-scored document in case of duplicates. 

52 - merge: Calculates a weighted sum of scores for duplicates and merges them. 

53 - reciprocal_rank_fusion: Merges and assigns scores based on reciprocal rank fusion. 

54 - distribution_based_rank_fusion: Merges and assigns scores based on scores distribution in each Retriever. 

55 

56 ### Usage example: 

57 

58 ```python 

59 from haystack import Pipeline, Document 

60 from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder 

61 from haystack.components.joiners import DocumentJoiner 

62 from haystack.components.retrievers import InMemoryBM25Retriever 

63 from haystack.components.retrievers import InMemoryEmbeddingRetriever 

64 from haystack.document_stores.in_memory import InMemoryDocumentStore 

65 

66 document_store = InMemoryDocumentStore() 

67 docs = [Document(content="Paris"), Document(content="Berlin"), Document(content="London")] 

68 embedder = OpenAIDocumentEmbedder() 

69 docs_embeddings = embedder.run(docs) 

70 document_store.write_documents(docs_embeddings['documents']) 

71 

72 p = Pipeline() 

73 p.add_component(instance=InMemoryBM25Retriever(document_store=document_store), name="bm25_retriever") 

74 p.add_component( 

75 instance=OpenAITextEmbedder(), 

76 name="text_embedder", 

77 ) 

78 p.add_component(instance=InMemoryEmbeddingRetriever(document_store=document_store), name="embedding_retriever") 

79 p.add_component(instance=DocumentJoiner(), name="joiner") 

80 p.connect("bm25_retriever", "joiner") 

81 p.connect("embedding_retriever", "joiner") 

82 p.connect("text_embedder.embedding", "embedding_retriever.query_embedding") 

83 query = "What is the capital of France?" 

84 p.run(data={"query": query, "text": query, "top_k": 1}) 

85 ``` 

86 """ 

87 

88 def __init__( 

89 self, 

90 join_mode: str | JoinMode = JoinMode.CONCATENATE, 

91 weights: list[float] | None = None, 

92 top_k: int | None = None, 

93 sort_by_score: bool = True, 

94 ) -> None: 

95 """ 

96 Creates a DocumentJoiner component. 

97 

98 :param join_mode: 

99 Specifies the join mode to use. Available modes: 

100 - `concatenate`: Keeps the highest-scored document in case of duplicates. 

101 - `merge`: Calculates a weighted sum of scores for duplicates and merges them. 

102 - `reciprocal_rank_fusion`: Merges and assigns scores based on reciprocal rank fusion. 

103 - `distribution_based_rank_fusion`: Merges and assigns scores based on scores 

104 distribution in each Retriever. 

105 :param weights: 

106 Assign importance to each list of documents to influence how they're joined. 

107 This parameter is ignored for 

108 `concatenate` or `distribution_based_rank_fusion` join modes. 

109 Weight for each list of documents must match the number of inputs. 

110 :param top_k: 

111 The maximum number of documents to return. Must be `None` or greater than 0. 

112 :param sort_by_score: 

113 If `True`, sorts the documents by score in descending order. 

114 If a document has no score, it is handled as if its score is -infinity. 

115 

116 :raises ValueError: 

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

118 """ 

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

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

121 if isinstance(join_mode, str): 

122 join_mode = JoinMode.from_str(join_mode) 

123 join_mode_functions = { 

124 JoinMode.CONCATENATE: DocumentJoiner._concatenate, 

125 JoinMode.MERGE: self._merge, 

126 JoinMode.RECIPROCAL_RANK_FUSION: self._rrf, 

127 JoinMode.DISTRIBUTION_BASED_RANK_FUSION: DocumentJoiner._distribution_based_rank_fusion, 

128 } 

129 self.join_mode_function = join_mode_functions[join_mode] 

130 self.join_mode = join_mode 

131 if weights: 

132 weight_sum = sum(weights) 

133 if weight_sum == 0: 

134 raise ValueError("The provided `weights` must not sum to zero.") 

135 self.weights: list[float] | None = [float(i) / weight_sum for i in weights] 

136 else: 

137 self.weights = None 

138 self.top_k = top_k 

139 self.sort_by_score = sort_by_score 

140 

141 @component.output_types(documents=list[Document]) 

142 def run(self, documents: Variadic[list[Document]], top_k: int | None = None) -> dict[str, Any]: 

143 """ 

144 Joins multiple lists of Documents into a single list depending on the `join_mode` parameter. 

145 

146 :param documents: 

147 List of list of documents to be merged. 

148 :param top_k: 

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

150 A value of 0 returns no documents. Must not be negative. 

151 

152 :returns: 

153 A dictionary with the following keys: 

154 - `documents`: Merged list of Documents 

155 

156 :raises ValueError: 

157 If `top_k` is negative. 

158 """ 

159 documents = list(documents) 

160 output_documents = self.join_mode_function(documents) 

161 

162 if self.sort_by_score: 

163 output_documents = sorted( 

164 output_documents, key=lambda doc: doc.score if doc.score is not None else -inf, reverse=True 

165 ) 

166 if any(doc.score is None for doc in output_documents): 

167 logger.info( 

168 "Some of the Documents DocumentJoiner got have score=None. It was configured to sort Documents by " 

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

170 ) 

171 

172 if top_k is not None: 

173 if top_k < 0: 

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

175 output_documents = output_documents[:top_k] 

176 elif self.top_k is not None: 

177 output_documents = output_documents[: self.top_k] 

178 

179 return {"documents": output_documents} 

180 

181 @staticmethod 

182 def _concatenate(document_lists: list[list[Document]]) -> list[Document]: 

183 """ 

184 Concatenate multiple lists of Documents and return only the Document with the highest score for duplicates. 

185 """ 

186 output = [] 

187 docs_per_id = defaultdict(list) 

188 for doc in itertools.chain.from_iterable(document_lists): 

189 docs_per_id[doc.id].append(doc) 

190 for docs in docs_per_id.values(): 

191 doc_with_best_score = max(docs, key=lambda doc: doc.score if doc.score is not None else -inf) 

192 output.append(doc_with_best_score) 

193 return output 

194 

195 def _merge(self, document_lists: list[list[Document]]) -> list[Document]: 

196 """ 

197 Merge multiple lists of Documents and calculate a weighted sum of the scores of duplicate Documents. 

198 """ 

199 # This check prevents a division by zero when no documents are passed 

200 if not document_lists: 

201 return [] 

202 

203 scores_map: dict = defaultdict(int) 

204 documents_map = {} 

205 weights = self.weights if self.weights else [1 / len(document_lists)] * len(document_lists) 

206 

207 for documents, weight in zip(document_lists, weights, strict=True): 

208 for doc in documents: 

209 scores_map[doc.id] += (doc.score if doc.score is not None else 0) * weight 

210 documents_map[doc.id] = doc 

211 

212 return [replace(doc, score=scores_map[doc.id]) for doc in documents_map.values()] 

213 

214 def _rrf(self, document_lists: list[list[Document]]) -> list[Document]: 

215 """ 

216 Merge multiple lists of Documents and assign scores based on reciprocal rank fusion. 

217 """ 

218 return _reciprocal_rank_fusion(document_lists, weights=self.weights) 

219 

220 @staticmethod 

221 def _distribution_based_rank_fusion(document_lists: list[list[Document]]) -> list[Document]: 

222 """ 

223 Merge multiple lists of Documents and assign scores based on Distribution-Based Score Fusion. 

224 

225 (https://medium.com/plain-simple-software/distribution-based-score-fusion-dbsf-a-new-approach-to-vector-search-ranking-f87c37488b18) 

226 If a Document is in more than one retriever, the one with the highest score is used. 

227 """ 

228 rescaled_lists: list[list[Document]] = [] 

229 for documents in document_lists: 

230 if len(documents) == 0: 

231 rescaled_lists.append(documents) 

232 continue 

233 

234 scores_list = [doc.score if doc.score is not None else 0 for doc in documents] 

235 

236 mean_score = sum(scores_list) / len(scores_list) 

237 std_dev = (sum((x - mean_score) ** 2 for x in scores_list) / len(scores_list)) ** 0.5 

238 min_score = mean_score - 3 * std_dev 

239 max_score = mean_score + 3 * std_dev 

240 delta_score = max_score - min_score 

241 

242 # if all docs have the same score delta_score is 0, the docs are uninformative for the query 

243 rescaled_lists.append( 

244 [ 

245 replace( 

246 doc, 

247 score=((doc.score if doc.score is not None else 0) - min_score) / delta_score 

248 if delta_score != 0.0 

249 else 0.0, 

250 ) 

251 for doc in documents 

252 ] 

253 ) 

254 

255 return DocumentJoiner._concatenate(document_lists=rescaled_lists) 

256 

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

258 """ 

259 Serializes the component to a dictionary. 

260 

261 :returns: 

262 Dictionary with serialized data. 

263 """ 

264 return default_to_dict( 

265 self, 

266 join_mode=str(self.join_mode), 

267 weights=self.weights, 

268 top_k=self.top_k, 

269 sort_by_score=self.sort_by_score, 

270 ) 

271 

272 @classmethod 

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

274 """ 

275 Deserializes the component from a dictionary. 

276 

277 :param data: 

278 The dictionary to deserialize from. 

279 :returns: 

280 The deserialized component. 

281 """ 

282 return default_from_dict(cls, data)