Coverage for haystack/components/retrievers/multi_retriever.py: 99%

131 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 asyncio 

6from concurrent.futures import ThreadPoolExecutor, as_completed 

7from math import inf 

8from typing import Any, Literal 

9 

10from haystack import component, default_from_dict, default_to_dict 

11from haystack.components.retrievers.types.protocol import TextRetriever 

12from haystack.core.serialization import component_from_dict, component_to_dict, import_class_by_name 

13from haystack.dataclasses import Document 

14from haystack.utils.async_utils import _execute_component_async, _gather_tasks_with_cancel 

15from haystack.utils.experimental import _experimental 

16from haystack.utils.misc import _deduplicate_documents, _reciprocal_rank_fusion 

17 

18 

19@_experimental 

20@component 

21class MultiRetriever: 

22 """ 

23 A component that accepts text retrievers and runs them in parallel, combining their results. 

24 

25 > **Note:** This component is experimental and may change or be removed in future releases without prior 

26 deprecation notice. 

27 

28 All retrievers must implement the `TextRetriever` protocol. Use `TextEmbeddingRetriever` to wrap an 

29 embedding-based retriever before passing it to this component. 

30 

31 Each retriever is queried concurrently using a thread pool. 

32 The results are deduplicated and returned as a single list of documents. 

33 

34 ### Usage example 

35 

36 ```python 

37 from haystack import Document 

38 from haystack.document_stores.in_memory import InMemoryDocumentStore 

39 from haystack.document_stores.types import DuplicatePolicy 

40 from haystack.components.retrievers import InMemoryBM25Retriever, InMemoryEmbeddingRetriever 

41 from haystack.components.retrievers import TextEmbeddingRetriever, MultiRetriever 

42 from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder 

43 from haystack.components.writers import DocumentWriter 

44 

45 documents = [ 

46 Document(content="Renewable energy is energy that is collected from renewable resources."), 

47 Document(content="Solar energy is a type of green energy that is harnessed from the sun."), 

48 Document(content="Wind energy is another type of green energy that is generated by wind turbines."), 

49 ] 

50 

51 # Populate the document store 

52 doc_store = InMemoryDocumentStore() 

53 doc_embedder = OpenAIDocumentEmbedder() 

54 doc_writer = DocumentWriter(document_store=doc_store, policy=DuplicatePolicy.SKIP) 

55 doc_writer.run(documents=doc_embedder.run(documents)["documents"]) 

56 

57 # Run the multi-retriever with all retrievers 

58 retriever = MultiRetriever( 

59 retrievers={ 

60 "bm25": InMemoryBM25Retriever(document_store=doc_store), 

61 "embedding": TextEmbeddingRetriever( 

62 retriever=InMemoryEmbeddingRetriever(document_store=doc_store), 

63 text_embedder=OpenAITextEmbedder(), 

64 ), 

65 }, 

66 top_k=3, 

67 ) 

68 

69 # Run all retrievers 

70 result = retriever.run(query="green energy sources") 

71 

72 # Run only the BM25 retriever 

73 result = retriever.run(query="green energy sources", active_retrievers=["bm25"]) 

74 

75 for doc in result["documents"]: 

76 print(doc.content) 

77 ``` 

78 """ 

79 

80 def __init__( 

81 self, 

82 *, 

83 retrievers: dict[str, TextRetriever], 

84 filters: dict[str, Any] | None = None, 

85 top_k_per_retriever: int | None = None, 

86 top_k: int | None = None, 

87 max_workers: int = 4, 

88 join_mode: Literal["concatenate", "reciprocal_rank_fusion"] = "reciprocal_rank_fusion", 

89 ) -> None: 

90 """ 

91 Create the MultiRetriever component. 

92 

93 :param retrievers: 

94 A dictionary mapping names to text retrievers (implementing the `TextRetriever` protocol) to run in 

95 parallel. 

96 :param filters: 

97 A dictionary of filters to apply when retrieving documents. 

98 :param top_k_per_retriever: 

99 The maximum number of documents to return per retriever. If set, this will override the `top_k` 

100 parameter for each retriever. If None, the `top_k` parameter of retrievers will be used. 

101 :param top_k: 

102 The maximum number of documents to return overall, extracted from the combined results of all 

103 retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of 

104 `join_mode`) so that the combined list has a consistent global ranking before it is truncated to 

105 `top_k`. If None, all results are returned. 

106 :param max_workers: 

107 The maximum number of threads in `run` and of concurrent retriever calls in `run_async`. 

108 :param join_mode: 

109 How to merge results from multiple retrievers. Available modes: 

110 - `concatenate`: Combines all results into a single list and deduplicates. 

111 - `reciprocal_rank_fusion`: Deduplicates and assigns scores based on reciprocal rank fusion. 

112 

113 :raises ValueError: 

114 If `top_k` or `top_k_per_retriever` is set and is not greater than 0. 

115 """ 

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

117 raise ValueError(f"top_k must be greater than 0, but got {top_k}") 

118 if top_k_per_retriever is not None and top_k_per_retriever <= 0: 

119 raise ValueError(f"top_k_per_retriever must be greater than 0, but got {top_k_per_retriever}") 

120 self.retrievers = retrievers 

121 self.filters = filters 

122 self.top_k_per_retriever = top_k_per_retriever 

123 self.top_k = top_k 

124 self.max_workers = max_workers 

125 self.join_mode = join_mode 

126 

127 def _resolve_top_k(self, top_k_per_retriever: int | None, top_k: int | None) -> tuple[int | None, int | None]: 

128 """ 

129 Resolve runtime values against the init defaults and reject negatives. 

130 

131 A resolved value of 0 is valid and means no documents are returned. 

132 """ 

133 resolved_top_k_per_retriever = ( 

134 top_k_per_retriever if top_k_per_retriever is not None else self.top_k_per_retriever 

135 ) 

136 resolved_top_k = top_k if top_k is not None else self.top_k 

137 if resolved_top_k is not None and resolved_top_k < 0: 

138 raise ValueError(f"top_k must be greater than or equal to 0, but got {resolved_top_k}") 

139 if resolved_top_k_per_retriever is not None and resolved_top_k_per_retriever < 0: 

140 raise ValueError( 

141 f"top_k_per_retriever must be greater than or equal to 0, but got {resolved_top_k_per_retriever}" 

142 ) 

143 return resolved_top_k_per_retriever, resolved_top_k 

144 

145 def _merge_results(self, document_lists: list[list[Document]], top_k: int | None = None) -> list[Document]: 

146 """ 

147 Merge per-retriever result lists according to `join_mode`. 

148 

149 In `concatenate` mode, all lists are flattened and deduplicated. In `reciprocal_rank_fusion` mode, results 

150 are deduplicated and re-scored using RRF, then returned in descending score order. When `top_k` is set, RRF 

151 is always used so the combined results have a consistent global ranking, and only the top `top_k` documents 

152 are returned. 

153 """ 

154 # When top_k is set we always use reciprocal rank fusion to merge the results, regardless of join_mode, 

155 # so that truncation is applied to a consistently ranked list. 

156 if top_k is not None or self.join_mode == "reciprocal_rank_fusion": 

157 documents = _reciprocal_rank_fusion(document_lists) 

158 merged = sorted(documents, key=lambda d: d.score if d.score is not None else -inf, reverse=True) 

159 return merged[:top_k] if top_k is not None else merged 

160 return _deduplicate_documents([doc for docs in document_lists for doc in docs]) 

161 

162 def _resolve_retrievers(self, active_retrievers: list[str] | None) -> dict[str, TextRetriever]: 

163 """ 

164 Returns the subset of retrievers to run based on the active_retrievers list. 

165 

166 :param active_retrievers: 

167 A list of retriever names to run. If None, all retrievers are returned. 

168 

169 :returns: 

170 A dictionary of retriever names to retriever instances. 

171 

172 :raises ValueError: 

173 If any name in `active_retrievers` does not match a retriever name. 

174 """ 

175 if active_retrievers is None: 

176 return self.retrievers 

177 unknown = set(active_retrievers) - self.retrievers.keys() 

178 if unknown: 

179 raise ValueError( 

180 f"Unknown retriever name(s): {sorted(unknown)}. Available retrievers: {sorted(self.retrievers.keys())}" 

181 ) 

182 return {name: self.retrievers[name] for name in active_retrievers} 

183 

184 def warm_up(self) -> None: 

185 """ 

186 Warm up the retrievers. 

187 """ 

188 for retriever in self.retrievers.values(): 

189 if hasattr(retriever, "warm_up"): 

190 retriever.warm_up() 

191 

192 async def warm_up_async(self) -> None: 

193 """ 

194 Warm up the retrievers on the serving event loop. 

195 """ 

196 for retriever in self.retrievers.values(): 

197 if hasattr(retriever, "warm_up_async"): 

198 await retriever.warm_up_async() 

199 elif hasattr(retriever, "warm_up"): 

200 retriever.warm_up() 

201 

202 def close(self) -> None: 

203 """ 

204 Release the retrievers' resources. 

205 """ 

206 for retriever in self.retrievers.values(): 

207 if hasattr(retriever, "close"): 

208 retriever.close() 

209 

210 async def close_async(self) -> None: 

211 """ 

212 Release the retrievers' async resources. 

213 """ 

214 for retriever in self.retrievers.values(): 

215 if hasattr(retriever, "close_async"): 

216 await retriever.close_async() 

217 elif hasattr(retriever, "close"): 

218 retriever.close() 

219 

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

221 def run( 

222 self, 

223 query: str, 

224 filters: dict[str, Any] | None = None, 

225 top_k_per_retriever: int | None = None, 

226 top_k: int | None = None, 

227 *, 

228 active_retrievers: list[str] | None = None, 

229 ) -> dict[str, list[Document]]: 

230 """ 

231 Runs retrievers in parallel on the given query and returns deduplicated results. 

232 

233 :param query: 

234 The query to run the retrievers on. 

235 :param filters: 

236 Filters to apply. Defaults to the value set at initialization. 

237 :param top_k_per_retriever: 

238 The maximum number of documents to return per retriever. When set, this will override the `top_k` 

239 parameter for each retriever. If None, the `top_k` parameter set for retrievers will be used. 

240 If 0, no documents are returned. Defaults to the value set at initialization. 

241 :param top_k: 

242 The maximum number of documents to return overall, extracted from the combined results of all 

243 retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of 

244 `join_mode`) so that the combined list has a consistent global ranking before it is truncated to 

245 `top_k`. If None, all results are returned. If 0, no documents are returned. 

246 Defaults to the value set at initialization. 

247 :param active_retrievers: 

248 Names of retrievers to run. Defaults to all. Must match keys in the `retrievers` dictionary. 

249 

250 :returns: 

251 A dictionary with the keys: 

252 - "documents": A deduplicated list of retrieved documents. 

253 

254 :raises ValueError: 

255 If any name in `active_retrievers` does not match a retriever name, 

256 or if the resolved `top_k` or `top_k_per_retriever` is negative. 

257 """ 

258 self.warm_up() 

259 

260 resolved_top_k_per_retriever, resolved_top_k = self._resolve_top_k(top_k_per_retriever, top_k) 

261 resolved_filters = filters if filters is not None else self.filters 

262 

263 retrievers_to_run = self._resolve_retrievers(active_retrievers) 

264 if resolved_top_k == 0 or resolved_top_k_per_retriever == 0: 

265 return {"documents": []} 

266 

267 results_by_name: dict[str, list[Document]] = {} 

268 with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 

269 future_to_name = {} 

270 for name, retriever in retrievers_to_run.items(): 

271 run_kwargs: dict[str, Any] = {"query": query} 

272 if resolved_top_k_per_retriever is not None: 

273 run_kwargs["top_k"] = resolved_top_k_per_retriever 

274 if resolved_filters is not None: 

275 run_kwargs["filters"] = resolved_filters 

276 future_to_name[executor.submit(retriever.run, **run_kwargs)] = name 

277 

278 for future in as_completed(future_to_name): 

279 name = future_to_name[future] 

280 try: 

281 results_by_name[name] = future.result().get("documents", []) 

282 except Exception as e: 

283 raise RuntimeError(f"Retriever '{name}' failed: {e}") from e 

284 

285 document_lists = [results_by_name[name] for name in retrievers_to_run] 

286 return {"documents": self._merge_results(document_lists, top_k=resolved_top_k)} 

287 

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

289 async def run_async( 

290 self, 

291 query: str, 

292 filters: dict[str, Any] | None = None, 

293 top_k_per_retriever: int | None = None, 

294 top_k: int | None = None, 

295 *, 

296 active_retrievers: list[str] | None = None, 

297 ) -> dict[str, list[Document]]: 

298 """ 

299 Runs retrievers concurrently on the given query and returns deduplicated results. 

300 

301 Uses each retriever's `run_async` method if available, otherwise runs `run` in a thread executor. 

302 

303 :param query: 

304 The query to run the retrievers on. 

305 :param filters: 

306 Filters to apply. Defaults to the value set at initialization. 

307 :param top_k_per_retriever: 

308 The maximum number of documents to return per retriever. When set, this will override the `top_k` 

309 parameter for each retriever. If None, the `top_k` parameter set for retrievers will be used. 

310 If 0, no documents are returned. Defaults to the value set at initialization. 

311 :param top_k: 

312 The maximum number of documents to return overall, extracted from the combined results of all 

313 retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of 

314 `join_mode`) so that the combined list has a consistent global ranking before it is truncated to 

315 `top_k`. If None, all results are returned. If 0, no documents are returned. 

316 Defaults to the value set at initialization. 

317 :param active_retrievers: 

318 Names of retrievers to run. Defaults to all. Must match keys in the `retrievers` dictionary. 

319 

320 :returns: 

321 A dictionary with the keys: 

322 - "documents": A deduplicated list of retrieved documents. 

323 

324 :raises ValueError: 

325 If any name in `active_retrievers` does not match a retriever name, 

326 or if the resolved `top_k` or `top_k_per_retriever` is negative. 

327 """ 

328 await self.warm_up_async() 

329 

330 resolved_top_k_per_retriever, resolved_top_k = self._resolve_top_k(top_k_per_retriever, top_k) 

331 resolved_filters = filters if filters is not None else self.filters 

332 

333 retrievers_to_run = self._resolve_retrievers(active_retrievers) 

334 if resolved_top_k == 0 or resolved_top_k_per_retriever == 0: 

335 return {"documents": []} 

336 

337 run_kwargs: dict[str, Any] = {"query": query} 

338 if resolved_top_k_per_retriever is not None: 

339 run_kwargs["top_k"] = resolved_top_k_per_retriever 

340 if resolved_filters is not None: 

341 run_kwargs["filters"] = resolved_filters 

342 

343 # Bound concurrency to max_workers, mirroring the ThreadPoolExecutor in the sync `run`. 

344 semaphore = asyncio.Semaphore(max(1, self.max_workers)) 

345 

346 async def _run_one(name: str, retriever: TextRetriever) -> list[Document]: 

347 async with semaphore: 

348 try: 

349 result = await _execute_component_async(retriever, **run_kwargs) 

350 return result.get("documents", []) 

351 except Exception as e: 

352 raise RuntimeError(f"Retriever '{name}' failed: {e}") from e 

353 

354 tasks = [asyncio.create_task(_run_one(name, retriever)) for name, retriever in retrievers_to_run.items()] 

355 document_lists = await _gather_tasks_with_cancel(tasks) 

356 return {"documents": self._merge_results(document_lists, top_k=resolved_top_k)} 

357 

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

359 """ 

360 Serializes the component to a dictionary. 

361 

362 :returns: 

363 Dictionary with serialized data. 

364 """ 

365 return default_to_dict( 

366 self, 

367 retrievers={name: component_to_dict(obj=r, name=name) for name, r in self.retrievers.items()}, 

368 filters=self.filters, 

369 top_k_per_retriever=self.top_k_per_retriever, 

370 top_k=self.top_k, 

371 max_workers=self.max_workers, 

372 join_mode=self.join_mode, 

373 ) 

374 

375 @classmethod 

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

377 """ 

378 Creates an instance of the component from a dictionary. 

379 

380 :param data: 

381 Dictionary with the data to create the component. 

382 """ 

383 retrievers_data = data.get("init_parameters", {}).get("retrievers", {}) 

384 if retrievers_data: 

385 retrievers = {} 

386 for name, retriever_data in retrievers_data.items(): 

387 try: 

388 imported_class = import_class_by_name(retriever_data["type"]) 

389 except ImportError as e: 

390 raise ImportError( 

391 f"Could not import class {retriever_data['type']} for retriever '{name}'. Error: {str(e)}" 

392 ) from e 

393 retrievers[name] = component_from_dict(cls=imported_class, data=retriever_data, name=name) 

394 data["init_parameters"]["retrievers"] = retrievers 

395 return default_from_dict(cls, data)