-
Notifications
You must be signed in to change notification settings - Fork 3k
Expand file tree
/
Copy pathquery_expander.py
More file actions
382 lines (310 loc) · 16.1 KB
/
Copy pathquery_expander.py
File metadata and controls
382 lines (310 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack import default_from_dict, default_to_dict, logging
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.components.generators.utils import _trace_chat_generator_run
from haystack.core.component import component
from haystack.core.serialization import component_to_dict
from haystack.dataclasses.chat_message import ChatMessage
from haystack.utils import deserialize_chatgenerator_inplace
from haystack.utils.async_utils import _execute_component_async
from haystack.utils.misc import _parse_dict_from_json
logger = logging.getLogger(__name__)
DEFAULT_PROMPT_TEMPLATE = """
You are part of an information system that processes user queries for retrieval.
You have to expand a given query into {{ n_expansions }} queries that are
semantically similar to improve retrieval recall.
Structure:
Follow the structure shown below in examples to generate expanded queries.
Examples:
1. Query: "climate change effects"
{"queries": ["impact of climate change", "consequences of global warming", "effects of environmental changes"]}
2. Query: "machine learning algorithms"
{"queries": ["neural networks", "clustering techniques", "supervised learning methods", "deep learning models"]}
3. Query: "open source NLP frameworks"
{"queries": ["natural language processing tools", "free nlp libraries", "open-source NLP platforms"]}
Guidelines:
- Generate queries that use different words and phrasings
- Include synonyms and related terms
- Maintain the same core meaning and intent
- Make queries that are likely to retrieve relevant information the original might miss
- Focus on variations that would work well with keyword-based search
- Respond in the same language as the input query
Your Task:
Query: "{{ query }}"
You *must* respond with a JSON object containing a "queries" array with the expanded queries.
Example: {"queries": ["query1", "query2", "query3"]}"""
@component
class QueryExpander:
"""
A component that returns a list of semantically similar queries to improve retrieval recall in RAG systems.
The component uses a chat generator to expand queries. The chat generator is expected to return a JSON response
with the following structure:
```json
{"queries": ["expanded query 1", "expanded query 2", "expanded query 3"]}
```
### Usage example
```python
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.query import QueryExpander
expander = QueryExpander(
chat_generator=OpenAIChatGenerator(model="gpt-4.1-mini"),
n_expansions=3
)
result = expander.run(query="green energy sources")
print(result["queries"])
# Output: ['alternative query 1', 'alternative query 2', 'alternative query 3', 'green energy sources']
# Note: Up to 3 additional queries + 1 original query (if include_original_query=True)
# To control total number of queries:
expander = QueryExpander(n_expansions=2, include_original_query=True) # Up to 3 total
# or
expander = QueryExpander(n_expansions=3, include_original_query=False) # Exactly 3 total
```
"""
def __init__(
self,
*,
chat_generator: ChatGenerator | None = None,
prompt_template: str | None = None,
n_expansions: int = 4,
include_original_query: bool = True,
) -> None:
"""
Initialize the QueryExpander component.
:param chat_generator: The chat generator component to use for query expansion.
If None, a default OpenAIChatGenerator with gpt-4.1-mini model is used.
:param prompt_template: Custom [PromptBuilder](https://docs.haystack.deepset.ai/docs/promptbuilder)
template for query expansion. The template should instruct the LLM to return a JSON response with the
structure: `{"queries": ["query1", "query2", "query3"]}`. The template should include 'query' and
'n_expansions' variables.
:param n_expansions: Number of alternative queries to generate (default: 4).
:param include_original_query: Whether to include the original query in the output.
"""
if n_expansions <= 0:
raise ValueError("n_expansions must be positive")
self.n_expansions = n_expansions
self.include_original_query = include_original_query
if chat_generator is None:
self.chat_generator: ChatGenerator = OpenAIChatGenerator(
model="gpt-4.1-mini",
generation_kwargs={
"temperature": 0.7,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "query_expansion",
"schema": {
"type": "object",
"properties": {"queries": {"type": "array", "items": {"type": "string"}}},
"required": ["queries"],
"additionalProperties": False,
},
},
},
"seed": 42,
},
)
else:
self.chat_generator = chat_generator
self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE
# Check if required variables are present in the template
if "query" not in self.prompt_template:
logger.warning(
"The prompt template does not contain the 'query' variable. This may cause issues during execution."
)
if "n_expansions" not in self.prompt_template:
logger.warning(
"The prompt template does not contain the 'n_expansions' variable. "
"This may cause issues during execution."
)
self._prompt_builder = PromptBuilder(
template=self.prompt_template, required_variables=["n_expansions", "query"]
)
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:return: Dictionary with serialized data.
"""
return default_to_dict(
self,
chat_generator=component_to_dict(self.chat_generator, name="chat_generator"),
prompt_template=self.prompt_template,
n_expansions=self.n_expansions,
include_original_query=self.include_original_query,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "QueryExpander":
"""
Deserializes the component from a dictionary.
:param data: Dictionary with serialized data.
:return: Deserialized component.
"""
init_params = data.get("init_parameters", {})
deserialize_chatgenerator_inplace(init_params, key="chat_generator")
return default_from_dict(cls, data)
@component.output_types(queries=list[str])
def run(self, query: str, n_expansions: int | None = None) -> dict[str, list[str]]:
"""
Expand the input query into multiple semantically similar queries.
The language of the original query is preserved in the expanded queries.
:param query: The original query to expand.
:param n_expansions: Number of additional queries to generate (not including the original).
If None, uses the value from initialization. Must be a positive integer.
:return: Dictionary with "queries" key containing the list of expanded queries.
If include_original_query=True, the original query will be included in addition
to the n_expansions alternative queries.
:raises ValueError: If n_expansions is not positive (less than or equal to 0).
"""
self.warm_up()
response = {"queries": [query] if self.include_original_query else []}
if not query.strip():
logger.warning("Empty query provided to QueryExpander")
return response
expansion_count = n_expansions if n_expansions is not None else self.n_expansions
if expansion_count <= 0:
raise ValueError("n_expansions must be positive")
try:
prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count)
messages = [ChatMessage.from_user(prompt_result["prompt"])]
with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span:
generator_result = self.chat_generator.run(messages=messages)
span.set_content_tag("haystack.component.output", generator_result)
if not generator_result.get("replies") or len(generator_result["replies"]) == 0:
logger.warning("ChatGenerator returned no replies for query: {query}", query=query)
return response
expanded_text = generator_result["replies"][0].text.strip()
expanded_queries = self._parse_expanded_queries(expanded_text)
# Limit the number of expanded queries to the requested amount
if len(expanded_queries) > expansion_count:
logger.warning(
"Generated {generated_count} queries but only {requested_count} were requested. "
"Truncating to the first {requested_count} queries. ",
generated_count=len(expanded_queries),
requested_count=expansion_count,
)
expanded_queries = expanded_queries[:expansion_count]
# Add original query if not already present
if self.include_original_query:
expanded_queries_lower = [q.lower() for q in expanded_queries]
if query.lower() not in expanded_queries_lower:
expanded_queries.append(query)
response["queries"] = expanded_queries
return response
except Exception as e:
# Fallback: return original query to maintain pipeline functionality
logger.exception("Failed to expand query {query}: {error}", query=query, error=str(e))
return response
@component.output_types(queries=list[str])
async def run_async(self, query: str, n_expansions: int | None = None) -> dict[str, list[str]]:
"""
Asynchronously expand the input query into multiple semantically similar queries.
The language of the original query is preserved in the expanded queries.
This is the asynchronous version of the `run` method. It has the same parameters and return values
but can be used with `await` in an async code. If the chat generator only implements a synchronous
`run` method, it is executed in a thread to avoid blocking the event loop.
:param query: The original query to expand.
:param n_expansions: Number of additional queries to generate (not including the original).
If None, uses the value from initialization. Must be a positive integer.
:return: Dictionary with "queries" key containing the list of expanded queries.
If include_original_query=True, the original query will be included in addition
to the n_expansions alternative queries.
:raises ValueError: If n_expansions is not positive (less than or equal to 0).
"""
await self.warm_up_async()
response = {"queries": [query] if self.include_original_query else []}
if not query.strip():
logger.warning("Empty query provided to QueryExpander")
return response
expansion_count = n_expansions if n_expansions is not None else self.n_expansions
if expansion_count <= 0:
raise ValueError("n_expansions must be positive")
try:
prompt_result = self._prompt_builder.run(query=query.strip(), n_expansions=expansion_count)
messages = [ChatMessage.from_user(prompt_result["prompt"])]
with _trace_chat_generator_run(self.chat_generator, {"messages": messages}) as span:
generator_result = await _execute_component_async(self.chat_generator, messages=messages)
span.set_content_tag("haystack.component.output", generator_result)
if not generator_result.get("replies") or len(generator_result["replies"]) == 0:
logger.warning("ChatGenerator returned no replies for query: {query}", query=query)
return response
expanded_text = generator_result["replies"][0].text.strip()
expanded_queries = self._parse_expanded_queries(expanded_text)
# Limit the number of expanded queries to the requested amount
if len(expanded_queries) > expansion_count:
logger.warning(
"Generated {generated_count} queries but only {requested_count} were requested. "
"Truncating to the first {requested_count} queries. ",
generated_count=len(expanded_queries),
requested_count=expansion_count,
)
expanded_queries = expanded_queries[:expansion_count]
# Add original query if not already present
if self.include_original_query:
expanded_queries_lower = [q.lower() for q in expanded_queries]
if query.lower() not in expanded_queries_lower:
expanded_queries.append(query)
response["queries"] = expanded_queries
return response
except Exception as e:
# Fallback: return original query to maintain pipeline functionality
logger.exception("Failed to expand query {query}: {error}", query=query, error=str(e))
return response
def warm_up(self) -> None:
"""
Warm up the underlying chat generator.
"""
if hasattr(self.chat_generator, "warm_up"):
self.chat_generator.warm_up()
async def warm_up_async(self) -> None:
"""
Warm up the underlying chat generator on the serving event loop.
"""
if hasattr(self.chat_generator, "warm_up_async"):
await self.chat_generator.warm_up_async()
elif hasattr(self.chat_generator, "warm_up"):
self.chat_generator.warm_up()
def close(self) -> None:
"""
Release the underlying chat generator's resources.
"""
if hasattr(self.chat_generator, "close"):
self.chat_generator.close()
async def close_async(self) -> None:
"""
Release the underlying chat generator's async resources.
"""
if hasattr(self.chat_generator, "close_async"):
await self.chat_generator.close_async()
elif hasattr(self.chat_generator, "close"):
self.chat_generator.close()
@staticmethod
def _parse_expanded_queries(generator_response: str) -> list[str]:
"""
Parse the generator response to extract individual expanded queries.
:param generator_response: The raw text response from the generator.
:return: List of parsed expanded queries, deduplicated in first-seen order.
"""
parsed = _parse_dict_from_json(generator_response, expected_keys=["queries"], raise_on_failure=False)
if parsed is None:
return []
if not isinstance(parsed["queries"], list):
logger.warning(
"Expected 'queries' to be a list but got {type}. Returning no expanded queries.",
type=type(parsed["queries"]).__name__,
)
return []
queries = []
seen: set[str] = set()
for item in parsed["queries"]:
if isinstance(item, str) and item.strip():
stripped = item.strip()
if stripped not in seen:
seen.add(stripped)
queries.append(stripped)
else:
logger.warning("Skipping non-string or empty query in response: {item}", item=item)
return queries