Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

374 lines
15 KiB
Python

# 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.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)
generator_result = self.chat_generator.run(messages=[ChatMessage.from_user(prompt_result["prompt"])])
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 requested and remove duplicates
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)
generator_result = await _execute_component_async(
self.chat_generator, messages=[ChatMessage.from_user(prompt_result["prompt"])]
)
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 requested and remove duplicates
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.
"""
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 = []
for item in parsed["queries"]:
if isinstance(item, str) and item.strip():
queries.append(item.strip())
else:
logger.warning("Skipping non-string or empty query in response: {item}", item=item)
return queries