Coverage for haystack/components/query/query_expander.py: 93%

142 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +0000

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

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5from typing import Any 

6 

7from haystack import default_from_dict, default_to_dict, logging 

8from haystack.components.builders.prompt_builder import PromptBuilder 

9from haystack.components.generators.chat.openai_responses import OpenAIResponsesChatGenerator 

10from haystack.components.generators.chat.types import ChatGenerator 

11from haystack.components.generators.utils import _trace_chat_generator_run 

12from haystack.core.component import component 

13from haystack.core.serialization import component_to_dict 

14from haystack.dataclasses.chat_message import ChatMessage 

15from haystack.utils import deserialize_chatgenerator_inplace 

16from haystack.utils.async_utils import _execute_component_async 

17from haystack.utils.misc import _parse_dict_from_json 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22DEFAULT_PROMPT_TEMPLATE = """ 

23You are part of an information system that processes user queries for retrieval. 

24You have to expand a given query into {{ n_expansions }} queries that are 

25semantically similar to improve retrieval recall. 

26 

27Structure: 

28Follow the structure shown below in examples to generate expanded queries. 

29These examples show the output when 3 queries are requested. 

30 

31Examples: 

321. Query: "climate change effects" 

33 {"queries": ["impact of climate change", "consequences of global warming", "effects of environmental changes"]} 

34 

352. Query: "machine learning algorithms" 

36 {"queries": ["neural networks", "clustering techniques", "supervised learning methods"]} 

37 

383. Query: "open source NLP frameworks" 

39 {"queries": ["natural language processing tools", "free nlp libraries", "open-source NLP platforms"]} 

40 

41Guidelines: 

42- Generate queries that use different words and phrasings 

43- Include synonyms and related terms 

44- Maintain the same core meaning and intent 

45- Make queries that are likely to retrieve relevant information the original might miss 

46- Focus on variations that would work well with keyword-based search 

47- Respond in the same language as the input query 

48 

49Your Task: 

50Query: "{{ query }}" 

51 

52You *must* respond with a JSON object containing a "queries" array with exactly {{ n_expansions }} expanded queries.""" 

53 

54 

55@component 

56class QueryExpander: 

57 """ 

58 A component that returns a list of semantically similar queries to improve retrieval recall in RAG systems. 

59 

60 The component uses a chat generator to expand queries. The chat generator is expected to return a JSON response 

61 with the following structure: 

62 ```json 

63 {"queries": ["expanded query 1", "expanded query 2", "expanded query 3"]} 

64 ``` 

65 

66 ### Usage example 

67 

68 ```python 

69 from haystack.components.generators.chat import OpenAIResponsesChatGenerator 

70 from haystack.components.query import QueryExpander 

71 

72 chat_generator = OpenAIResponsesChatGenerator( 

73 model="gpt-5.6-luna", 

74 generation_kwargs={ 

75 "text": { 

76 "format": { 

77 "type": "json_schema", 

78 "name": "query_expansion", 

79 "strict": True, 

80 "schema": { 

81 "type": "object", 

82 "properties": {"queries": {"type": "array", "items": {"type": "string"}}}, 

83 "required": ["queries"], 

84 "additionalProperties": False, 

85 }, 

86 } 

87 } 

88 }, 

89 ) 

90 

91 expander = QueryExpander(chat_generator=chat_generator, n_expansions=3) 

92 

93 result = expander.run(query="green energy sources") 

94 print(result["queries"]) 

95 # Output: ['alternative query 1', 'alternative query 2', 'alternative query 3', 'green energy sources'] 

96 # Note: Up to 3 additional queries + 1 original query (if include_original_query=True) 

97 

98 # To control total number of queries: 

99 expander = QueryExpander(n_expansions=2, include_original_query=True) # Up to 3 total 

100 # or 

101 expander = QueryExpander(n_expansions=3, include_original_query=False) # Exactly 3 total 

102 ``` 

103 """ 

104 

105 def __init__( 

106 self, 

107 *, 

108 chat_generator: ChatGenerator | None = None, 

109 prompt_template: str | None = None, 

110 n_expansions: int = 4, 

111 include_original_query: bool = True, 

112 ) -> None: 

113 """ 

114 Initialize the QueryExpander component. 

115 

116 :param chat_generator: The chat generator component to use for query expansion. 

117 If None, a default `OpenAIResponsesChatGenerator` with the gpt-4.1-mini model and `store` set to `False` 

118 is used. 

119 :param prompt_template: Custom [PromptBuilder](https://docs.haystack.deepset.ai/docs/promptbuilder) 

120 template for query expansion. The template should instruct the LLM to return a JSON response with the 

121 structure: `{"queries": ["query1", "query2", "query3"]}`. The template should include 'query' and 

122 'n_expansions' variables. 

123 :param n_expansions: Number of alternative queries to generate (default: 4). 

124 :param include_original_query: Whether to include the original query in the output. 

125 """ 

126 if n_expansions <= 0: 

127 raise ValueError("n_expansions must be positive") 

128 

129 self.n_expansions = n_expansions 

130 self.include_original_query = include_original_query 

131 

132 if chat_generator is None: 

133 self.chat_generator: ChatGenerator = OpenAIResponsesChatGenerator( 

134 model="gpt-4.1-mini", 

135 generation_kwargs={ 

136 "temperature": 0.7, 

137 "store": False, 

138 "text": { 

139 "format": { 

140 "type": "json_schema", 

141 "name": "query_expansion", 

142 "schema": { 

143 "type": "object", 

144 "properties": {"queries": {"type": "array", "items": {"type": "string"}}}, 

145 "required": ["queries"], 

146 "additionalProperties": False, 

147 }, 

148 } 

149 }, 

150 }, 

151 ) 

152 else: 

153 self.chat_generator = chat_generator 

154 

155 self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE 

156 

157 # Check if required variables are present in the template 

158 if "query" not in self.prompt_template: 

159 logger.warning( 

160 "The prompt template does not contain the 'query' variable. This may cause issues during execution." 

161 ) 

162 if "n_expansions" not in self.prompt_template: 

163 logger.warning( 

164 "The prompt template does not contain the 'n_expansions' variable. " 

165 "This may cause issues during execution." 

166 ) 

167 

168 self._prompt_builder = PromptBuilder( 

169 template=self.prompt_template, required_variables=["n_expansions", "query"] 

170 ) 

171 

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

173 """ 

174 Serializes the component to a dictionary. 

175 

176 :return: Dictionary with serialized data. 

177 """ 

178 return default_to_dict( 

179 self, 

180 chat_generator=component_to_dict(self.chat_generator, name="chat_generator"), 

181 prompt_template=self.prompt_template, 

182 n_expansions=self.n_expansions, 

183 include_original_query=self.include_original_query, 

184 ) 

185 

186 @classmethod 

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

188 """ 

189 Deserializes the component from a dictionary. 

190 

191 :param data: Dictionary with serialized data. 

192 :return: Deserialized component. 

193 """ 

194 init_params = data.get("init_parameters", {}) 

195 

196 deserialize_chatgenerator_inplace(init_params, key="chat_generator") 

197 

198 return default_from_dict(cls, data) 

199 

200 @component.output_types(queries=list[str]) 

201 def run(self, query: str, n_expansions: int | None = None) -> dict[str, list[str]]: 

202 """ 

203 Expand the input query into multiple semantically similar queries. 

204 

205 The language of the original query is preserved in the expanded queries. 

206 

207 :param query: The original query to expand. 

208 :param n_expansions: Number of additional queries to generate (not including the original). 

209 If None, uses the value from initialization. Must be a positive integer. 

210 :return: Dictionary with "queries" key containing the list of expanded queries. 

211 If include_original_query=True, the original query will be included in addition 

212 to the n_expansions alternative queries. 

213 :raises ValueError: If n_expansions is not positive (less than or equal to 0). 

214 """ 

215 

216 self.warm_up() 

217 

218 if not isinstance(query, str) or not query.strip(): 

219 logger.warning("Empty query provided to QueryExpander. {fallback}", fallback=self._fallback_message()) 

220 return {"queries": [query] if self.include_original_query and isinstance(query, str) else []} 

221 

222 response = {"queries": [query] if self.include_original_query else []} 

223 

224 expansion_count = n_expansions if n_expansions is not None else self.n_expansions 

225 if expansion_count <= 0: 

226 raise ValueError("n_expansions must be positive") 

227 

228 try: 

229 prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count) 

230 messages = [ChatMessage.from_user(prompt_result["prompt"])] 

231 with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span: 

232 generator_result = self.chat_generator.run(messages=messages) 

233 span.set_content_tag("haystack.component.output", generator_result) 

234 

235 if not generator_result.get("replies") or len(generator_result["replies"]) == 0: 

236 logger.warning( 

237 "ChatGenerator returned no replies for query: {query}. {fallback}", 

238 query=query, 

239 fallback=self._fallback_message(), 

240 ) 

241 return response 

242 

243 expanded_text = generator_result["replies"][0].text.strip() 

244 expanded_queries = self._parse_expanded_queries(expanded_text) 

245 

246 # Limit the number of expanded queries to the requested amount 

247 if len(expanded_queries) > expansion_count: 

248 logger.warning( 

249 "Generated {generated_count} queries but only {requested_count} were requested. " 

250 "Truncating to the first {requested_count} queries. ", 

251 generated_count=len(expanded_queries), 

252 requested_count=expansion_count, 

253 ) 

254 expanded_queries = expanded_queries[:expansion_count] 

255 

256 # Add original query if not already present 

257 if self.include_original_query: 

258 expanded_queries_lower = [q.lower() for q in expanded_queries] 

259 if query.lower() not in expanded_queries_lower: 

260 expanded_queries.append(query) 

261 

262 response["queries"] = expanded_queries 

263 return response 

264 

265 except Exception as e: 

266 # Fallback: return original query to maintain pipeline functionality 

267 logger.exception( 

268 "Failed to expand query {query}. {fallback} Error: {error}", 

269 query=query, 

270 fallback=self._fallback_message(), 

271 error=str(e), 

272 ) 

273 return response 

274 

275 @component.output_types(queries=list[str]) 

276 async def run_async(self, query: str, n_expansions: int | None = None) -> dict[str, list[str]]: 

277 """ 

278 Asynchronously expand the input query into multiple semantically similar queries. 

279 

280 The language of the original query is preserved in the expanded queries. 

281 

282 This is the asynchronous version of the `run` method. It has the same parameters and return values 

283 but can be used with `await` in an async code. If the chat generator only implements a synchronous 

284 `run` method, it is executed in a thread to avoid blocking the event loop. 

285 

286 :param query: The original query to expand. 

287 :param n_expansions: Number of additional queries to generate (not including the original). 

288 If None, uses the value from initialization. Must be a positive integer. 

289 :return: Dictionary with "queries" key containing the list of expanded queries. 

290 If include_original_query=True, the original query will be included in addition 

291 to the n_expansions alternative queries. 

292 :raises ValueError: If n_expansions is not positive (less than or equal to 0). 

293 """ 

294 

295 await self.warm_up_async() 

296 

297 if not isinstance(query, str) or not query.strip(): 

298 logger.warning("Empty query provided to QueryExpander. {fallback}", fallback=self._fallback_message()) 

299 return {"queries": [query] if self.include_original_query and isinstance(query, str) else []} 

300 

301 response = {"queries": [query] if self.include_original_query else []} 

302 

303 expansion_count = n_expansions if n_expansions is not None else self.n_expansions 

304 if expansion_count <= 0: 

305 raise ValueError("n_expansions must be positive") 

306 

307 try: 

308 prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count) 

309 messages = [ChatMessage.from_user(prompt_result["prompt"])] 

310 with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span: 

311 generator_result = await _execute_component_async(self.chat_generator, messages=messages) 

312 span.set_content_tag("haystack.component.output", generator_result) 

313 

314 if not generator_result.get("replies") or len(generator_result["replies"]) == 0: 

315 logger.warning( 

316 "ChatGenerator returned no replies for query: {query}. {fallback}", 

317 query=query, 

318 fallback=self._fallback_message(), 

319 ) 

320 return response 

321 

322 expanded_text = generator_result["replies"][0].text.strip() 

323 expanded_queries = self._parse_expanded_queries(expanded_text) 

324 

325 # Limit the number of expanded queries to the requested amount 

326 if len(expanded_queries) > expansion_count: 

327 logger.warning( 

328 "Generated {generated_count} queries but only {requested_count} were requested. " 

329 "Truncating to the first {requested_count} queries. ", 

330 generated_count=len(expanded_queries), 

331 requested_count=expansion_count, 

332 ) 

333 expanded_queries = expanded_queries[:expansion_count] 

334 

335 # Add original query if not already present 

336 if self.include_original_query: 

337 expanded_queries_lower = [q.lower() for q in expanded_queries] 

338 if query.lower() not in expanded_queries_lower: 

339 expanded_queries.append(query) 

340 

341 response["queries"] = expanded_queries 

342 return response 

343 

344 except Exception as e: 

345 # Fallback: return original query to maintain pipeline functionality 

346 logger.exception( 

347 "Failed to expand query {query}. {fallback} Error: {error}", 

348 query=query, 

349 fallback=self._fallback_message(), 

350 error=str(e), 

351 ) 

352 return response 

353 

354 def warm_up(self) -> None: 

355 """ 

356 Warm up the underlying chat generator. 

357 """ 

358 if hasattr(self.chat_generator, "warm_up"): 

359 self.chat_generator.warm_up() 

360 

361 async def warm_up_async(self) -> None: 

362 """ 

363 Warm up the underlying chat generator on the serving event loop. 

364 """ 

365 if hasattr(self.chat_generator, "warm_up_async"): 

366 await self.chat_generator.warm_up_async() 

367 elif hasattr(self.chat_generator, "warm_up"): 

368 self.chat_generator.warm_up() 

369 

370 def close(self) -> None: 

371 """ 

372 Release the underlying chat generator's resources. 

373 """ 

374 if hasattr(self.chat_generator, "close"): 

375 self.chat_generator.close() 

376 

377 async def close_async(self) -> None: 

378 """ 

379 Release the underlying chat generator's async resources. 

380 """ 

381 if hasattr(self.chat_generator, "close_async"): 

382 await self.chat_generator.close_async() 

383 elif hasattr(self.chat_generator, "close"): 

384 self.chat_generator.close() 

385 

386 def _fallback_message(self) -> str: 

387 return "Returning only the original query." if self.include_original_query else "Returning no queries." 

388 

389 def _parse_expanded_queries(self, generator_response: str) -> list[str]: 

390 """ 

391 Parse the generator response to extract individual expanded queries. 

392 

393 :param generator_response: The raw text response from the generator. 

394 :return: List of parsed expanded queries, deduplicated in first-seen order. 

395 """ 

396 try: 

397 parsed = _parse_dict_from_json(generator_response, expected_keys=["queries"], raise_on_failure=True) 

398 except ValueError as e: 

399 logger.warning( 

400 "QueryExpander failed to parse the ChatGenerator response: {response}. {fallback} Error: {error}", 

401 response=generator_response, 

402 fallback=self._fallback_message(), 

403 error=e, 

404 ) 

405 return [] 

406 

407 if not isinstance(parsed["queries"], list): 

408 logger.warning( 

409 "Expected 'queries' to be a list but got {type}. {fallback}", 

410 type=type(parsed["queries"]).__name__, 

411 fallback=self._fallback_message(), 

412 ) 

413 return [] 

414 

415 queries = [] 

416 seen: set[str] = set() 

417 for item in parsed["queries"]: 

418 if isinstance(item, str) and item.strip(): 

419 stripped = item.strip() 

420 if stripped not in seen: 

421 seen.add(stripped) 

422 queries.append(stripped) 

423 else: 

424 logger.warning("Skipping non-string or empty query in response: {item}", item=item) 

425 

426 if not queries: 

427 logger.warning( 

428 "QueryExpander found no valid queries in the ChatGenerator response. {fallback}", 

429 fallback=self._fallback_message(), 

430 ) 

431 

432 return queries