chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# We avoid lazy imports here because:
|
||||
# - they create potential static type checking issues which are hard to debug
|
||||
# - they make this module more complicated and hard to maintain
|
||||
# - they offer minimal performance gains in this case.
|
||||
|
||||
import haystack.logging
|
||||
|
||||
# Imported so the `haystack.tracing` namespace is available after `import haystack`.
|
||||
import haystack.tracing # noqa: F401
|
||||
from haystack.core.component import component
|
||||
from haystack.core.errors import ComponentError, DeserializationError
|
||||
from haystack.core.pipeline import Pipeline
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
from haystack.core.super_component.super_component import SuperComponent, super_component
|
||||
from haystack.dataclasses import Answer, Document, ExtractedAnswer, GeneratedAnswer
|
||||
from haystack.version import __version__ # noqa: F401
|
||||
|
||||
# Initialize the logging configuration.
|
||||
# This is a no-op unless `structlog` is installed. `configure_structlog=False` means we only install our own scoped
|
||||
# logging handler (so Haystack's logs are formatted) without touching the process-global `structlog` configuration.
|
||||
haystack.logging.configure_logging(configure_structlog=False)
|
||||
|
||||
__all__ = [
|
||||
"Answer",
|
||||
"ComponentError",
|
||||
"DeserializationError",
|
||||
"Document",
|
||||
"ExtractedAnswer",
|
||||
"GeneratedAnswer",
|
||||
"Pipeline",
|
||||
"SuperComponent",
|
||||
"super_component",
|
||||
"component",
|
||||
"default_from_dict",
|
||||
"default_to_dict",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"agent": ["Agent"], "state": ["State"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent import Agent as Agent
|
||||
from .state import State as State
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"state": ["State", "merge_lists", "replace_values"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state import State as State
|
||||
from .state_utils import merge_lists as merge_lists
|
||||
from .state_utils import replace_values as replace_values
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,207 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from typing import Any, get_args
|
||||
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.utils import _deserialize_value_with_schema, _serialize_value_with_schema
|
||||
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
|
||||
from haystack.utils.type_serialization import deserialize_type, serialize_type
|
||||
|
||||
from .state_utils import _is_list_type, _is_valid_type, merge_lists, replace_values
|
||||
|
||||
|
||||
def _schema_to_dict(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert a schema dictionary to a serializable format.
|
||||
|
||||
Converts each parameter's type and optional handler function into a serializable
|
||||
format using type and callable serialization utilities.
|
||||
|
||||
:param schema: Dictionary mapping parameter names to their type and handler configs
|
||||
:returns: Dictionary with serialized type and handler information
|
||||
"""
|
||||
serialized_schema = {}
|
||||
for param, config in schema.items():
|
||||
serialized_schema[param] = {"type": serialize_type(config["type"])}
|
||||
if config.get("handler"):
|
||||
serialized_schema[param]["handler"] = serialize_callable(config["handler"])
|
||||
|
||||
return serialized_schema
|
||||
|
||||
|
||||
def _schema_from_dict(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert a serialized schema dictionary back to its original format.
|
||||
|
||||
Deserializes the type and optional handler function for each parameter from their
|
||||
serialized format back into Python types and callables.
|
||||
|
||||
:param schema: Dictionary containing serialized schema information
|
||||
:returns: Dictionary with deserialized type and handler configurations
|
||||
"""
|
||||
deserialized_schema = {}
|
||||
for param, config in schema.items():
|
||||
deserialized_schema[param] = {"type": deserialize_type(config["type"])}
|
||||
|
||||
if config.get("handler"):
|
||||
deserialized_schema[param]["handler"] = deserialize_callable(config["handler"])
|
||||
|
||||
return deserialized_schema
|
||||
|
||||
|
||||
def _validate_schema(schema: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate that a schema dictionary meets all required constraints.
|
||||
|
||||
Checks that each parameter definition has a valid type field and that any handler
|
||||
specified is a callable function.
|
||||
|
||||
:param schema: Dictionary mapping parameter names to their type and handler configs
|
||||
:raises ValueError: If schema validation fails due to missing or invalid fields
|
||||
"""
|
||||
for param, definition in schema.items():
|
||||
if "type" not in definition:
|
||||
raise ValueError(f"StateSchema: Key '{param}' is missing a 'type' entry.")
|
||||
if not _is_valid_type(definition["type"]):
|
||||
raise ValueError(f"StateSchema: 'type' for key '{param}' must be a Python type, got {definition['type']}")
|
||||
if definition.get("handler") is not None and not callable(definition["handler"]):
|
||||
raise ValueError(f"StateSchema: 'handler' for key '{param}' must be callable or None")
|
||||
if param == "messages": # definition["type"] != list[ChatMessage] but split to cover also List[ChatMessage]
|
||||
if not _is_list_type(definition["type"]):
|
||||
raise ValueError(f"StateSchema: 'messages' must be of type list[ChatMessage], got {definition['type']}")
|
||||
# Check if the list contains ChatMessage elements
|
||||
args = get_args(definition["type"])
|
||||
if not args or not issubclass(args[0], ChatMessage):
|
||||
raise ValueError(f"StateSchema: 'messages' must be of type list[ChatMessage], got {definition['type']}")
|
||||
|
||||
|
||||
class State:
|
||||
"""
|
||||
State is a container for storing shared information during the execution of an Agent and its tools.
|
||||
|
||||
For instance, State can be used to store documents, context, and intermediate results.
|
||||
|
||||
Internally it wraps a `_data` dictionary defined by a `schema`. Each schema entry has:
|
||||
```json
|
||||
"parameter_name": {
|
||||
"type": SomeType, # expected type
|
||||
"handler": Optional[Callable[[Any, Any], Any]] # merge/update function
|
||||
}
|
||||
```
|
||||
|
||||
Handlers control how values are merged when using the `set()` method:
|
||||
- For list types: defaults to `merge_lists` (concatenates lists)
|
||||
- For other types: defaults to `replace_values` (overwrites existing value)
|
||||
|
||||
A `messages` field with type `list[ChatMessage]` is automatically added to the schema.
|
||||
|
||||
This makes it possible for the Agent to read from and write to the same context.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.agents.state import State
|
||||
|
||||
my_state = State(
|
||||
schema={"gh_repo_name": {"type": str}, "user_name": {"type": str}},
|
||||
data={"gh_repo_name": "my_repo", "user_name": "my_user_name"}
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, schema: dict[str, Any], data: dict[str, Any] | None = None) -> None:
|
||||
"""
|
||||
Initialize a State object with a schema and optional data.
|
||||
|
||||
:param schema: Dictionary mapping parameter names to their type and handler configs.
|
||||
Type must be a valid Python type, and handler must be a callable function or None.
|
||||
If handler is None, the default handler for the type will be used. The default handlers are:
|
||||
- For list types: `haystack.agents.state.state_utils.merge_lists`
|
||||
- For all other types: `haystack.agents.state.state_utils.replace_values`
|
||||
:param data: Optional dictionary of initial data to populate the state
|
||||
"""
|
||||
_validate_schema(schema)
|
||||
self.schema = deepcopy(schema)
|
||||
if self.schema.get("messages") is None:
|
||||
self.schema["messages"] = {"type": list[ChatMessage], "handler": merge_lists}
|
||||
self._data = data or {}
|
||||
|
||||
# Set default handlers if not provided in schema
|
||||
for definition in self.schema.values():
|
||||
# Skip if handler is already defined and not None
|
||||
if definition.get("handler") is not None:
|
||||
continue
|
||||
# Set default handler based on type
|
||||
if _is_list_type(definition["type"]):
|
||||
definition["handler"] = merge_lists
|
||||
else:
|
||||
definition["handler"] = replace_values
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Retrieve a value from the state by key.
|
||||
|
||||
:param key: Key to look up in the state
|
||||
:param default: Value to return if key is not found
|
||||
:returns: Value associated with key or default if not found
|
||||
"""
|
||||
return deepcopy(self._data.get(key, default))
|
||||
|
||||
def set(self, key: str, value: Any, handler_override: Callable[[Any, Any], Any] | None = None) -> None:
|
||||
"""
|
||||
Set or merge a value in the state according to schema rules.
|
||||
|
||||
Value is merged or overwritten according to these rules:
|
||||
- if handler_override is given, use that
|
||||
- else use the handler defined in the schema for 'key'
|
||||
|
||||
:param key: Key to store the value under
|
||||
:param value: Value to store or merge
|
||||
:param handler_override: Optional function to override the default merge behavior
|
||||
"""
|
||||
# If key not in schema, we throw an error
|
||||
definition = self.schema.get(key, None)
|
||||
if definition is None:
|
||||
raise ValueError(f"State: Key '{key}' not found in schema. Schema: {self.schema}")
|
||||
|
||||
# Get current value from state and apply handler
|
||||
current_value = self._data.get(key, None)
|
||||
handler = handler_override or definition["handler"]
|
||||
self._data[key] = handler(current_value, value)
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""
|
||||
All current data of the state.
|
||||
"""
|
||||
return self._data
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""
|
||||
Check if a key exists in the state.
|
||||
|
||||
:param key: Key to check for existence
|
||||
:returns: True if key exists in state, False otherwise
|
||||
"""
|
||||
return key in self._data
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the State object to a dictionary.
|
||||
"""
|
||||
serialized = {}
|
||||
serialized["schema"] = _schema_to_dict(self.schema)
|
||||
serialized["data"] = _serialize_value_with_schema(self._data)
|
||||
return serialized
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "State":
|
||||
"""
|
||||
Convert a dictionary back to a State object.
|
||||
"""
|
||||
schema = _schema_from_dict(data.get("schema", {}))
|
||||
deserialized_data = _deserialize_value_with_schema(data.get("data", {}))
|
||||
return State(schema, deserialized_data)
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import inspect
|
||||
from typing import Any, TypeVar, Union, get_origin
|
||||
|
||||
from haystack.utils.type_serialization import _is_union_type
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _is_valid_type(obj: Any) -> bool:
|
||||
"""
|
||||
Check if an object is a valid type annotation.
|
||||
|
||||
Valid types include:
|
||||
- Normal classes (str, dict, CustomClass)
|
||||
- Generic types (list[str], dict[str, int])
|
||||
- Union types (Union[str, int], Optional[str], str | int, str | None)
|
||||
|
||||
:param obj: The object to check
|
||||
:return: True if the object is a valid type annotation, False otherwise
|
||||
|
||||
Example usage:
|
||||
# >> _is_valid_type(str)
|
||||
# >> True
|
||||
# >> _is_valid_type(list[int])
|
||||
# >> True
|
||||
# >> _is_valid_type(Union[str, int])
|
||||
# >> True
|
||||
# >> _is_valid_type(str | int)
|
||||
# >> True
|
||||
# >> _is_valid_type(42)
|
||||
# >> False
|
||||
"""
|
||||
# Handle Union types (including Optional)
|
||||
if (origin := get_origin(obj)) and _is_union_type(origin):
|
||||
return True
|
||||
|
||||
# Bare Union type (without parameters) is not a valid type annotation
|
||||
# Previously handled by inspect.isclass(obj) but in python 3.14 this returns True for typing.Union
|
||||
if obj == Union:
|
||||
return False
|
||||
|
||||
# Handle normal classes and generic types
|
||||
return inspect.isclass(obj) or type(obj).__name__ in {"_GenericAlias", "GenericAlias"}
|
||||
|
||||
|
||||
def _is_list_type(type_hint: Any) -> bool:
|
||||
"""
|
||||
Check if a type hint represents a list type.
|
||||
|
||||
:param type_hint: The type hint to check
|
||||
:return: True if the type hint represents a list, False otherwise
|
||||
"""
|
||||
return type_hint == list or (hasattr(type_hint, "__origin__") and get_origin(type_hint) == list)
|
||||
|
||||
|
||||
def merge_lists(current: Union[list[T], T, None], new: Union[list[T], T]) -> list[T]:
|
||||
"""
|
||||
Merges two values into a single list.
|
||||
|
||||
If either `current` or `new` is not already a list, it is converted into one.
|
||||
The function ensures that both inputs are treated as lists and concatenates them.
|
||||
|
||||
If `current` is None, it is treated as an empty list.
|
||||
|
||||
:param current: The existing value(s), either a single item or a list.
|
||||
:param new: The new value(s) to merge, either a single item or a list.
|
||||
:return: A list containing elements from both `current` and `new`.
|
||||
"""
|
||||
current_list = [] if current is None else current if isinstance(current, list) else [current]
|
||||
new_list = new if isinstance(new, list) else [new]
|
||||
return current_list + new_list
|
||||
|
||||
|
||||
def replace_values(current: Any, new: Any) -> Any: # noqa: ARG001
|
||||
"""
|
||||
Replace the `current` value with the `new` value.
|
||||
|
||||
:param current: The existing value
|
||||
:param new: The new value to replace
|
||||
:return: The new value
|
||||
"""
|
||||
return new
|
||||
@@ -0,0 +1,667 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from haystack import logging, tracing
|
||||
from haystack.components.agents.state.state import State
|
||||
from haystack.core.component.sockets import Sockets
|
||||
from haystack.dataclasses import ChatMessage, ToolCall
|
||||
from haystack.dataclasses.streaming_chunk import StreamingCallbackT, StreamingChunk, _invoke_streaming_callback
|
||||
from haystack.tools import ComponentTool, Tool, ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets
|
||||
from haystack.tools.errors import ToolInvocationError
|
||||
from haystack.tools.parameters_schema_utils import _unwrap_optional
|
||||
from haystack.tracing.utils import _serializable_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _AllStateKeys:
|
||||
"""Sentinel representing "every state key", used for tools that receive the full State object."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<all state keys>"
|
||||
|
||||
|
||||
_ALL_STATE_KEYS = _AllStateKeys()
|
||||
|
||||
# A set of state keys, or the _ALL_STATE_KEYS sentinel meaning "every key".
|
||||
_StateKeys = set[str] | _AllStateKeys
|
||||
|
||||
|
||||
class ToolNotFoundException(Exception):
|
||||
"""Exception raised when a tool is not found in the list of available tools."""
|
||||
|
||||
def __init__(self, tool_name: str, available_tools: list[str]) -> None:
|
||||
message = f"Tool '{tool_name}' not found. Available tools: {', '.join(available_tools)}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _validate_and_prepare_tools(tools: ToolsType) -> dict[str, Tool]:
|
||||
"""
|
||||
Flatten, deduplicate-check, and index tools by name.
|
||||
|
||||
:raises ValueError: If no tools are provided or if duplicate tool names are found.
|
||||
"""
|
||||
if not tools:
|
||||
raise ValueError("Tool execution requires at least one tool.")
|
||||
|
||||
available_tools = flatten_tools_or_toolsets(tools)
|
||||
_check_duplicate_tool_names(available_tools)
|
||||
tool_names = [tool.name for tool in available_tools]
|
||||
|
||||
return dict(zip(tool_names, available_tools, strict=True))
|
||||
|
||||
|
||||
def _merge_tool_outputs_into_state(tool: Tool, result: Any, state: State) -> None:
|
||||
"""
|
||||
Write tool outputs into State according to the tool's `outputs_to_state` mapping.
|
||||
|
||||
:raises RuntimeError: If writing an output value into the state fails.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return
|
||||
|
||||
for state_key, config in (tool.outputs_to_state or {}).items():
|
||||
source_key = config.get("source", None)
|
||||
if source_key and source_key not in result:
|
||||
continue
|
||||
output_value = result.get(source_key) if source_key else result
|
||||
try:
|
||||
state.set(state_key, output_value, handler_override=config.get("handler"))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Tool '{tool.name}': failed to merge outputs into state. {e}") from e
|
||||
|
||||
|
||||
def _result_to_string(result: Any) -> str:
|
||||
"""
|
||||
Convert a tool result to a string.
|
||||
|
||||
Strings are returned as-is; all other types are passed through a JSON serialization step to produce more readable
|
||||
output, with a fallback to plain str() conversion if serialization fails.
|
||||
|
||||
:param result: The tool result to convert.
|
||||
:returns: A string representation of the tool result.
|
||||
"""
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
serializable = _serializable_value(value=result, use_placeholders=False)
|
||||
try:
|
||||
return json.dumps(serializable, ensure_ascii=False)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Tool result is not JSON serializable. Falling back to str conversion. Result: {result}\nError: {err}",
|
||||
result=result,
|
||||
err=error,
|
||||
)
|
||||
return str(result)
|
||||
|
||||
|
||||
def _process_tool_output(config: dict[str, Any], result: Any, tool_call: ToolCall, *, raise_on_failure: bool) -> Any:
|
||||
"""
|
||||
Extract and convert a single tool output according to `config`.
|
||||
|
||||
`config` may contain `source` (key to extract from result dict), `handler` (conversion callable), and
|
||||
`raw_result` (return the value without string conversion).
|
||||
|
||||
If a configured `handler` raises, the exception is re-raised when `raise_on_failure` is True; otherwise
|
||||
a warning is logged and the value is converted via `_result_to_string`.
|
||||
"""
|
||||
source_key = config.get("source")
|
||||
value = result.get(source_key) if source_key is not None and isinstance(result, dict) else result
|
||||
|
||||
handler = config.get("handler")
|
||||
raw_result = config.get("raw_result", False)
|
||||
|
||||
if handler is None:
|
||||
# raw result is mostly used to allow ImageContent or TextContent blocks to be directly returned and consumed
|
||||
# by ChatMessage.from_tool without string conversion.
|
||||
if raw_result:
|
||||
return value
|
||||
return _result_to_string(value)
|
||||
|
||||
try:
|
||||
return handler(value)
|
||||
except Exception as e:
|
||||
if raise_on_failure:
|
||||
raise
|
||||
logger.warning(
|
||||
"Output handler '{handler}' for tool '{tool}' failed, falling back to string conversion. Error: {err}",
|
||||
handler=handler.__name__,
|
||||
tool=tool_call.tool_name,
|
||||
err=e,
|
||||
)
|
||||
return _result_to_string(value)
|
||||
|
||||
|
||||
def _build_tool_result_message(result: Any, tool_call: ToolCall, tool: Tool, *, raise_on_failure: bool) -> ChatMessage:
|
||||
"""Convert a raw tool result into a ChatMessage, applying `outputs_to_string` config if present."""
|
||||
outputs_config = tool.outputs_to_string or {}
|
||||
|
||||
# Single-output config (or no config): keys are at the root level
|
||||
if not outputs_config or any(k in outputs_config for k in ("source", "handler", "raw_result")):
|
||||
tool_result = _process_tool_output(outputs_config, result, tool_call, raise_on_failure=raise_on_failure)
|
||||
return ChatMessage.from_tool(tool_result=tool_result, origin=tool_call)
|
||||
|
||||
# Multi-output config: each key maps to its own sub-config — stringify each value, then stringify the whole dict
|
||||
tool_result_dict = {
|
||||
output_key: _process_tool_output(
|
||||
{**cfg, "raw_result": False}, result, tool_call, raise_on_failure=raise_on_failure
|
||||
)
|
||||
for output_key, cfg in outputs_config.items()
|
||||
}
|
||||
return ChatMessage.from_tool(tool_result=_result_to_string(tool_result_dict), origin=tool_call)
|
||||
|
||||
|
||||
def _create_tool_result_streaming_chunk(tool_message: ChatMessage, tool_call: ToolCall, index: int) -> StreamingChunk:
|
||||
"""
|
||||
Create a streaming chunk that carries a tool result.
|
||||
|
||||
:param tool_message: The tool result message to stream.
|
||||
:param tool_call: The ToolCall object that triggered the tool invocation.
|
||||
:param index: The position of this tool result in the stream (in execution order).
|
||||
:returns: A StreamingChunk containing the tool result and metadata about the tool call.
|
||||
"""
|
||||
return StreamingChunk(
|
||||
content="",
|
||||
index=index,
|
||||
tool_call_result=tool_message.tool_call_results[0],
|
||||
start=True,
|
||||
meta={"tool_result": tool_message.tool_call_results[0].result, "tool_call": tool_call},
|
||||
)
|
||||
|
||||
|
||||
def _create_tool_span(tool: Tool, tool_call: ToolCall) -> Any:
|
||||
"""
|
||||
Create one tracing span for a single tool call, nested under the currently active span.
|
||||
|
||||
The established standard for tracing agents is one span per tool call, so each call gets its own span rather than
|
||||
grouping all of a step's calls together. The OpenTelemetry GenAI semantic conventions codify this with a dedicated
|
||||
"execute tool" span per invocation
|
||||
(https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md#execute-tool-span),
|
||||
and tracing backends such as Langfuse follow the same model. The span is tagged with the tool's identity; the caller
|
||||
adds the call arguments and result as content tags.
|
||||
"""
|
||||
return tracing.tracer.trace(
|
||||
"haystack.agent.step.tool",
|
||||
tags={"haystack.tool.name": tool_call.tool_name, "haystack.tool.description": tool.description},
|
||||
parent_span=tracing.tracer.current_span(),
|
||||
)
|
||||
|
||||
|
||||
def _make_context_bound_invoke(tool: Tool, args: dict[str, Any], tool_call: ToolCall) -> Callable[[], Any]:
|
||||
"""
|
||||
Return a zero-arg callable that runs `tool.invoke(**args)` under the current contextvars snapshot.
|
||||
|
||||
This preserves tracing spans and other context-local state across thread-pool boundaries, so the per-call span
|
||||
created inside the worker nests correctly under the step span. The callable returns a ToolInvocationError instead
|
||||
of raising so that parallel executions can collect failures without aborting the whole batch.
|
||||
"""
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
def _invoke() -> Any:
|
||||
with _create_tool_span(tool, tool_call) as span:
|
||||
span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments)
|
||||
try:
|
||||
result = tool.invoke(**args)
|
||||
except ToolInvocationError as e:
|
||||
span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)})
|
||||
return e
|
||||
span.set_content_tag("haystack.agent.step.tool.output", result)
|
||||
return result
|
||||
|
||||
def _runner() -> Any:
|
||||
return ctx.run(_invoke)
|
||||
|
||||
return _runner
|
||||
|
||||
|
||||
def _make_bounded_invoke_async(
|
||||
tool: Tool, args: dict[str, Any], semaphore: asyncio.Semaphore, tool_call: ToolCall
|
||||
) -> Callable[[], Any]:
|
||||
"""
|
||||
Return a zero-arg async callable that awaits `tool.invoke_async(**args)` while holding `semaphore`.
|
||||
|
||||
Concurrency is bounded uniformly across native-async tools and sync-fallback tools (which dispatch
|
||||
to a worker thread inside `Tool.invoke_async`). ContextVars naturally inherit into child tasks for
|
||||
the native-async branch, and `asyncio.to_thread` propagates them for the fallback branch, so the per-call
|
||||
span nests correctly under the step span.
|
||||
|
||||
Returns a `ToolInvocationError` instead of raising so that gathered executions can collect failures
|
||||
without aborting the whole batch.
|
||||
"""
|
||||
|
||||
async def _runner() -> Any:
|
||||
async with semaphore:
|
||||
with _create_tool_span(tool, tool_call) as span:
|
||||
span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments)
|
||||
try:
|
||||
result = await tool.invoke_async(**args)
|
||||
except ToolInvocationError as e:
|
||||
span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)})
|
||||
return e
|
||||
span.set_content_tag("haystack.agent.step.tool.output", result)
|
||||
return result
|
||||
|
||||
return _runner
|
||||
|
||||
|
||||
def _get_func_params(tool: Tool) -> dict[str, Any]:
|
||||
"""
|
||||
Return parameter names → annotations for a tool's invocation function.
|
||||
|
||||
- For ComponentTool, this is the annotated input schema defined on the underlying component.
|
||||
- For regular Tools, this is the function signature of the `function` callable, falling back to `async_function`
|
||||
for async-only tools.
|
||||
|
||||
:param tool: The tool to inspect.
|
||||
:returns: A dict mapping parameter names to their type annotations.
|
||||
"""
|
||||
if isinstance(tool, ComponentTool):
|
||||
assert hasattr(tool._component, "__haystack_input__") and isinstance(
|
||||
tool._component.__haystack_input__, Sockets
|
||||
)
|
||||
return {name: socket.type for name, socket in tool._component.__haystack_input__._sockets_dict.items()}
|
||||
# Tool.__post_init__ guarantees that at least one of `function` / `async_function` is set.
|
||||
target = tool.function if tool.function is not None else tool.async_function
|
||||
return {name: param.annotation for name, param in inspect.signature(target).parameters.items()} # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]:
|
||||
"""
|
||||
Merge LLM-provided arguments with state-sourced arguments.
|
||||
|
||||
LLM args take precedence. State values are pulled in only for the keys a tool explicitly declares via its
|
||||
`inputs_from_state` mapping, then the live State object is injected for any param annotated as State.
|
||||
|
||||
:param tool: The tool being invoked, used to determine parameter mappings and State injection.
|
||||
:param llm_args: The arguments provided by the LLM, which take precedence over state values.
|
||||
:param state: The current runtime state, used to source additional arguments as needed.
|
||||
:returns: A dict of arguments to invoke the tool with, combining LLM and state values according to the rules
|
||||
described above.
|
||||
"""
|
||||
final_args = dict(llm_args)
|
||||
func_params = _get_func_params(tool)
|
||||
|
||||
# A tool reads from State by name only via an explicit `inputs_from_state` mapping
|
||||
for state_key, param_name in (tool.inputs_from_state or {}).items():
|
||||
if param_name not in final_args and state.has(state_key):
|
||||
final_args[param_name] = state.get(state_key)
|
||||
|
||||
# We also inject the full State object for any parameter annotated as State
|
||||
for param_name, param_type in func_params.items():
|
||||
if _unwrap_optional(param_type) is State:
|
||||
final_args[param_name] = state
|
||||
|
||||
return final_args
|
||||
|
||||
|
||||
def _prepare_tool_args(
|
||||
*,
|
||||
tool: Tool,
|
||||
tool_call_arguments: dict[str, Any],
|
||||
state: State,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
enable_streaming_passthrough: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Prepare the final arguments for a tool by injecting state inputs and optionally a streaming callback.
|
||||
|
||||
:param tool:
|
||||
The tool instance to prepare arguments for.
|
||||
:param tool_call_arguments:
|
||||
The initial arguments provided for the tool call.
|
||||
:param state:
|
||||
The current state containing inputs to be injected into the tool arguments.
|
||||
:param streaming_callback:
|
||||
Optional streaming callback to be injected if enabled and applicable.
|
||||
:param enable_streaming_passthrough:
|
||||
Flag indicating whether to inject the streaming callback into the tool arguments.
|
||||
|
||||
:returns:
|
||||
A dictionary of final arguments ready for tool invocation.
|
||||
"""
|
||||
# Combine user + state inputs
|
||||
final_args = _inject_state_args(tool, tool_call_arguments.copy(), state)
|
||||
# Check whether to inject streaming_callback
|
||||
if (
|
||||
enable_streaming_passthrough
|
||||
and streaming_callback is not None
|
||||
and "streaming_callback" not in final_args
|
||||
and "streaming_callback" in _get_func_params(tool)
|
||||
):
|
||||
final_args["streaming_callback"] = streaming_callback
|
||||
return final_args
|
||||
|
||||
|
||||
def _resolve_tool_calls(
|
||||
messages_with_tool_calls: list[ChatMessage], tools_with_names: dict[str, Tool], *, raise_on_failure: bool
|
||||
) -> tuple[list[ToolCall], list[Tool], list[ChatMessage]]:
|
||||
"""
|
||||
Walk all tool calls in `messages_with_tool_calls` and resolve each to its Tool.
|
||||
|
||||
Argument preparation is deliberately *not* done here: args are prepared per execution batch (see
|
||||
`_schedule_tool_calls`) so that a tool reading from State observes writes made by tools that ran earlier in the same
|
||||
step.
|
||||
|
||||
:returns: (tool_calls, resolved_tools, error_messages)
|
||||
- tool_calls: ToolCall objects for each valid call, in call order
|
||||
- resolved_tools: the resolved Tool for each entry in `tool_calls` (parallel list)
|
||||
- error_messages: ChatMessages for tool-not-found errors (when raise_on_failure is False)
|
||||
"""
|
||||
tool_calls: list[ToolCall] = []
|
||||
resolved_tools: list[Tool] = []
|
||||
error_messages: list[ChatMessage] = []
|
||||
|
||||
for message in messages_with_tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
tool_name = tool_call.tool_name
|
||||
|
||||
if tool_name not in tools_with_names:
|
||||
error = ToolNotFoundException(tool_name, list(tools_with_names.keys()))
|
||||
if raise_on_failure:
|
||||
raise error
|
||||
logger.error("{error_exception}", error_exception=error)
|
||||
error_messages.append(ChatMessage.from_tool(tool_result=str(error), origin=tool_call, error=True))
|
||||
continue
|
||||
|
||||
tool_calls.append(tool_call)
|
||||
resolved_tools.append(tools_with_names[tool_name])
|
||||
|
||||
return tool_calls, resolved_tools, error_messages
|
||||
|
||||
|
||||
def _keys_intersect(a: _StateKeys, b: _StateKeys) -> bool:
|
||||
"""
|
||||
Return whether two State-key sets share at least one key, treating `_ALL_STATE_KEYS` as a wildcard.
|
||||
|
||||
Used to detect read-after-write dependencies between tool calls: the reader's read set is tested against the
|
||||
writer's write set.
|
||||
|
||||
:param a: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key".
|
||||
:param b: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key".
|
||||
:returns: True if the sets overlap (a wildcard overlaps any non-empty set, and two wildcards always overlap).
|
||||
"""
|
||||
if a is _ALL_STATE_KEYS:
|
||||
# `a` covers every key, so it overlaps `b` as long as `b` touches any key. Two wildcards always overlap;
|
||||
# otherwise `bool(b)` is True iff the concrete set `b` is non-empty.
|
||||
return b is _ALL_STATE_KEYS or bool(b)
|
||||
if b is _ALL_STATE_KEYS:
|
||||
# Symmetric case: wildcard `b` overlaps `a` iff the concrete set `a` is non-empty (`bool(set)` == non-empty).
|
||||
return bool(a)
|
||||
# Both are concrete sets: they overlap iff their set intersection is non-empty.
|
||||
return bool(a & b) # type: ignore[operator]
|
||||
|
||||
|
||||
def _state_io_for_call(tool: Tool, llm_args: dict[str, Any]) -> tuple[_StateKeys, _StateKeys]:
|
||||
"""
|
||||
Compute the State keys a tool call reads from and writes to.
|
||||
|
||||
Mirrors the resolution logic in `_inject_state_args`:
|
||||
- A tool with a `State`-annotated parameter can read/write any key, so both sets are the `_ALL_STATE_KEYS` wildcard.
|
||||
- Otherwise reads come from the tool's explicit `inputs_from_state` mapping, excluding any parameter the LLM already
|
||||
supplied (LLM args take precedence and short-circuit the state lookup).
|
||||
- Writes are the keys in `outputs_to_state`.
|
||||
|
||||
:returns: A `(reads, writes)` tuple of state-key sets (or the `_ALL_STATE_KEYS` wildcard).
|
||||
"""
|
||||
func_params = _get_func_params(tool)
|
||||
# Check if State is in func_params
|
||||
if any(_unwrap_optional(param_type) is State for param_type in func_params.values()):
|
||||
return _ALL_STATE_KEYS, _ALL_STATE_KEYS
|
||||
|
||||
# Calculate reads
|
||||
param_mappings = tool.inputs_from_state or {}
|
||||
reads = {state_key for state_key, param_name in param_mappings.items() if param_name not in llm_args}
|
||||
# Calculate writes
|
||||
writes = set((tool.outputs_to_state or {}).keys())
|
||||
|
||||
return reads, writes
|
||||
|
||||
|
||||
def _schedule_tool_calls(tool_calls: list[ToolCall], tools: list[Tool]) -> list[list[int]]:
|
||||
"""
|
||||
Group tool calls into ordered execution batches based on their State read/write sets.
|
||||
|
||||
Calls within a batch are mutually independent and run in parallel; batches run sequentially. The schedule guarantees
|
||||
that a call reading a State key always runs in a later batch than any call (in the same step) that writes that
|
||||
key — so read-after-write dependencies are honored regardless of the order the LLM requested the calls in.
|
||||
|
||||
This is a layered topological sort: each round, every call whose dependencies have all been scheduled forms the
|
||||
next parallel batch. Dependency cycles — e.g. a tool that both reads and writes the same key, requested more than
|
||||
once — cannot be ordered by the read-after-write rule alone, so they are broken deterministically by call order
|
||||
(the lowest-index remaining call runs next, on its own).
|
||||
|
||||
Pure write-write overlaps create no dependency: nobody reads the contended key, and outputs are merged into State
|
||||
sequentially in call order afterward, so the result stays deterministic without serializing execution.
|
||||
|
||||
:param tool_calls: The tool calls to schedule, in call order.
|
||||
:param tools: The resolved Tool for each entry in `tool_calls` (parallel list).
|
||||
:returns: A list of batches, each a list of indices into `tool_calls`.
|
||||
"""
|
||||
# Per-call (reads, writes) State-key sets, in call order.
|
||||
io_list = [_state_io_for_call(tool, tc.arguments) for tc, tool in zip(tool_calls, tools, strict=True)]
|
||||
n = len(io_list)
|
||||
|
||||
# deps[j] = indices that must run before j because j reads a key they write (read-after-write).
|
||||
deps: list[set[int]] = [set() for _ in range(n)]
|
||||
for j in range(n):
|
||||
reads_j, _ = io_list[j]
|
||||
for i in range(n):
|
||||
if i == j:
|
||||
continue
|
||||
_, writes_i = io_list[i]
|
||||
if _keys_intersect(reads_j, writes_i):
|
||||
deps[j].add(i)
|
||||
|
||||
scheduled = [False] * n
|
||||
done: set[int] = set()
|
||||
batches: list[list[int]] = []
|
||||
|
||||
while len(done) < n:
|
||||
# A call is ready once every writer it depends on has already been scheduled (`deps[k] <= done`, i.e. its
|
||||
# dependency set is a subset of the already-done set). All ready calls have no dependency on each other —
|
||||
# if one read a key another writes, it would still be waiting — so the whole `ready` list runs in parallel.
|
||||
ready = [k for k in range(n) if not scheduled[k] and deps[k] <= done]
|
||||
if not ready:
|
||||
# A dependency cycle remains: break it deterministically by running the lowest-index call next.
|
||||
ready = [next(k for k in range(n) if not scheduled[k])]
|
||||
for k in ready:
|
||||
scheduled[k] = True
|
||||
done.update(ready)
|
||||
batches.append(ready)
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def _finalize_tool_result(
|
||||
result: Any, tool_call: ToolCall, tool: Tool, state: State, *, raise_on_failure: bool
|
||||
) -> ChatMessage:
|
||||
"""
|
||||
Turn a single tool invocation result into a tool-result ChatMessage, merging outputs into State.
|
||||
|
||||
On a `ToolInvocationError`, either re-raise (when `raise_on_failure`) or return an error message. Otherwise
|
||||
merge the tool's outputs into State (in call order, so write-write merges stay deterministic) and build the
|
||||
result message.
|
||||
"""
|
||||
if isinstance(result, ToolInvocationError):
|
||||
if raise_on_failure:
|
||||
raise result
|
||||
logger.error("{error_exception}", error_exception=result)
|
||||
return ChatMessage.from_tool(tool_result=str(result), origin=tool_call, error=True)
|
||||
|
||||
_merge_tool_outputs_into_state(tool, result, state)
|
||||
return _build_tool_result_message(result, tool_call, tool, raise_on_failure=raise_on_failure)
|
||||
|
||||
|
||||
def _run_tool(
|
||||
*,
|
||||
messages: list[ChatMessage],
|
||||
state: State,
|
||||
tools: ToolsType,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
raise_on_failure: bool = True,
|
||||
enable_streaming_callback_passthrough: bool = False,
|
||||
max_workers: int = 4,
|
||||
) -> tuple[list[ChatMessage], State]:
|
||||
"""
|
||||
Invoke all tools referenced by tool calls in `messages`.
|
||||
|
||||
:param messages: ChatMessage objects that may contain tool calls.
|
||||
:param state: Runtime state passed to and updated by tools.
|
||||
:param tools: The tools available for invocation.
|
||||
:param streaming_callback: Called once per tool result as it becomes available.
|
||||
:param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message.
|
||||
:param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it.
|
||||
:param max_workers: Maximum number of parallel tool invocations.
|
||||
:returns: (tool_messages, updated_state)
|
||||
"""
|
||||
tools_with_names = _validate_and_prepare_tools(tools)
|
||||
|
||||
messages_with_tool_calls = [m for m in messages if m.tool_calls]
|
||||
if not messages_with_tool_calls:
|
||||
return [], state
|
||||
|
||||
tool_calls, resolved_tools, error_messages = _resolve_tool_calls(
|
||||
messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure
|
||||
)
|
||||
if not tool_calls:
|
||||
return error_messages, state
|
||||
|
||||
# Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`).
|
||||
batches = _schedule_tool_calls(tool_calls, resolved_tools)
|
||||
|
||||
# Results are indexed by call position so the returned messages stay in call order, even though batches may
|
||||
# execute the calls in a different order.
|
||||
results: list[ChatMessage | None] = [None] * len(tool_calls)
|
||||
stream_index = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for batch in batches:
|
||||
# Prepare args at the start of each batch so tools that read from State observe writes merged by earlier
|
||||
# batches.
|
||||
futures = {}
|
||||
for idx in batch:
|
||||
args = _prepare_tool_args(
|
||||
tool=resolved_tools[idx],
|
||||
tool_call_arguments=tool_calls[idx].arguments,
|
||||
state=state,
|
||||
streaming_callback=streaming_callback,
|
||||
enable_streaming_passthrough=enable_streaming_callback_passthrough,
|
||||
)
|
||||
futures[idx] = executor.submit(_make_context_bound_invoke(resolved_tools[idx], args, tool_calls[idx]))
|
||||
|
||||
# Merge results in call order within the batch so write-write merges stay deterministic.
|
||||
for idx in batch:
|
||||
message = _finalize_tool_result(
|
||||
futures[idx].result(),
|
||||
tool_calls[idx],
|
||||
resolved_tools[idx],
|
||||
state,
|
||||
raise_on_failure=raise_on_failure,
|
||||
)
|
||||
results[idx] = message
|
||||
if streaming_callback is not None:
|
||||
streaming_callback(_create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index))
|
||||
stream_index += 1
|
||||
|
||||
tool_messages = error_messages + [m for m in results if m is not None]
|
||||
|
||||
# We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream.
|
||||
if tool_messages and streaming_callback is not None:
|
||||
streaming_callback(
|
||||
StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"})
|
||||
)
|
||||
|
||||
return tool_messages, state
|
||||
|
||||
|
||||
async def _run_tool_async(
|
||||
*,
|
||||
messages: list[ChatMessage],
|
||||
state: State,
|
||||
tools: ToolsType,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
raise_on_failure: bool = True,
|
||||
enable_streaming_callback_passthrough: bool = False,
|
||||
max_workers: int = 4,
|
||||
) -> tuple[list[ChatMessage], State]:
|
||||
"""
|
||||
Asynchronous variant of `run_tool`. Tool calls execute concurrently via a thread pool.
|
||||
|
||||
:param messages: ChatMessage objects that may contain tool calls.
|
||||
:param state: Runtime state passed to and updated by tools.
|
||||
:param tools: The tools available for invocation.
|
||||
:param streaming_callback: Async callback called once per tool result.
|
||||
:param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message.
|
||||
:param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it.
|
||||
:param max_workers: Maximum number of parallel tool invocations.
|
||||
:returns: (tool_messages, updated_state)
|
||||
"""
|
||||
tools_with_names = _validate_and_prepare_tools(tools)
|
||||
|
||||
messages_with_tool_calls = [m for m in messages if m.tool_calls]
|
||||
if not messages_with_tool_calls:
|
||||
return [], state
|
||||
|
||||
tool_calls, resolved_tools, error_messages = _resolve_tool_calls(
|
||||
messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure
|
||||
)
|
||||
if not tool_calls:
|
||||
return error_messages, state
|
||||
|
||||
# Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`).
|
||||
batches = _schedule_tool_calls(tool_calls, resolved_tools)
|
||||
|
||||
# Results are indexed by call position so the returned messages stay in call order, even though batches may
|
||||
# execute the calls in a different order.
|
||||
results: list[ChatMessage | None] = [None] * len(tool_calls)
|
||||
stream_index = 0
|
||||
|
||||
# `max_workers` + Semaphore bounds concurrency for both sync and async tool calls async tools are awaited directly,
|
||||
# and sync tools are dispatched to a worker thread inside `Tool.invoke_async`.
|
||||
semaphore = asyncio.Semaphore(max_workers)
|
||||
|
||||
for batch in batches:
|
||||
# Prepare args at the start of each batch so readers observe writes merged by earlier batches.
|
||||
tasks = {}
|
||||
for idx in batch:
|
||||
args = _prepare_tool_args(
|
||||
tool=resolved_tools[idx],
|
||||
tool_call_arguments=tool_calls[idx].arguments,
|
||||
state=state,
|
||||
streaming_callback=streaming_callback,
|
||||
enable_streaming_passthrough=enable_streaming_callback_passthrough,
|
||||
)
|
||||
tasks[idx] = _make_bounded_invoke_async(resolved_tools[idx], args, semaphore, tool_calls[idx])()
|
||||
batch_results = await asyncio.gather(*tasks.values())
|
||||
|
||||
# Merge results in call order within the batch so write-write merges stay deterministic.
|
||||
for idx, result in zip(tasks.keys(), batch_results, strict=True):
|
||||
message = _finalize_tool_result(
|
||||
result, tool_calls[idx], resolved_tools[idx], state, raise_on_failure=raise_on_failure
|
||||
)
|
||||
results[idx] = message
|
||||
if streaming_callback is not None:
|
||||
await _invoke_streaming_callback(
|
||||
streaming_callback, _create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index)
|
||||
)
|
||||
stream_index += 1
|
||||
|
||||
tool_messages = error_messages + [m for m in results if m is not None]
|
||||
|
||||
# We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream.
|
||||
if tool_messages and streaming_callback is not None:
|
||||
await _invoke_streaming_callback(
|
||||
streaming_callback,
|
||||
StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"}),
|
||||
)
|
||||
|
||||
return tool_messages, state
|
||||
@@ -0,0 +1,22 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"answer_builder": ["AnswerBuilder"],
|
||||
"chat_prompt_builder": ["ChatPromptBuilder"],
|
||||
"prompt_builder": ["PromptBuilder"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .answer_builder import AnswerBuilder as AnswerBuilder
|
||||
from .chat_prompt_builder import ChatPromptBuilder as ChatPromptBuilder
|
||||
from .prompt_builder import PromptBuilder as PromptBuilder
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,314 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, GeneratedAnswer, component, logging
|
||||
from haystack.dataclasses.chat_message import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_REFERENCE_PATTERN = r"\[(\d+)\]"
|
||||
EXPANDED_REFERENCE_PATTERN = r"\[(\d+(?:[,-]\d+)*)\]"
|
||||
|
||||
|
||||
@component
|
||||
class AnswerBuilder:
|
||||
"""
|
||||
Converts a query and Generator replies into a `GeneratedAnswer` object.
|
||||
|
||||
AnswerBuilder parses Generator replies using custom regular expressions.
|
||||
Check out the usage example below to see how it works.
|
||||
Optionally, it can also take documents and metadata from the Generator to add to the `GeneratedAnswer` object.
|
||||
AnswerBuilder works with both non-chat and chat Generators.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.builders import AnswerBuilder
|
||||
|
||||
builder = AnswerBuilder(pattern="Answer: (.*)")
|
||||
builder.run(query="What's the answer?", replies=["This is an argument. Answer: This is the answer."])
|
||||
```
|
||||
|
||||
### Usage example with documents and reference pattern
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.builders import AnswerBuilder
|
||||
|
||||
replies = ["The capital of France is Paris [2]."]
|
||||
|
||||
docs = [
|
||||
Document(content="Berlin is the capital of Germany."),
|
||||
Document(content="Paris is the capital of France."),
|
||||
Document(content="Rome is the capital of Italy."),
|
||||
]
|
||||
|
||||
builder = AnswerBuilder(reference_pattern="\\[(\\d+)\\]", return_only_referenced_documents=False)
|
||||
result = builder.run(query="What is the capital of France?", replies=replies, documents=docs)["answers"][0]
|
||||
|
||||
print(f"Answer: {result.data}")
|
||||
print("References:")
|
||||
for doc in result.documents:
|
||||
if doc.meta["referenced"]:
|
||||
print(f"[{doc.meta['source_index']}] {doc.content}")
|
||||
print("Other sources:")
|
||||
for doc in result.documents:
|
||||
if not doc.meta["referenced"]:
|
||||
print(f"[{doc.meta['source_index']}] {doc.content}")
|
||||
|
||||
# >> Answer: The capital of France is Paris
|
||||
# >> References:
|
||||
# >> [2] Paris is the capital of France.
|
||||
# >> Other sources:
|
||||
# >> [1] Berlin is the capital of Germany.
|
||||
# >> [3] Rome is the capital of Italy.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern: str | None = None,
|
||||
reference_pattern: str | None = None,
|
||||
last_message_only: bool = False,
|
||||
*,
|
||||
return_only_referenced_documents: bool = True,
|
||||
expand_reference_ranges: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of the AnswerBuilder component.
|
||||
|
||||
:param pattern:
|
||||
The regular expression pattern to extract the answer text from the Generator.
|
||||
If not specified, the entire response is used as the answer.
|
||||
The regular expression can have one capture group at most.
|
||||
If present, the capture group text
|
||||
is used as the answer. If no capture group is present, the whole match is used as the answer.
|
||||
Examples:
|
||||
`[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
|
||||
`Answer: (.*)` finds "this is an answer" in a string "this is an argument. Answer: this is an answer".
|
||||
|
||||
:param reference_pattern:
|
||||
The regular expression pattern used for parsing the document references.
|
||||
If not specified, no parsing is done, and all documents are returned.
|
||||
References need to be specified as indices of the input documents and start at [1].
|
||||
Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
|
||||
If this parameter is provided, documents metadata will contain a "referenced" key with a boolean value.
|
||||
|
||||
:param last_message_only:
|
||||
If False (default value), all messages are used as the answer.
|
||||
If True, only the last message is used as the answer.
|
||||
|
||||
:param return_only_referenced_documents:
|
||||
To be used in conjunction with `reference_pattern`.
|
||||
If True (default value), only the documents that were actually referenced in `replies` are returned.
|
||||
If False, all documents are returned.
|
||||
If `reference_pattern` is not provided, this parameter has no effect, and all documents are returned.
|
||||
:param expand_reference_ranges:
|
||||
If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
|
||||
Defaults to False for backwards compatibility.
|
||||
When enabled with the default `reference_pattern`, a broader pattern is used automatically.
|
||||
"""
|
||||
if pattern:
|
||||
AnswerBuilder._check_num_groups_in_regex(pattern)
|
||||
|
||||
self.pattern = pattern
|
||||
self.reference_pattern = reference_pattern
|
||||
self.last_message_only = last_message_only
|
||||
self.return_only_referenced_documents = return_only_referenced_documents
|
||||
self.expand_reference_ranges = expand_reference_ranges
|
||||
|
||||
@component.output_types(answers=list[GeneratedAnswer])
|
||||
def run(
|
||||
self,
|
||||
query: str,
|
||||
replies: list[str] | list[ChatMessage],
|
||||
meta: list[dict[str, Any]] | None = None,
|
||||
documents: list[Document] | None = None,
|
||||
pattern: str | None = None,
|
||||
reference_pattern: str | None = None,
|
||||
expand_reference_ranges: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Turns the output of a Generator into `GeneratedAnswer` objects using regular expressions.
|
||||
|
||||
:param query:
|
||||
The input query used as the Generator prompt.
|
||||
:param replies:
|
||||
The output of the Generator. Can be a list of strings or a list of `ChatMessage` objects.
|
||||
:param meta:
|
||||
The metadata returned by the Generator. If not specified, the generated answer will contain no metadata.
|
||||
:param documents:
|
||||
The documents used as the Generator inputs. If specified, they are added to
|
||||
the `GeneratedAnswer` objects.
|
||||
The Document copies inside the returned `GeneratedAnswer.documents` each include a "source_index" key,
|
||||
representing the document's 1-based position in the input list. The original input documents are
|
||||
not modified.
|
||||
When `reference_pattern` is provided:
|
||||
- "referenced" key is added to the Document copies inside `GeneratedAnswer.documents`, indicating if
|
||||
the document was referenced in the output.
|
||||
- `return_only_referenced_documents` init parameter controls if all or only referenced documents are
|
||||
returned.
|
||||
:param pattern:
|
||||
The regular expression pattern to extract the answer text from the Generator.
|
||||
If not specified, the entire response is used as the answer.
|
||||
The regular expression can have one capture group at most.
|
||||
If present, the capture group text
|
||||
is used as the answer. If no capture group is present, the whole match is used as the answer.
|
||||
Examples:
|
||||
`[^\\n]+$` finds "this is an answer" in a string "this is an argument.\\nthis is an answer".
|
||||
`Answer: (.*)` finds "this is an answer" in a string
|
||||
"this is an argument. Answer: this is an answer".
|
||||
:param reference_pattern:
|
||||
The regular expression pattern used for parsing the document references.
|
||||
If not specified, no parsing is done, and all documents are returned.
|
||||
References need to be specified as indices of the input documents and start at [1].
|
||||
Example: `\\[(\\d+)\\]` finds "1" in a string "this is an answer[1]".
|
||||
:param expand_reference_ranges:
|
||||
If True, reference ranges like `[6-10]` are expanded to documents 6 through 10.
|
||||
If not specified, the value from the component initialization is used.
|
||||
|
||||
:returns: A dictionary with the following keys:
|
||||
- `answers`: The answers received from the output of the Generator.
|
||||
"""
|
||||
if not meta:
|
||||
meta = [{}] * len(replies)
|
||||
elif len(replies) != len(meta):
|
||||
raise ValueError(f"Number of replies ({len(replies)}), and metadata ({len(meta)}) must match.")
|
||||
|
||||
if pattern:
|
||||
AnswerBuilder._check_num_groups_in_regex(pattern)
|
||||
|
||||
pattern = pattern or self.pattern
|
||||
reference_pattern = reference_pattern or self.reference_pattern
|
||||
expand_reference_ranges = (
|
||||
self.expand_reference_ranges if expand_reference_ranges is None else expand_reference_ranges
|
||||
)
|
||||
reference_pattern = AnswerBuilder._resolve_reference_pattern(
|
||||
reference_pattern=reference_pattern, expand_reference_ranges=expand_reference_ranges
|
||||
)
|
||||
|
||||
replies_to_iterate = replies[-1:] if self.last_message_only and replies else replies
|
||||
meta_to_iterate = meta[-1:] if self.last_message_only and meta else meta
|
||||
|
||||
all_answers = []
|
||||
for reply, given_metadata in zip(replies_to_iterate, meta_to_iterate, strict=True):
|
||||
# Extract content from ChatMessage objects if reply is a ChatMessages, else use the string as is
|
||||
extracted_reply = reply.text or "" if isinstance(reply, ChatMessage) else str(reply)
|
||||
extracted_metadata = reply.meta if isinstance(reply, ChatMessage) else {}
|
||||
|
||||
extracted_metadata = {**extracted_metadata, **given_metadata}
|
||||
extracted_metadata["all_messages"] = replies
|
||||
|
||||
referenced_docs = []
|
||||
if documents:
|
||||
referenced_idxs = (
|
||||
AnswerBuilder._extract_reference_idxs(
|
||||
extracted_reply,
|
||||
reference_pattern,
|
||||
expand_ranges=expand_reference_ranges,
|
||||
num_documents=len(documents),
|
||||
)
|
||||
if reference_pattern
|
||||
else set()
|
||||
)
|
||||
doc_idxs = (
|
||||
referenced_idxs
|
||||
if reference_pattern and self.return_only_referenced_documents
|
||||
else set(range(len(documents)))
|
||||
)
|
||||
|
||||
for idx in doc_idxs:
|
||||
try:
|
||||
doc = documents[idx]
|
||||
except IndexError:
|
||||
logger.warning(
|
||||
"Document index '{index}' referenced in Generator output is out of range. ", index=idx + 1
|
||||
)
|
||||
continue
|
||||
|
||||
doc_meta: dict[str, Any] = dict(doc.meta or {})
|
||||
doc_meta["source_index"] = idx + 1
|
||||
if reference_pattern:
|
||||
doc_meta["referenced"] = idx in referenced_idxs
|
||||
referenced_docs.append(replace(doc, meta=doc_meta))
|
||||
|
||||
answer_string = AnswerBuilder._extract_answer_string(extracted_reply, pattern)
|
||||
answer = GeneratedAnswer(
|
||||
data=answer_string, query=query, documents=referenced_docs, meta=extracted_metadata
|
||||
)
|
||||
all_answers.append(answer)
|
||||
|
||||
return {"answers": all_answers}
|
||||
|
||||
@staticmethod
|
||||
def _extract_answer_string(reply: str, pattern: str | None = None) -> str:
|
||||
"""
|
||||
Extract the answer string from the generator output using the specified pattern.
|
||||
|
||||
If no pattern is specified, the whole string is used as the answer.
|
||||
|
||||
:param reply:
|
||||
The output of the Generator. A string.
|
||||
:param pattern:
|
||||
The regular expression pattern to use to extract the answer text from the generator output.
|
||||
"""
|
||||
if pattern is None:
|
||||
return reply
|
||||
|
||||
if match := re.search(pattern, reply):
|
||||
# No capture group in pattern -> use the whole match as answer
|
||||
if not match.lastindex:
|
||||
return match.group(0)
|
||||
# One capture group in pattern -> use the capture group as answer
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_reference_pattern(reference_pattern: str | None, expand_reference_ranges: bool) -> str | None:
|
||||
if not reference_pattern or not expand_reference_ranges:
|
||||
return reference_pattern
|
||||
if reference_pattern == DEFAULT_REFERENCE_PATTERN:
|
||||
return EXPANDED_REFERENCE_PATTERN
|
||||
return reference_pattern
|
||||
|
||||
@staticmethod
|
||||
def _extract_reference_idxs(
|
||||
reply: str, reference_pattern: str, expand_ranges: bool = False, num_documents: int | None = None
|
||||
) -> set[int]:
|
||||
matches = re.findall(reference_pattern, reply)
|
||||
idxs: set[int] = set()
|
||||
for match in matches:
|
||||
if expand_ranges:
|
||||
for part in match.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
start_str, end_str = part.split("-", 1)
|
||||
start, end = int(start_str), int(end_str)
|
||||
if start > end:
|
||||
continue
|
||||
# Clamp the range end to the number of documents to avoid materializing a huge
|
||||
# set from an out-of-range citation like `[1-999999999]` in the Generator output.
|
||||
if num_documents is not None:
|
||||
end = min(end, num_documents)
|
||||
idxs.update(range(start - 1, end))
|
||||
else:
|
||||
idxs.add(int(part) - 1)
|
||||
else:
|
||||
idxs.add(int(match) - 1)
|
||||
return idxs
|
||||
|
||||
@staticmethod
|
||||
def _check_num_groups_in_regex(pattern: str) -> None:
|
||||
num_groups = re.compile(pattern).groups
|
||||
if num_groups > 1:
|
||||
raise ValueError(
|
||||
f"Pattern '{pattern}' contains multiple capture groups. "
|
||||
f"Please specify a pattern with at most one capture group."
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.dataclasses.chat_message import ChatMessage, ChatRole, TextContent
|
||||
from haystack.lazy_imports import LazyImport
|
||||
from haystack.utils import Jinja2TimeExtension
|
||||
from haystack.utils.jinja2_chat_extension import ChatMessageExtension
|
||||
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install \"arrow>=1.3.0\"'") as arrow_import:
|
||||
import arrow # noqa: F401
|
||||
|
||||
NO_TEXT_ERROR_MESSAGE = "ChatMessages from {role} role must contain text. Received ChatMessage with no text: {message}"
|
||||
|
||||
FILTER_NOT_ALLOWED_ERROR_MESSAGE = (
|
||||
"The templatize_part filter cannot be used with a template containing a list of"
|
||||
"ChatMessage objects. Use a string template or remove the templatize_part filter "
|
||||
"from the template."
|
||||
)
|
||||
|
||||
|
||||
@component
|
||||
class ChatPromptBuilder:
|
||||
"""
|
||||
Renders a chat prompt from a template using Jinja2 syntax.
|
||||
|
||||
A template can be a list of `ChatMessage` objects, or a special string, as shown in the usage examples.
|
||||
|
||||
It constructs prompts using static or dynamic templates, which you can update for each pipeline run.
|
||||
|
||||
Template variables in the template are required by default. To make any subset of variables optional,
|
||||
set `required_variables` to an explicit list of the variables that should remain required; any variable
|
||||
not listed becomes optional and defaults to an empty string when missing.
|
||||
Set `required_variables` to `None` to mark every variable as optional.
|
||||
|
||||
### Usage examples
|
||||
|
||||
#### Static ChatMessage prompt template
|
||||
|
||||
```python
|
||||
template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")]
|
||||
builder = ChatPromptBuilder(template=template)
|
||||
builder.run(target_language="spanish", snippet="I can't speak spanish.")
|
||||
```
|
||||
|
||||
#### Overriding static ChatMessage template at runtime
|
||||
|
||||
```python
|
||||
template = [ChatMessage.from_user("Translate to {{ target_language }}. Context: {{ snippet }}; Translation:")]
|
||||
builder = ChatPromptBuilder(template=template)
|
||||
builder.run(target_language="spanish", snippet="I can't speak spanish.")
|
||||
|
||||
msg = "Translate to {{ target_language }} and summarize. Context: {{ snippet }}; Summary:"
|
||||
summary_template = [ChatMessage.from_user(msg)]
|
||||
builder.run(target_language="spanish", snippet="I can't speak spanish.", template=summary_template)
|
||||
```
|
||||
|
||||
#### Dynamic ChatMessage prompt template
|
||||
|
||||
```python
|
||||
from haystack.components.builders import ChatPromptBuilder
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack import Pipeline
|
||||
|
||||
# no parameter init, we don't use any runtime template variables
|
||||
prompt_builder = ChatPromptBuilder()
|
||||
llm = OpenAIChatGenerator(model="gpt-5-mini")
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("prompt_builder", prompt_builder)
|
||||
pipe.add_component("llm", llm)
|
||||
pipe.connect("prompt_builder.prompt", "llm.messages")
|
||||
|
||||
location = "Berlin"
|
||||
language = "English"
|
||||
system_message = ChatMessage.from_system("You are an assistant giving information to tourists in {{language}}")
|
||||
messages = [system_message, ChatMessage.from_user("Tell me about {{location}}")]
|
||||
|
||||
res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "language": language},
|
||||
"template": messages}})
|
||||
print(res)
|
||||
# >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
|
||||
# "Berlin is the capital city of Germany and one of the most vibrant
|
||||
# and diverse cities in Europe. Here are some key things to know...Enjoy your time exploring the vibrant and dynamic
|
||||
# capital of Germany!")], _name=None, _meta={'model': 'gpt-5-mini',
|
||||
# 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 27, 'completion_tokens': 681, 'total_tokens':
|
||||
# 708}})]}}
|
||||
|
||||
messages = [system_message, ChatMessage.from_user("What's the weather forecast for {{location}} in the next {{day_count}} days?")]
|
||||
|
||||
res = pipe.run(data={"prompt_builder": {"template_variables": {"location": location, "day_count": "5"},
|
||||
"template": messages}})
|
||||
|
||||
print(res)
|
||||
# >> {'llm': {'replies': [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
|
||||
# "Here is the weather forecast for Berlin in the next 5
|
||||
# days:\\n\\nDay 1: Mostly cloudy with a high of 22°C (72°F) and...so it's always a good idea to check for updates
|
||||
# closer to your visit.")], _name=None, _meta={'model': 'gpt-5-mini',
|
||||
# 'index': 0, 'finish_reason': 'stop', 'usage': {'prompt_tokens': 37, 'completion_tokens': 201,
|
||||
# 'total_tokens': 238}})]}}
|
||||
```
|
||||
|
||||
#### String prompt template
|
||||
```python
|
||||
from haystack.components.builders import ChatPromptBuilder
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
|
||||
template = \"\"\"
|
||||
{% message role="system" %}
|
||||
You are a helpful assistant.
|
||||
{% endmessage %}
|
||||
|
||||
{% message role="user" %}
|
||||
Hello! I am {{user_name}}. What's the difference between the following images?
|
||||
{% for image in images %}
|
||||
{{ image | templatize_part }}
|
||||
{% endfor %}
|
||||
{% endmessage %}
|
||||
\"\"\"
|
||||
|
||||
images = [ImageContent.from_file_path("test/test_files/images/apple.jpg"),
|
||||
ImageContent.from_file_path("test/test_files/images/haystack-logo.png")]
|
||||
|
||||
builder = ChatPromptBuilder(template=template)
|
||||
builder.run(user_name="John", images=images)
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: list[ChatMessage] | str | None = None,
|
||||
required_variables: list[str] | Literal["*"] | None = "*",
|
||||
variables: list[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Constructs a ChatPromptBuilder component.
|
||||
|
||||
:param template:
|
||||
A list of `ChatMessage` objects or a string template. The component looks for Jinja2 template syntax and
|
||||
renders the prompt with the provided variables. Provide the template in either
|
||||
the `init` method` or the `run` method.
|
||||
:param required_variables:
|
||||
List variables that must be provided as input to ChatPromptBuilder.
|
||||
Defaults to `"*"`, which marks every variable found in the prompt as required.
|
||||
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
|
||||
optional and is replaced with an empty string in the rendered prompt when missing.
|
||||
Set to `None` to mark every variable as optional.
|
||||
:param variables:
|
||||
List input variables to use in prompt templates instead of the ones inferred from the
|
||||
`template` parameter. For example, to use more variables during prompt engineering than the ones present
|
||||
in the default template, you can provide them here.
|
||||
"""
|
||||
self._variables = variables
|
||||
self._required_variables = required_variables
|
||||
self.template = template
|
||||
|
||||
self._env = SandboxedEnvironment(extensions=[ChatMessageExtension])
|
||||
if arrow_import.is_successful():
|
||||
self._env.add_extension(Jinja2TimeExtension)
|
||||
|
||||
extracted_variables = []
|
||||
if template and not variables:
|
||||
if isinstance(template, list):
|
||||
for message in template:
|
||||
if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM):
|
||||
# infer variables from template
|
||||
if message.text is None:
|
||||
raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message))
|
||||
if message.text and "templatize_part" in message.text:
|
||||
raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE)
|
||||
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
||||
env=self._env, template=message.text
|
||||
)
|
||||
extracted_variables += list(template_variables - assigned_variables)
|
||||
elif isinstance(template, str):
|
||||
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
||||
env=self._env, template=template
|
||||
)
|
||||
extracted_variables = list(template_variables - assigned_variables)
|
||||
|
||||
extracted_variables = extracted_variables or []
|
||||
self.variables = variables or extracted_variables
|
||||
self.required_variables = required_variables or []
|
||||
|
||||
if len(self.variables) > 0 and required_variables is None:
|
||||
logger.warning(
|
||||
"ChatPromptBuilder has {length} prompt variables and `required_variables` is explicitly set to "
|
||||
"`None`. This treats all prompt variables as optional, which may lead to unintended behavior in "
|
||||
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
|
||||
"variables to be optional.",
|
||||
length=len(self.variables),
|
||||
)
|
||||
|
||||
# setup inputs
|
||||
for var in self.variables:
|
||||
if self.required_variables == "*" or var in self.required_variables:
|
||||
component.set_input_type(self, var, Any)
|
||||
else:
|
||||
component.set_input_type(self, var, Any, "")
|
||||
|
||||
@component.output_types(prompt=list[ChatMessage])
|
||||
def run(
|
||||
self,
|
||||
template: list[ChatMessage] | str | None = None,
|
||||
template_variables: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
"""
|
||||
Renders the prompt template with the provided variables.
|
||||
|
||||
It applies the template variables to render the final prompt. You can provide variables with pipeline kwargs.
|
||||
To overwrite the default template, you can set the `template` parameter.
|
||||
To overwrite pipeline kwargs, you can set the `template_variables` parameter.
|
||||
|
||||
:param template:
|
||||
An optional list of `ChatMessage` objects or string template to overwrite ChatPromptBuilder's default
|
||||
template.
|
||||
If `None`, the default template provided at initialization is used.
|
||||
:param template_variables:
|
||||
An optional dictionary of template variables to overwrite the pipeline variables.
|
||||
:param kwargs:
|
||||
Pipeline variables used for rendering the prompt.
|
||||
|
||||
:returns: A dictionary with the following keys:
|
||||
- `prompt`: The updated list of `ChatMessage` objects after rendering the templates.
|
||||
:raises ValueError:
|
||||
If `chat_messages` is empty or contains elements that are not instances of `ChatMessage`.
|
||||
"""
|
||||
kwargs = kwargs or {}
|
||||
template_variables = template_variables or {}
|
||||
template_variables_combined = {**kwargs, **template_variables}
|
||||
|
||||
if template is None:
|
||||
template = self.template
|
||||
|
||||
if not template:
|
||||
raise ValueError(
|
||||
f"The {self.__class__.__name__} requires a non-empty list of ChatMessage instances. "
|
||||
f"Please provide a valid list of ChatMessage instances to render the prompt."
|
||||
)
|
||||
|
||||
if isinstance(template, list) and not all(isinstance(message, ChatMessage) for message in template):
|
||||
raise ValueError(
|
||||
f"The {self.__class__.__name__} expects a list containing only ChatMessage instances. "
|
||||
f"The provided list contains other types. Please ensure that all elements in the list "
|
||||
f"are ChatMessage instances."
|
||||
)
|
||||
|
||||
processed_messages = []
|
||||
if isinstance(template, list):
|
||||
for message in template:
|
||||
if message.is_from(ChatRole.USER) or message.is_from(ChatRole.SYSTEM):
|
||||
self._validate_variables(set(template_variables_combined.keys()))
|
||||
if message.text is None:
|
||||
raise ValueError(NO_TEXT_ERROR_MESSAGE.format(role=message.role.value, message=message))
|
||||
if message.text and "templatize_part" in message.text:
|
||||
raise ValueError(FILTER_NOT_ALLOWED_ERROR_MESSAGE)
|
||||
compiled_template = self._env.from_string(message.text)
|
||||
rendered_text = compiled_template.render(template_variables_combined)
|
||||
# use dataclasses.replace to avoid in-place mutation of the original message
|
||||
rendered_message: ChatMessage = replace(message, _content=[TextContent(text=rendered_text)])
|
||||
processed_messages.append(rendered_message)
|
||||
else:
|
||||
processed_messages.append(message)
|
||||
elif isinstance(template, str):
|
||||
self._validate_variables(set(template_variables_combined.keys()))
|
||||
processed_messages = self._render_chat_messages_from_str_template(template, template_variables_combined)
|
||||
|
||||
return {"prompt": processed_messages}
|
||||
|
||||
def _render_chat_messages_from_str_template(
|
||||
self, template: str, template_variables: dict[str, Any]
|
||||
) -> list[ChatMessage]:
|
||||
"""
|
||||
Renders a chat message from a string template.
|
||||
|
||||
This must be used in conjunction with the `ChatMessageExtension` Jinja2 extension
|
||||
and the `templatize_part` filter.
|
||||
"""
|
||||
compiled_template = self._env.from_string(template)
|
||||
rendered = compiled_template.render(template_variables)
|
||||
|
||||
messages = []
|
||||
for line in rendered.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
messages.append(ChatMessage.from_dict(json.loads(line)))
|
||||
|
||||
return messages
|
||||
|
||||
def _validate_variables(self, provided_variables: set[str]) -> None:
|
||||
"""
|
||||
Checks if all the required template variables are provided.
|
||||
|
||||
:param provided_variables:
|
||||
A set of provided template variables.
|
||||
:raises ValueError:
|
||||
If no template is provided or if all the required template variables are not provided.
|
||||
"""
|
||||
if self.required_variables == "*":
|
||||
required_variables = sorted(self.variables)
|
||||
else:
|
||||
required_variables = self.required_variables
|
||||
missing_variables = [var for var in required_variables if var not in provided_variables]
|
||||
if missing_variables:
|
||||
missing_vars_str = ", ".join(missing_variables)
|
||||
raise ValueError(
|
||||
f"Missing required input variables in ChatPromptBuilder: {missing_vars_str}. "
|
||||
f"Required variables: {required_variables}. Provided variables: {provided_variables}."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Returns a dictionary representation of the component.
|
||||
|
||||
:returns:
|
||||
Serialized dictionary representation of the component.
|
||||
"""
|
||||
template: list[dict[str, Any]] | str | None = None
|
||||
if isinstance(self.template, list):
|
||||
template = [m.to_dict() for m in self.template]
|
||||
elif isinstance(self.template, str):
|
||||
template = self.template
|
||||
|
||||
return default_to_dict(
|
||||
self, template=template, variables=self._variables, required_variables=self._required_variables
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ChatPromptBuilder":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize and create the component.
|
||||
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
init_parameters = data["init_parameters"]
|
||||
template = init_parameters.get("template")
|
||||
if template:
|
||||
if isinstance(template, list):
|
||||
init_parameters["template"] = [ChatMessage.from_dict(d) for d in template]
|
||||
elif isinstance(template, str):
|
||||
init_parameters["template"] = template
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,271 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import component, default_to_dict, logging
|
||||
from haystack.utils import Jinja2TimeExtension
|
||||
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class PromptBuilder:
|
||||
"""
|
||||
|
||||
Renders a prompt filling in any variables so that it can send it to a Generator.
|
||||
|
||||
The prompt uses Jinja2 template syntax.
|
||||
The variables in the default template are used as PromptBuilder's input and are all required by default.
|
||||
To make any subset of variables optional, set `required_variables` to an explicit list of the variables that
|
||||
should remain required. Optional variables are replaced with an empty string in the rendered prompt.
|
||||
To try out different prompts, you can replace the prompt template at runtime by
|
||||
providing a template for each pipeline run invocation.
|
||||
|
||||
### Usage examples
|
||||
|
||||
#### On its own
|
||||
|
||||
This example uses PromptBuilder to render a prompt template and fill it with `target_language`
|
||||
and `snippet`. PromptBuilder returns a prompt with the string "Translate the following context to Spanish.
|
||||
Context: I can't speak Spanish.; Translation:".
|
||||
```python
|
||||
from haystack.components.builders import PromptBuilder
|
||||
|
||||
template = "Translate the following context to {{ target_language }}. Context: {{ snippet }}; Translation:"
|
||||
builder = PromptBuilder(template=template)
|
||||
builder.run(target_language="spanish", snippet="I can't speak spanish.")
|
||||
```
|
||||
|
||||
#### In a Pipeline
|
||||
|
||||
This is an example of a RAG pipeline where PromptBuilder renders a custom prompt template and fills it
|
||||
with the contents of the retrieved documents and a query. The rendered prompt is then sent to a ChatGenerator.
|
||||
```python
|
||||
from haystack import Pipeline, Document
|
||||
from haystack.utils import Secret
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.builders.prompt_builder import PromptBuilder
|
||||
|
||||
# in a real world use case documents could come from a retriever, web, or any other source
|
||||
documents = [Document(content="Joe lives in Berlin"), Document(content="Joe is a software engineer")]
|
||||
prompt_template = \"\"\"
|
||||
Given these documents, answer the question.
|
||||
Documents:
|
||||
{% for doc in documents %}
|
||||
{{ doc.content }}
|
||||
{% endfor %}
|
||||
|
||||
Question: {{query}}
|
||||
Answer:
|
||||
\"\"\"
|
||||
p = Pipeline()
|
||||
p.add_component(instance=PromptBuilder(template=prompt_template), name="prompt_builder")
|
||||
p.add_component(instance=OpenAIChatGenerator(api_key=Secret.from_env_var("OPENAI_API_KEY")), name="llm")
|
||||
p.connect("prompt_builder", "llm")
|
||||
|
||||
question = "Where does Joe live?"
|
||||
result = p.run({"prompt_builder": {"documents": documents, "query": question}})
|
||||
print(result)
|
||||
```
|
||||
|
||||
#### Changing the template at runtime (prompt engineering)
|
||||
|
||||
You can change the prompt template of an existing pipeline, like in this example:
|
||||
```python
|
||||
documents = [
|
||||
Document(content="Joe lives in Berlin", meta={"name": "doc1"}),
|
||||
Document(content="Joe is a software engineer", meta={"name": "doc1"}),
|
||||
]
|
||||
new_template = \"\"\"
|
||||
You are a helpful assistant.
|
||||
Given these documents, answer the question.
|
||||
Documents:
|
||||
{% for doc in documents %}
|
||||
Document {{ loop.index }}:
|
||||
Document name: {{ doc.meta['name'] }}
|
||||
{{ doc.content }}
|
||||
{% endfor %}
|
||||
|
||||
Question: {{ query }}
|
||||
Answer:
|
||||
\"\"\"
|
||||
p.run({
|
||||
"prompt_builder": {
|
||||
"documents": documents,
|
||||
"query": question,
|
||||
"template": new_template,
|
||||
},
|
||||
})
|
||||
```
|
||||
To replace the variables in the default template when testing your prompt,
|
||||
pass the new variables in the `variables` parameter.
|
||||
|
||||
#### Overwriting variables at runtime
|
||||
|
||||
To overwrite the values of variables, use `template_variables` during runtime:
|
||||
```python
|
||||
language_template = \"\"\"
|
||||
You are a helpful assistant.
|
||||
Given these documents, answer the question.
|
||||
Documents:
|
||||
{% for doc in documents %}
|
||||
Document {{ loop.index }}:
|
||||
Document name: {{ doc.meta['name'] }}
|
||||
{{ doc.content }}
|
||||
{% endfor %}
|
||||
|
||||
Question: {{ query }}
|
||||
Please provide your answer in {{ answer_language | default('English') }}
|
||||
Answer:
|
||||
\"\"\"
|
||||
p.run({
|
||||
"prompt_builder": {
|
||||
"documents": documents,
|
||||
"query": question,
|
||||
"template": language_template,
|
||||
"template_variables": {"answer_language": "German"},
|
||||
},
|
||||
})
|
||||
```
|
||||
Note that `language_template` introduces variable `answer_language` which is not bound to any pipeline variable.
|
||||
If not set otherwise, it will use its default value 'English'.
|
||||
This example overwrites its value to 'German'.
|
||||
Use `template_variables` to overwrite pipeline variables (such as documents) as well.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
required_variables: list[str] | Literal["*"] | None = "*",
|
||||
variables: list[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Constructs a PromptBuilder component.
|
||||
|
||||
:param template:
|
||||
A prompt template that uses Jinja2 syntax to add variables. For example:
|
||||
`"Summarize this document: {{ documents[0].content }}\\nSummary:"`
|
||||
It's used to render the prompt.
|
||||
The variables in the default template are input for PromptBuilder and are all required by default.
|
||||
:param required_variables: List variables that must be provided as input to PromptBuilder.
|
||||
Defaults to `"*"`, which marks every variable found in the prompt as required.
|
||||
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
|
||||
optional and is replaced with an empty string in the rendered prompt when missing.
|
||||
Set to `None` to mark every variable as optional.
|
||||
:param variables:
|
||||
List input variables to use in prompt templates instead of the ones inferred from the
|
||||
`template` parameter. For example, to use more variables during prompt engineering than the ones present
|
||||
in the default template, you can provide them here.
|
||||
"""
|
||||
self._template_string = template
|
||||
self._variables = variables
|
||||
self._required_variables = required_variables
|
||||
self.required_variables = required_variables or []
|
||||
try:
|
||||
# The Jinja2TimeExtension needs an optional dependency to be installed.
|
||||
# If it's not available we can do without it and use the PromptBuilder as is.
|
||||
self._env = SandboxedEnvironment(extensions=[Jinja2TimeExtension])
|
||||
except ImportError:
|
||||
self._env = SandboxedEnvironment()
|
||||
|
||||
self.template = self._env.from_string(template)
|
||||
|
||||
if not variables:
|
||||
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
||||
env=self._env, template=template
|
||||
)
|
||||
variables = list(template_variables - assigned_variables)
|
||||
|
||||
variables = variables or []
|
||||
self.variables = variables
|
||||
|
||||
if len(self.variables) > 0 and required_variables is None:
|
||||
logger.warning(
|
||||
"PromptBuilder has {length} prompt variables and `required_variables` is explicitly set to `None`. "
|
||||
"This treats all prompt variables as optional, which may lead to unintended behavior in "
|
||||
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
|
||||
"variables to be optional.",
|
||||
length=len(self.variables),
|
||||
)
|
||||
|
||||
# setup inputs
|
||||
for var in self.variables:
|
||||
if self.required_variables == "*" or var in self.required_variables:
|
||||
component.set_input_type(self, var, Any)
|
||||
else:
|
||||
component.set_input_type(self, var, Any, "")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Returns a dictionary representation of the component.
|
||||
|
||||
:returns:
|
||||
Serialized dictionary representation of the component.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, template=self._template_string, variables=self._variables, required_variables=self._required_variables
|
||||
)
|
||||
|
||||
@component.output_types(prompt=str)
|
||||
def run(
|
||||
self, template: str | None = None, template_variables: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Renders the prompt template with the provided variables.
|
||||
|
||||
It applies the template variables to render the final prompt. You can provide variables via pipeline kwargs.
|
||||
In order to overwrite the default template, you can set the `template` parameter.
|
||||
In order to overwrite pipeline kwargs, you can set the `template_variables` parameter.
|
||||
|
||||
:param template:
|
||||
An optional string template to overwrite PromptBuilder's default template. If None, the default template
|
||||
provided at initialization is used.
|
||||
:param template_variables:
|
||||
An optional dictionary of template variables to overwrite the pipeline variables.
|
||||
:param kwargs:
|
||||
Pipeline variables used for rendering the prompt.
|
||||
|
||||
:returns: A dictionary with the following keys:
|
||||
- `prompt`: The updated prompt text after rendering the prompt template.
|
||||
|
||||
:raises ValueError:
|
||||
If any of the required template variables is not provided.
|
||||
"""
|
||||
kwargs = kwargs or {}
|
||||
template_variables = template_variables or {}
|
||||
template_variables_combined = {**kwargs, **template_variables}
|
||||
self._validate_variables(set(template_variables_combined.keys()))
|
||||
|
||||
compiled_template = self.template
|
||||
if template is not None:
|
||||
compiled_template = self._env.from_string(template)
|
||||
|
||||
result = compiled_template.render(template_variables_combined)
|
||||
return {"prompt": result}
|
||||
|
||||
def _validate_variables(self, provided_variables: set[str]) -> None:
|
||||
"""
|
||||
Checks if all the required template variables are provided.
|
||||
|
||||
:param provided_variables:
|
||||
A set of provided template variables.
|
||||
:raises ValueError:
|
||||
If any of the required template variables is not provided.
|
||||
"""
|
||||
if self.required_variables == "*":
|
||||
required_variables = sorted(self.variables)
|
||||
else:
|
||||
required_variables = self.required_variables
|
||||
missing_variables = [var for var in required_variables if var not in provided_variables]
|
||||
if missing_variables:
|
||||
missing_vars_str = ", ".join(missing_variables)
|
||||
raise ValueError(
|
||||
f"Missing required input variables in PromptBuilder: {missing_vars_str}. "
|
||||
f"Required variables: {required_variables}. Provided variables: {provided_variables}."
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"cache_checker": ["CacheChecker"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cache_checker import CacheChecker as CacheChecker
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,123 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict
|
||||
from haystack.document_stores.types import DocumentStore
|
||||
|
||||
|
||||
@component
|
||||
class CacheChecker:
|
||||
"""
|
||||
Checks for the presence of documents in a Document Store based on a specified field in each document's metadata.
|
||||
|
||||
If matching documents are found, they are returned as "hits". If not found in the cache, the items
|
||||
are returned as "misses".
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.components.caching.cache_checker import CacheChecker
|
||||
|
||||
docstore = InMemoryDocumentStore()
|
||||
documents = [
|
||||
Document(content="doc1", meta={"url": "https://example.com/1"}),
|
||||
Document(content="doc2", meta={"url": "https://example.com/2"}),
|
||||
Document(content="doc3", meta={"url": "https://example.com/1"}),
|
||||
Document(content="doc4", meta={"url": "https://example.com/2"}),
|
||||
]
|
||||
docstore.write_documents(documents)
|
||||
checker = CacheChecker(docstore, cache_field="url")
|
||||
results = checker.run(items=["https://example.com/1", "https://example.com/5"])
|
||||
assert results == {"hits": [documents[0], documents[2]], "misses": ["https://example.com/5"]}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, document_store: DocumentStore, cache_field: str) -> None:
|
||||
"""
|
||||
Creates a CacheChecker component.
|
||||
|
||||
:param document_store:
|
||||
Document Store to check for the presence of specific documents.
|
||||
:param cache_field:
|
||||
Name of the document's metadata field
|
||||
to check for cache hits.
|
||||
"""
|
||||
self.document_store = document_store
|
||||
self.cache_field = cache_field
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, document_store=self.document_store, cache_field=self.cache_field)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "CacheChecker":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(hits=list[Document], misses=list)
|
||||
def run(self, items: list[Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Checks if any document associated with the specified cache field is already present in the store.
|
||||
|
||||
:param items:
|
||||
Values to be checked against the cache field.
|
||||
:return:
|
||||
A dictionary with two keys:
|
||||
- `hits` - Documents that matched with at least one of the items.
|
||||
- `misses` - Items that were not present in any documents.
|
||||
"""
|
||||
found_documents = []
|
||||
misses = []
|
||||
|
||||
for item in items:
|
||||
filters = {"field": self.cache_field, "operator": "==", "value": item}
|
||||
found = self.document_store.filter_documents(filters=filters)
|
||||
if found:
|
||||
found_documents.extend(found)
|
||||
else:
|
||||
misses.append(item)
|
||||
return {"hits": found_documents, "misses": misses}
|
||||
|
||||
@component.output_types(hits=list[Document], misses=list)
|
||||
async def run_async(self, items: list[Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously checks if any document associated with the specified cache field is already present in the store.
|
||||
|
||||
:param items:
|
||||
Values to be checked against the cache field.
|
||||
:return:
|
||||
A dictionary with two keys:
|
||||
- `hits` - Documents that matched with at least one of the items.
|
||||
- `misses` - Items that were not present in any documents.
|
||||
"""
|
||||
found_documents = []
|
||||
misses = []
|
||||
|
||||
if not hasattr(self.document_store, "filter_documents_async"):
|
||||
raise TypeError(f"Document store {type(self.document_store).__name__} does not provide async support.")
|
||||
|
||||
for item in items:
|
||||
filters = {"field": self.cache_field, "operator": "==", "value": item}
|
||||
found = await self.document_store.filter_documents_async(filters=filters)
|
||||
if found:
|
||||
found_documents.extend(found)
|
||||
else:
|
||||
misses.append(item)
|
||||
return {"hits": found_documents, "misses": misses}
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"csv": ["CSVToDocument"],
|
||||
"docx": ["DOCXToDocument"],
|
||||
"file_to_file_content": ["FileToFileContent"],
|
||||
"html": ["HTMLToDocument"],
|
||||
"json": ["JSONConverter"],
|
||||
"markdown": ["MarkdownToDocument"],
|
||||
"msg": ["MSGToDocument"],
|
||||
"multi_file_converter": ["MultiFileConverter"],
|
||||
"output_adapter": ["OutputAdapter"],
|
||||
"pdfminer": ["PDFMinerToDocument"],
|
||||
"pptx": ["PPTXToDocument"],
|
||||
"pypdf": ["PyPDFToDocument"],
|
||||
"txt": ["TextFileToDocument"],
|
||||
"xlsx": ["XLSXToDocument"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .csv import CSVToDocument as CSVToDocument
|
||||
from .docx import DOCXToDocument as DOCXToDocument
|
||||
from .file_to_file_content import FileToFileContent as FileToFileContent
|
||||
from .html import HTMLToDocument as HTMLToDocument
|
||||
from .json import JSONConverter as JSONConverter
|
||||
from .markdown import MarkdownToDocument as MarkdownToDocument
|
||||
from .msg import MSGToDocument as MSGToDocument
|
||||
from .multi_file_converter import MultiFileConverter as MultiFileConverter
|
||||
from .output_adapter import OutputAdapter as OutputAdapter
|
||||
from .pdfminer import PDFMinerToDocument as PDFMinerToDocument
|
||||
from .pptx import PPTXToDocument as PPTXToDocument
|
||||
from .pypdf import PyPDFToDocument as PyPDFToDocument
|
||||
from .txt import TextFileToDocument as TextFileToDocument
|
||||
from .xlsx import XLSXToDocument as XLSXToDocument
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ROW_MODE_SIZE_WARN_BYTES = 5 * 1024 * 1024 # ~5MB; warn when parsing rows might be memory-heavy
|
||||
|
||||
|
||||
@component
|
||||
class CSVToDocument:
|
||||
"""
|
||||
Converts CSV files to Documents.
|
||||
|
||||
By default, it uses UTF-8 encoding when converting files but
|
||||
you can also set a custom encoding.
|
||||
It can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.csv import CSVToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = CSVToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/csv/sample_1.csv"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'col1,col2\\nrow1,row1\\nrow2,row2\\n'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encoding: str = "utf-8",
|
||||
store_full_path: bool = False,
|
||||
*,
|
||||
conversion_mode: Literal["file", "row"] = "file",
|
||||
delimiter: str = ",",
|
||||
quotechar: str = '"',
|
||||
) -> None:
|
||||
"""
|
||||
Creates a CSVToDocument component.
|
||||
|
||||
:param encoding:
|
||||
The encoding of the csv files to convert.
|
||||
If the encoding is specified in the metadata of a source ByteStream,
|
||||
it overrides this value.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
:param conversion_mode:
|
||||
- "file" (default): one Document per CSV file whose content is the raw CSV text.
|
||||
- "row": convert each CSV row to its own Document (requires `content_column` in `run()`).
|
||||
:param delimiter:
|
||||
CSV delimiter used when parsing in row mode (passed to ``csv.DictReader``).
|
||||
:param quotechar:
|
||||
CSV quote character used when parsing in row mode (passed to ``csv.DictReader``).
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.store_full_path = store_full_path
|
||||
self.conversion_mode = conversion_mode
|
||||
self.delimiter = delimiter
|
||||
self.quotechar = quotechar
|
||||
|
||||
# Basic validation
|
||||
if len(self.delimiter) != 1:
|
||||
raise ValueError("CSVToDocument: delimiter must be a single character.")
|
||||
if len(self.quotechar) != 1:
|
||||
raise ValueError("CSVToDocument: quotechar must be a single character.")
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
*,
|
||||
content_column: str | None = None,
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts CSV files to a Document (file mode) or to one Document per row (row mode).
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param content_column:
|
||||
**Required when** ``conversion_mode="row"``.
|
||||
The column name whose values become ``Document.content`` for each row.
|
||||
The column must exist in the CSV header.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output documents.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created documents
|
||||
"""
|
||||
documents: list[Document] = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
try:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
raw = io.BytesIO(bytestream.data).getvalue()
|
||||
data = raw.decode(encoding=encoding)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
file_path = bytestream.meta.get("file_path")
|
||||
if file_path: # Ensure the value is not None for mypy
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
# Mode: file (backward-compatible default) -> one Document per file
|
||||
if self.conversion_mode == "file":
|
||||
documents.append(Document(content=data, meta=merged_metadata))
|
||||
continue
|
||||
|
||||
# --- ROW MODE (strict) ---
|
||||
# Require content_column in run(); no fallback
|
||||
if not content_column:
|
||||
raise ValueError(
|
||||
"CSVToDocument(row): 'content_column' is required in run() when conversion_mode='row'."
|
||||
)
|
||||
|
||||
# Warn for large CSVs in row mode (memory consideration)
|
||||
try:
|
||||
size_bytes = len(raw)
|
||||
if size_bytes > _ROW_MODE_SIZE_WARN_BYTES:
|
||||
logger.warning(
|
||||
"CSVToDocument(row): parsing a large CSV (~{mb:.1f} MB). "
|
||||
"Consider chunking/streaming if you hit memory issues.",
|
||||
mb=size_bytes / (1024 * 1024),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create DictReader; if this fails, raise (no fallback)
|
||||
try:
|
||||
# ``restkey`` ensures surplus fields on ragged rows (rows with more values than the
|
||||
# header, e.g. an unquoted comma inside a value) land under an explicit string key
|
||||
# instead of the default ``None`` key, which would break ``Document`` id generation.
|
||||
reader = csv.DictReader(
|
||||
io.StringIO(data), delimiter=self.delimiter, quotechar=self.quotechar, restkey="extra_columns"
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"CSVToDocument(row): could not parse CSV rows for {source}: {e}") from e
|
||||
|
||||
# Validate header contains content_column; strict error if missing
|
||||
header = reader.fieldnames or []
|
||||
if content_column not in header:
|
||||
raise ValueError(
|
||||
f"CSVToDocument(row): content_column='{content_column}' not found in header "
|
||||
f"for {source}. Available columns: {header}"
|
||||
)
|
||||
|
||||
# Build documents; if a row processing fails, raise immediately (no skip)
|
||||
for i, row in enumerate(reader):
|
||||
try:
|
||||
doc = self._build_document_from_row(
|
||||
row=row, base_meta=merged_metadata, row_index=i, content_column=content_column
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"CSVToDocument(row): failed to process row {i} for {source}: {e}") from e
|
||||
documents.append(doc)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
# ----- helpers -----
|
||||
def _safe_value(self, value: Any) -> str:
|
||||
"""Normalize CSV cell values: None -> '', everything -> str."""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
def _build_document_from_row(
|
||||
self, row: dict[str, Any], base_meta: dict[str, Any], row_index: int, content_column: str
|
||||
) -> Document:
|
||||
"""
|
||||
Build a ``Document`` from one parsed CSV row.
|
||||
|
||||
:param row: Mapping of column name to cell value for the current row
|
||||
(as produced by ``csv.DictReader``).
|
||||
:param base_meta: File-level and user-provided metadata to start from
|
||||
(for example: ``file_path``, ``encoding``).
|
||||
:param row_index: Zero-based row index in the CSV; stored as
|
||||
``row_number`` in the output document's metadata.
|
||||
:param content_column: Column name to use for ``Document.content``.
|
||||
:returns: A ``Document`` with chosen content and merged metadata.
|
||||
Remaining row columns are added to ``meta`` with collision-safe
|
||||
keys (prefixed with ``csv_`` if needed).
|
||||
"""
|
||||
row_meta = dict(base_meta)
|
||||
|
||||
# content (strict: content_column must exist; validated by caller)
|
||||
content = self._safe_value(row.get(content_column))
|
||||
|
||||
# merge remaining columns into meta with collision handling
|
||||
for k, v in row.items():
|
||||
if k == content_column:
|
||||
continue
|
||||
key_to_use = k
|
||||
if key_to_use in row_meta:
|
||||
# Avoid clobbering existing meta like file_path/encoding; prefix and de-dupe
|
||||
base_key = f"csv_{key_to_use}"
|
||||
key_to_use = base_key
|
||||
suffix = 1
|
||||
while key_to_use in row_meta:
|
||||
key_to_use = f"{base_key}_{suffix}"
|
||||
suffix += 1
|
||||
row_meta[key_to_use] = self._safe_value(v)
|
||||
|
||||
row_meta["row_number"] = row_index
|
||||
return Document(content=content, meta=row_meta)
|
||||
@@ -0,0 +1,410 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install python-docx'") as docx_import:
|
||||
import docx
|
||||
from docx.document import Document as DocxDocument
|
||||
from docx.table import Table
|
||||
from docx.text.hyperlink import Hyperlink
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.text.run import Run
|
||||
from lxml.etree import _Comment
|
||||
|
||||
|
||||
@dataclass
|
||||
class DOCXMetadata:
|
||||
"""
|
||||
Describes the metadata of Docx file.
|
||||
|
||||
:param author: The author
|
||||
:param category: The category
|
||||
:param comments: The comments
|
||||
:param content_status: The content status
|
||||
:param created: The creation date (ISO formatted string)
|
||||
:param identifier: The identifier
|
||||
:param keywords: Available keywords
|
||||
:param language: The language of the document
|
||||
:param last_modified_by: User who last modified the document
|
||||
:param last_printed: The last printed date (ISO formatted string)
|
||||
:param modified: The last modification date (ISO formatted string)
|
||||
:param revision: The revision number
|
||||
:param subject: The subject
|
||||
:param title: The title
|
||||
:param version: The version
|
||||
"""
|
||||
|
||||
author: str
|
||||
category: str
|
||||
comments: str
|
||||
content_status: str
|
||||
created: str | None
|
||||
identifier: str
|
||||
keywords: str
|
||||
language: str
|
||||
last_modified_by: str
|
||||
last_printed: str | None
|
||||
modified: str | None
|
||||
revision: int
|
||||
subject: str
|
||||
title: str
|
||||
version: str
|
||||
|
||||
|
||||
class DOCXTableFormat(Enum):
|
||||
"""
|
||||
Supported formats for storing DOCX tabular data in a Document.
|
||||
"""
|
||||
|
||||
MARKDOWN = "markdown"
|
||||
CSV = "csv"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "DOCXTableFormat":
|
||||
"""
|
||||
Convert a string to a DOCXTableFormat enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in DOCXTableFormat}
|
||||
table_format = enum_map.get(string.lower())
|
||||
if table_format is None:
|
||||
msg = f"Unknown table format '{string}'. Supported formats are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return table_format
|
||||
|
||||
|
||||
class DOCXLinkFormat(Enum):
|
||||
"""
|
||||
Supported formats for storing DOCX link information in a Document.
|
||||
"""
|
||||
|
||||
MARKDOWN = "markdown"
|
||||
PLAIN = "plain"
|
||||
NONE = "none"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "DOCXLinkFormat":
|
||||
"""
|
||||
Convert a string to a DOCXLinkFormat enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in DOCXLinkFormat}
|
||||
link_format = enum_map.get(string.lower())
|
||||
if link_format is None:
|
||||
msg = f"Unknown link format '{string}'. Supported formats are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return link_format
|
||||
|
||||
|
||||
@component
|
||||
class DOCXToDocument:
|
||||
"""
|
||||
Converts DOCX files to Documents.
|
||||
|
||||
Uses `python-docx` library to convert the DOCX file to a document.
|
||||
This component does not preserve page breaks in the original document.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters.docx import DOCXToDocument, DOCXTableFormat, DOCXLinkFormat
|
||||
from datetime import datetime
|
||||
|
||||
converter = DOCXToDocument(table_format=DOCXTableFormat.CSV, link_format=DOCXLinkFormat.MARKDOWN)
|
||||
results = converter.run(
|
||||
sources=["test/test_files/docx/sample_docx.docx"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is a text from the DOCX file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_format: str | DOCXTableFormat = DOCXTableFormat.CSV,
|
||||
link_format: str | DOCXLinkFormat = DOCXLinkFormat.NONE,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a DOCXToDocument component.
|
||||
|
||||
:param table_format: The format for table output. Can be either DOCXTableFormat.MARKDOWN,
|
||||
DOCXTableFormat.CSV, "markdown", or "csv".
|
||||
:param link_format: The format for link output. Can be either:
|
||||
DOCXLinkFormat.MARKDOWN or "markdown" to get `[text](address)`,
|
||||
DOCXLinkFormat.PLAIN or "plain" to get text (address),
|
||||
DOCXLinkFormat.NONE or "none" to get text without links.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
docx_import.check()
|
||||
self.table_format = DOCXTableFormat.from_str(table_format) if isinstance(table_format, str) else table_format
|
||||
self.link_format = DOCXLinkFormat.from_str(link_format) if isinstance(link_format, str) else link_format
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
table_format=str(self.table_format),
|
||||
link_format=str(self.link_format),
|
||||
store_full_path=self.store_full_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "DOCXToDocument":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
if "table_format" in data["init_parameters"]:
|
||||
data["init_parameters"]["table_format"] = DOCXTableFormat.from_str(data["init_parameters"]["table_format"])
|
||||
if "link_format" in data["init_parameters"]:
|
||||
data["init_parameters"]["link_format"] = DOCXLinkFormat.from_str(data["init_parameters"]["link_format"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts DOCX files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents
|
||||
"""
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
docx_document = docx.Document(io.BytesIO(bytestream.data))
|
||||
elements = self._extract_elements(docx_document)
|
||||
text = "\n".join(elements)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to a DOCX Document, skipping. Error: {error}",
|
||||
source=source,
|
||||
error=e,
|
||||
)
|
||||
continue
|
||||
|
||||
docx_metadata = asdict(self._get_docx_metadata(document=docx_document))
|
||||
merged_metadata = {**bytestream.meta, **metadata, "docx": docx_metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
file_path = bytestream.meta.get("file_path")
|
||||
if file_path: # Ensure the value is not None for mypy
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
def _extract_elements(self, document: "DocxDocument") -> list[str]:
|
||||
"""
|
||||
Extracts elements from a DOCX file.
|
||||
|
||||
:param document: The DOCX Document object.
|
||||
:returns: List of strings (paragraph texts and table representations) with page breaks added as '\f' characters.
|
||||
"""
|
||||
elements = []
|
||||
for element in document.element.body:
|
||||
if isinstance(element, _Comment):
|
||||
continue
|
||||
if element.tag.endswith("p"):
|
||||
paragraph = Paragraph(element, document)
|
||||
if paragraph.contains_page_break:
|
||||
para_text = self._process_paragraph_with_page_breaks(paragraph)
|
||||
else:
|
||||
para_text = self._process_links_in_paragraph(paragraph)
|
||||
elements.append(para_text)
|
||||
elif element.tag.endswith("tbl"):
|
||||
table = docx.table.Table(element, document)
|
||||
table_str = (
|
||||
self._table_to_markdown(table)
|
||||
if self.table_format == DOCXTableFormat.MARKDOWN
|
||||
else self._table_to_csv(table)
|
||||
)
|
||||
elements.append(table_str)
|
||||
|
||||
return elements
|
||||
|
||||
def _process_paragraph_with_page_breaks(self, paragraph: "Paragraph") -> str:
|
||||
"""
|
||||
Processes a paragraph with page breaks.
|
||||
|
||||
:param paragraph: The DOCX paragraph to process.
|
||||
:returns: A string with page breaks added as '\f' characters.
|
||||
"""
|
||||
para_text = ""
|
||||
# Usually, just 1 page break exists, but could be more if paragraph is really long, so we loop over them
|
||||
for pb_index, page_break in enumerate(paragraph.rendered_page_breaks):
|
||||
# Can only extract text from first paragraph page break, unfortunately
|
||||
if pb_index == 0:
|
||||
if page_break.preceding_paragraph_fragment:
|
||||
para_text += self._process_links_in_paragraph(page_break.preceding_paragraph_fragment)
|
||||
para_text += "\f"
|
||||
if page_break.following_paragraph_fragment:
|
||||
# following_paragraph_fragment contains all text for remainder of paragraph.
|
||||
# However, if the remainder of the paragraph spans multiple page breaks, it won't include
|
||||
# those later page breaks so we have to add them at end of text in the `else` block below.
|
||||
# This is not ideal, but this case should be very rare and this is likely good enough.
|
||||
para_text += self._process_links_in_paragraph(page_break.following_paragraph_fragment)
|
||||
else:
|
||||
para_text += "\f"
|
||||
return para_text
|
||||
|
||||
def _process_links_in_paragraph(self, paragraph: "Paragraph") -> str:
|
||||
"""
|
||||
Processes links in a paragraph and formats them according to the specified link format.
|
||||
|
||||
:param paragraph: The DOCX paragraph to process.
|
||||
:returns: A string with links formatted according to the specified format.
|
||||
"""
|
||||
if self.link_format == DOCXLinkFormat.NONE:
|
||||
return paragraph.text
|
||||
text = ""
|
||||
# Iterate over all hyperlinks and other content in the paragraph
|
||||
# https://python-docx.readthedocs.io/en/latest/api/text.html#docx.text.paragraph.Paragraph.iter_inner_content
|
||||
for content in paragraph.iter_inner_content():
|
||||
if isinstance(content, Run):
|
||||
text += content.text
|
||||
elif isinstance(content, Hyperlink):
|
||||
if self.link_format == DOCXLinkFormat.MARKDOWN:
|
||||
formatted_link = f"[{content.text}]({content.address})"
|
||||
else: # PLAIN format
|
||||
formatted_link = f"{content.text} ({content.address})"
|
||||
text += formatted_link
|
||||
|
||||
return text
|
||||
|
||||
def _table_to_markdown(self, table: "Table") -> str:
|
||||
"""
|
||||
Converts a DOCX table to a Markdown string.
|
||||
|
||||
:param table: The DOCX table to convert.
|
||||
:returns: A Markdown string representation of the table.
|
||||
"""
|
||||
markdown: list[str] = []
|
||||
max_col_widths: list[int] = []
|
||||
|
||||
# Calculate max width for each column
|
||||
for row in table.rows:
|
||||
for i, cell in enumerate(row.cells):
|
||||
cell_text = cell.text.strip()
|
||||
if i >= len(max_col_widths):
|
||||
max_col_widths.append(len(cell_text))
|
||||
else:
|
||||
max_col_widths[i] = max(max_col_widths[i], len(cell_text))
|
||||
|
||||
# Process rows
|
||||
for i, row in enumerate(table.rows):
|
||||
md_row = [cell.text.strip().ljust(max_col_widths[j]) for j, cell in enumerate(row.cells)]
|
||||
markdown.append("| " + " | ".join(md_row) + " |")
|
||||
|
||||
# Add separator after header row
|
||||
if i == 0:
|
||||
separator = ["-" * max_col_widths[j] for j in range(len(row.cells))]
|
||||
markdown.append("| " + " | ".join(separator) + " |")
|
||||
|
||||
return "\n".join(markdown)
|
||||
|
||||
def _table_to_csv(self, table: "Table") -> str:
|
||||
"""
|
||||
Converts a DOCX table to a CSV string.
|
||||
|
||||
:param table: The DOCX table to convert.
|
||||
:returns: A CSV string representation of the table.
|
||||
"""
|
||||
csv_output = StringIO()
|
||||
csv_writer = csv.writer(csv_output, quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
# Process rows
|
||||
for row in table.rows:
|
||||
csv_row = [cell.text.strip() for cell in row.cells]
|
||||
csv_writer.writerow(csv_row)
|
||||
|
||||
# Get the CSV as a string and strip any trailing newlines
|
||||
csv_string = csv_output.getvalue().strip()
|
||||
csv_output.close()
|
||||
|
||||
return csv_string
|
||||
|
||||
def _get_docx_metadata(self, document: "DocxDocument") -> DOCXMetadata:
|
||||
"""
|
||||
Get all relevant data from the 'core_properties' attribute from a DOCX Document.
|
||||
|
||||
:param document:
|
||||
The DOCX Document you want to extract metadata from
|
||||
|
||||
:returns:
|
||||
A `DOCXMetadata` dataclass all the relevant fields from the 'core_properties'
|
||||
"""
|
||||
return DOCXMetadata(
|
||||
author=document.core_properties.author,
|
||||
category=document.core_properties.category,
|
||||
comments=document.core_properties.comments,
|
||||
content_status=document.core_properties.content_status,
|
||||
created=(document.core_properties.created.isoformat() if document.core_properties.created else None),
|
||||
identifier=document.core_properties.identifier,
|
||||
keywords=document.core_properties.keywords,
|
||||
language=document.core_properties.language,
|
||||
last_modified_by=document.core_properties.last_modified_by,
|
||||
last_printed=(
|
||||
document.core_properties.last_printed.isoformat() if document.core_properties.last_printed else None
|
||||
),
|
||||
modified=(document.core_properties.modified.isoformat() if document.core_properties.modified else None),
|
||||
revision=document.core_properties.revision,
|
||||
subject=document.core_properties.subject,
|
||||
title=document.core_properties.title,
|
||||
version=document.core_properties.version,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream, FileContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMPTY_BYTE_STRING = b""
|
||||
|
||||
|
||||
@component
|
||||
class FileToFileContent:
|
||||
"""
|
||||
Converts files to FileContent objects to be included in ChatMessage objects.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.converters import FileToFileContent
|
||||
|
||||
converter = FileToFileContent()
|
||||
sources = ["test/test_files/pdf/react_paper.pdf", "test/test_files/images/haystack-logo.png"]
|
||||
file_contents = converter.run(sources=sources)["file_contents"]
|
||||
|
||||
print(file_contents)
|
||||
# >> [FileContent(base64_data='...', mime_type='application/pdf', filename='react_paper.pdf', extra={}),
|
||||
# >> FileContent(base64_data='...', mime_type='image/png', filename='haystack-logo.png', extra={})
|
||||
# >>]
|
||||
```
|
||||
"""
|
||||
|
||||
@component.output_types(file_contents=list[FileContent])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], *, extra: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[FileContent]]:
|
||||
"""
|
||||
Converts files to FileContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param extra:
|
||||
Optional extra information to attach to the FileContent objects. Can be used to store provider-specific
|
||||
information.
|
||||
To avoid serialization issues, values should be JSON serializable.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the extra of all produced FileContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `file_contents`: A list of FileContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"file_contents": []}
|
||||
|
||||
file_contents = []
|
||||
|
||||
extra_list = normalize_metadata(extra, sources_count=len(sources))
|
||||
|
||||
for source, extra_dict in zip(sources, extra_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
filename = source.name if isinstance(source, Path) else None
|
||||
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source, guess_mime_type=True)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
if bytestream.data == _EMPTY_BYTE_STRING:
|
||||
logger.warning("File {source} is empty. Skipping it.", source=source)
|
||||
continue
|
||||
|
||||
base64_data = base64.b64encode(bytestream.data).decode("utf-8")
|
||||
# ``normalize_metadata`` returns the same dict object for every source when ``extra`` is a
|
||||
# single dict (or ``None``), so give each FileContent its own copy. Otherwise mutating one
|
||||
# file's ``extra`` downstream would leak into all the others. The other converters avoid this
|
||||
# implicitly by merging ``extra`` into a fresh ``{**bytestream.meta, ...}`` dict.
|
||||
file_content = FileContent(
|
||||
base64_data=base64_data, mime_type=bytestream.mime_type, filename=filename, extra=dict(extra_dict)
|
||||
)
|
||||
file_contents.append(file_content)
|
||||
|
||||
return {"file_contents": file_contents}
|
||||
@@ -0,0 +1,147 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install trafilatura'") as trafilatura_import:
|
||||
from trafilatura import extract
|
||||
|
||||
|
||||
@component
|
||||
class HTMLToDocument:
|
||||
"""
|
||||
Converts an HTML file to a Document.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.converters import HTMLToDocument
|
||||
|
||||
converter = HTMLToDocument()
|
||||
results = converter.run(sources=["test/test_files/html/paul_graham_superlinear.html"])
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is a text from the HTML file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, extraction_kwargs: dict[str, Any] | None = None, store_full_path: bool = False, encoding: str = "utf-8"
|
||||
) -> None:
|
||||
"""
|
||||
Create an HTMLToDocument component.
|
||||
|
||||
:param extraction_kwargs: A dictionary containing keyword arguments to customize the extraction process. These
|
||||
are passed to the underlying Trafilatura `extract` function. For the full list of available arguments, see
|
||||
the [Trafilatura documentation](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#extract).
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
:param encoding:
|
||||
The default encoding to use when converting HTML files. If the encoding is specified in the metadata of a
|
||||
source ByteStream, it overrides this value.
|
||||
"""
|
||||
trafilatura_import.check()
|
||||
|
||||
self.extraction_kwargs = extraction_kwargs or {}
|
||||
self.store_full_path = store_full_path
|
||||
self.encoding = encoding
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, extraction_kwargs=self.extraction_kwargs, store_full_path=self.store_full_path, encoding=self.encoding
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "HTMLToDocument":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
extraction_kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a list of HTML files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of HTML file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
:param extraction_kwargs:
|
||||
Additional keyword arguments to customize the extraction process.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents
|
||||
"""
|
||||
|
||||
merged_extraction_kwargs = {**self.extraction_kwargs, **(extraction_kwargs or {})}
|
||||
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source=source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
if not bytestream.data:
|
||||
logger.warning("Skipping {source} because it is empty.", source=source)
|
||||
continue
|
||||
|
||||
try:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
text = extract(bytestream.data.decode(encoding), **merged_extraction_kwargs)
|
||||
except Exception as conversion_e:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source,
|
||||
error=conversion_e,
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
file_path = bytestream.meta.get("file_path")
|
||||
if file_path: # Ensure the value is not None for mypy
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,23 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"document_to_image": ["DocumentToImageContent"],
|
||||
"file_to_document": ["ImageFileToDocument"],
|
||||
"file_to_image": ["ImageFileToImageContent"],
|
||||
"pdf_to_image": ["PDFToImageContent"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .document_to_image import DocumentToImageContent as DocumentToImageContent
|
||||
from .file_to_document import ImageFileToDocument as ImageFileToDocument
|
||||
from .file_to_image import ImageFileToImageContent as ImageFileToImageContent
|
||||
from .pdf_to_image import PDFToImageContent as PDFToImageContent
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.image.image_utils import (
|
||||
_batch_convert_pdf_pages_to_images,
|
||||
_encode_image_to_base64,
|
||||
_extract_image_sources_info,
|
||||
_PDFPageInfo,
|
||||
pillow_import,
|
||||
pypdfium2_import,
|
||||
)
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class DocumentToImageContent:
|
||||
"""
|
||||
Converts documents sourced from PDF and image files into ImageContents.
|
||||
|
||||
This component processes a list of documents and extracts visual content from supported file formats, converting
|
||||
them into ImageContents that can be used for multimodal AI tasks. It handles both direct image files and PDF
|
||||
documents by extracting specific pages as images.
|
||||
|
||||
Documents are expected to have metadata containing:
|
||||
- The `file_path_meta_field` key with a valid file path that exists when combined with `root_path`
|
||||
- A supported image format (MIME type must be one of the supported image types)
|
||||
- For PDF files, a `page_number` key specifying which page to extract
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.converters.image.document_to_image import DocumentToImageContent
|
||||
|
||||
converter = DocumentToImageContent(
|
||||
file_path_meta_field="file_path",
|
||||
root_path="test/test_files",
|
||||
detail="high",
|
||||
size=(800, 600)
|
||||
)
|
||||
|
||||
documents = [
|
||||
Document(content="Optional description of apple.jpg", meta={"file_path": "images/apple.jpg"}),
|
||||
Document(
|
||||
content="Optional description of sample_pdf_1.pdf",
|
||||
meta={"file_path": "pdf/sample_pdf_1.pdf", "page_number": 1}
|
||||
)
|
||||
]
|
||||
|
||||
result = converter.run(documents)
|
||||
image_contents = result["image_contents"]
|
||||
# [ImageContent(
|
||||
# base64_image='/9j/4A...', mime_type='image/jpeg', detail='high', meta={'file_path': 'images/apple.jpg'}
|
||||
# ),
|
||||
# ImageContent(
|
||||
# base64_image='/9j/4A...', mime_type='image/jpeg', detail='high',
|
||||
# meta={'file_path': 'pdf/sample_pdf_1.pdf', 'page_number': 1})
|
||||
# )]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
file_path_meta_field: str = "file_path",
|
||||
root_path: str | None = None,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the DocumentToImageContent component.
|
||||
|
||||
:param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF.
|
||||
:param root_path: The root directory path where document files are located. If provided, file paths in
|
||||
document metadata will be resolved relative to this path. If None, file paths are treated as absolute paths.
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). Can be "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
"""
|
||||
pillow_import.check()
|
||||
pypdfium2_import.check()
|
||||
|
||||
self.file_path_meta_field = file_path_meta_field
|
||||
self.root_path = root_path or ""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent | None])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[ImageContent | None]]:
|
||||
"""
|
||||
Convert documents with image or PDF sources into ImageContent objects.
|
||||
|
||||
This method processes the input documents, extracting images from supported file formats and converting them
|
||||
into ImageContent objects.
|
||||
|
||||
:param documents: A list of documents to process. Each document should have metadata containing at minimum
|
||||
a 'file_path_meta_field' key. PDF documents additionally require a 'page_number' key to specify which
|
||||
page to convert.
|
||||
|
||||
:returns:
|
||||
Dictionary containing one key:
|
||||
- "image_contents": ImageContents created from the processed documents. These contain base64-encoded image
|
||||
data and metadata. The order corresponds to order of input documents.
|
||||
:raises ValueError:
|
||||
If any document is missing the required metadata keys, has an invalid file path, or has an unsupported
|
||||
MIME type. The error message will specify which document and what information is missing or incorrect.
|
||||
"""
|
||||
if not documents:
|
||||
return {"image_contents": []}
|
||||
|
||||
images_source_info = _extract_image_sources_info(
|
||||
documents=documents, file_path_meta_field=self.file_path_meta_field, root_path=self.root_path
|
||||
)
|
||||
|
||||
image_contents: list[ImageContent | None] = [None] * len(documents)
|
||||
|
||||
pdf_page_infos: list[_PDFPageInfo] = []
|
||||
|
||||
for doc_idx, image_source_info in enumerate(images_source_info):
|
||||
mime_type = image_source_info["mime_type"]
|
||||
path = image_source_info["path"]
|
||||
if mime_type == "application/pdf":
|
||||
# Store PDF documents for later processing
|
||||
page_number = image_source_info.get("page_number")
|
||||
assert page_number is not None # checked in _extract_image_sources_info but mypy doesn't know that
|
||||
pdf_page_info: _PDFPageInfo = {"doc_idx": doc_idx, "path": path, "page_number": page_number}
|
||||
pdf_page_infos.append(pdf_page_info)
|
||||
else:
|
||||
# Process images directly
|
||||
bytestream = ByteStream.from_file_path(filepath=path, mime_type=mime_type)
|
||||
_, base64_image = _encode_image_to_base64(bytestream=bytestream, size=self.size)
|
||||
image_contents[doc_idx] = ImageContent(
|
||||
base64_image=base64_image,
|
||||
mime_type=mime_type,
|
||||
detail=self.detail,
|
||||
meta={"file_path": documents[doc_idx].meta[self.file_path_meta_field]},
|
||||
)
|
||||
|
||||
# efficiently convert PDF pages to images: each PDF is opened and processed only once
|
||||
pdf_page_infos_by_doc_idx: dict[int, _PDFPageInfo] = {
|
||||
pdf_page_info["doc_idx"]: pdf_page_info for pdf_page_info in pdf_page_infos
|
||||
}
|
||||
pdf_images_by_doc_idx = _batch_convert_pdf_pages_to_images(
|
||||
pdf_page_infos=pdf_page_infos, size=self.size, return_base64=True
|
||||
)
|
||||
for doc_idx, base64_pdf_image in pdf_images_by_doc_idx.items():
|
||||
meta = {
|
||||
"file_path": documents[doc_idx].meta[self.file_path_meta_field],
|
||||
"page_number": pdf_page_infos_by_doc_idx[doc_idx]["page_number"],
|
||||
}
|
||||
# we know that base64_pdf_image is a string because we set return_base64=True but mypy doesn't know that
|
||||
assert isinstance(base64_pdf_image, str)
|
||||
image_contents[doc_idx] = ImageContent(
|
||||
base64_image=base64_pdf_image, mime_type="image/jpeg", detail=self.detail, meta=meta
|
||||
)
|
||||
|
||||
none_image_contents_doc_ids = [
|
||||
documents[doc_idx].id for doc_idx, image_content in enumerate(image_contents) if image_content is None
|
||||
]
|
||||
if none_image_contents_doc_ids:
|
||||
logger.warning(
|
||||
"Conversion failed for some documents. Their output will be None. Document IDs: {document_ids}",
|
||||
document_ids=none_image_contents_doc_ids,
|
||||
)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,98 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class ImageFileToDocument:
|
||||
"""
|
||||
Converts image file references into empty Document objects with associated metadata.
|
||||
|
||||
This component is useful in pipelines where image file paths need to be wrapped in `Document` objects to be
|
||||
processed by downstream components such as the `LLMDocumentContentExtractor` or the
|
||||
`SentenceTransformersDocumentImageEmbedder` (available in the `sentence-transformers-haystack` integration).
|
||||
|
||||
It does **not** extract any content from the image files, instead it creates `Document` objects with `None` as
|
||||
their content and attaches metadata such as file path and any user-provided values.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import ImageFileToDocument
|
||||
|
||||
converter = ImageFileToDocument()
|
||||
|
||||
sources = ["image.jpg", "another_image.png"]
|
||||
|
||||
result = converter.run(sources=sources)
|
||||
documents = result["documents"]
|
||||
|
||||
print(documents)
|
||||
|
||||
# [Document(id=..., meta: {'file_path': 'image.jpg'}),
|
||||
# Document(id=..., meta: {'file_path': 'another_image.png'})]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Initialize the ImageFileToDocument component.
|
||||
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, *, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Convert image files into empty Document objects with metadata.
|
||||
|
||||
This method accepts image file references (as file paths or ByteStreams) and creates `Document` objects
|
||||
without content. These documents are enriched with metadata derived from the input source and optional
|
||||
user-provided metadata.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, its length must match the number of sources, as they are zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: A list of `Document` objects with empty content and associated metadata.
|
||||
"""
|
||||
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=None, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,150 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import mimetypes
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.image.image_utils import _encode_image_to_base64
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport(
|
||||
"The 'size' parameter is set. "
|
||||
"Image resizing will be applied, which requires the Pillow library. "
|
||||
"Run 'pip install pillow'"
|
||||
) as pillow_import:
|
||||
import PIL # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMPTY_BYTE_STRING = b""
|
||||
|
||||
|
||||
@component
|
||||
class ImageFileToImageContent:
|
||||
"""
|
||||
Converts image files to ImageContent objects.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import ImageFileToImageContent
|
||||
|
||||
converter = ImageFileToImageContent()
|
||||
|
||||
sources = ["image.jpg", "another_image.png"]
|
||||
|
||||
image_contents = converter.run(sources=sources)["image_contents"]
|
||||
print(image_contents)
|
||||
|
||||
# [ImageContent(base64_image='...',
|
||||
# mime_type='image/jpeg',
|
||||
# detail=None,
|
||||
# meta={'file_path': 'image.jpg'}),
|
||||
# ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *, detail: Literal["auto", "high", "low"] | None = None, size: tuple[int, int] | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Create the ImageFileToImageContent component.
|
||||
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
"""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
|
||||
if self.size is not None:
|
||||
pillow_import.check()
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> dict[str, list[ImageContent]]:
|
||||
"""
|
||||
Converts files to ImageContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the ImageContent objects.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced ImageContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output ImageContent objects.
|
||||
:param detail:
|
||||
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
If not provided, the detail level will be the one set in the constructor.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
If not provided, the size value will be the one set in the constructor.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `image_contents`: A list of ImageContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"image_contents": []}
|
||||
|
||||
resolved_detail = detail or self.detail
|
||||
resolved_size = size or self.size
|
||||
|
||||
# Check import
|
||||
if resolved_size:
|
||||
pillow_import.check()
|
||||
|
||||
image_contents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
if bytestream.mime_type is None and isinstance(source, Path):
|
||||
bytestream = replace(bytestream, mime_type=mimetypes.guess_type(source.as_posix())[0])
|
||||
|
||||
if bytestream.data == _EMPTY_BYTE_STRING:
|
||||
logger.warning("File {source} is empty. Skipping it.", source=source)
|
||||
continue
|
||||
|
||||
try:
|
||||
inferred_mime_type, base64_image = _encode_image_to_base64(bytestream=bytestream, size=resolved_size)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
image_content = ImageContent(
|
||||
base64_image=base64_image, mime_type=inferred_mime_type, meta=merged_metadata, detail=resolved_detail
|
||||
)
|
||||
image_contents.append(image_content)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,338 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, Union
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from haystack import logging
|
||||
from haystack.dataclasses import ByteStream, Document
|
||||
from haystack.dataclasses.image_content import IMAGE_MIME_TYPES, MIME_TO_FORMAT
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pypdfium2'") as pypdfium2_import:
|
||||
from pypdfium2 import PdfDocument
|
||||
|
||||
with LazyImport("Run 'pip install pillow'") as pillow_import:
|
||||
from PIL import Image as PILImage
|
||||
from PIL.Image import Image
|
||||
from PIL.ImageFile import ImageFile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _encode_image_to_base64(bytestream: ByteStream, size: tuple[int, int] | None = None) -> tuple[str | None, str]:
|
||||
"""
|
||||
Encode an image from a ByteStream into a base64-encoded string.
|
||||
|
||||
Optionally resize the image before encoding to improve performance for downstream processing.
|
||||
|
||||
:param bytestream: ByteStream containing the image data.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
|
||||
:returns:
|
||||
A tuple (mime_type, base64_str), where:
|
||||
- mime_type (Optional[str]): The mime type of the encoded image, determined from the original data or image
|
||||
content. Can be None if the mime type cannot be reliably identified.
|
||||
- base64_str (str): The base64-encoded string representation of the (optionally resized) image.
|
||||
"""
|
||||
if size is None:
|
||||
if bytestream.mime_type is None:
|
||||
logger.warning(
|
||||
"No mime type provided for the image. "
|
||||
"This may cause compatibility issues with downstream systems requiring a specific mime type. "
|
||||
"Please provide a mime type for the image."
|
||||
)
|
||||
return bytestream.mime_type, base64.b64encode(bytestream.data).decode("utf-8")
|
||||
|
||||
# Check the import
|
||||
pillow_import.check()
|
||||
|
||||
# Load the image
|
||||
if bytestream.mime_type and bytestream.mime_type in MIME_TO_FORMAT:
|
||||
formats = [MIME_TO_FORMAT[bytestream.mime_type]]
|
||||
else:
|
||||
formats = None
|
||||
image: "ImageFile" = PILImage.open(BytesIO(bytestream.data), formats=formats)
|
||||
|
||||
# NOTE: We prefer the format returned by PIL
|
||||
inferred_mime_type = image.get_format_mimetype() or bytestream.mime_type
|
||||
|
||||
# Downsize the image in place
|
||||
if size is not None:
|
||||
# Set reducing_gap=None to disable multi-step shrink; better quality.
|
||||
# https://pillow.readthedocs.io/en/latest/reference/Image.html#PIL.Image.Image.thumbnail
|
||||
image.thumbnail(size=size, reducing_gap=None)
|
||||
|
||||
# Convert the image to base64 string
|
||||
if not inferred_mime_type:
|
||||
logger.warning(
|
||||
"Could not determine mime type for image. Defaulting to 'image/jpeg'. "
|
||||
"Consider providing a mime_type parameter."
|
||||
)
|
||||
inferred_mime_type = "image/jpeg"
|
||||
return inferred_mime_type, _encode_pil_image_to_base64(image=image, mime_type=inferred_mime_type)
|
||||
|
||||
|
||||
def _encode_pil_image_to_base64(image: Union["Image", "ImageFile"], mime_type: str = "image/jpeg") -> str:
|
||||
"""
|
||||
Convert a PIL Image object to a base64-encoded string.
|
||||
|
||||
Automatically converts images with transparency to RGB if saving as JPEG.
|
||||
|
||||
:param image: A PIL Image or ImageFile object to encode.
|
||||
:param mime_type: The MIME type to use when encoding the image. Defaults to "image/jpeg".
|
||||
:returns:
|
||||
Base64-encoded string representing the image.
|
||||
"""
|
||||
# Check the import
|
||||
pillow_import.check()
|
||||
|
||||
# Convert image to RGB if it has an alpha channel and we are saving as JPEG
|
||||
if (mime_type == "image/jpeg" or mime_type == "image/jpg") and (
|
||||
image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||
):
|
||||
image = image.convert("RGB")
|
||||
|
||||
buffered = BytesIO()
|
||||
form = MIME_TO_FORMAT.get(mime_type)
|
||||
if form is None:
|
||||
logger.warning("Could not determine format for mime type {mime_type}. Defaulting to JPEG.", mime_type=mime_type)
|
||||
form = "JPEG"
|
||||
image.save(buffered, format=form)
|
||||
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _convert_pdf_to_images(
|
||||
*,
|
||||
bytestream: ByteStream,
|
||||
return_base64: bool = False,
|
||||
page_range: list[int] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> list[tuple[int, "Image"]] | list[tuple[int, str]]:
|
||||
"""
|
||||
Convert a PDF file into a list of PIL Image objects or base64-encoded images.
|
||||
|
||||
Checks PDF dimensions and adjusts size constraints based on aspect ratio.
|
||||
|
||||
:param bytestream: ByteStream object containing the PDF data
|
||||
:param return_base64: If True, return base64-encoded images instead of PIL images.
|
||||
:param page_range: List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
|
||||
:returns:
|
||||
A list of tuples, each tuple containing the page number and the PIL Image object or base64-encoded image string.
|
||||
"""
|
||||
|
||||
pypdfium2_import.check()
|
||||
pillow_import.check()
|
||||
|
||||
try:
|
||||
pdf = PdfDocument(BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read PDF file {file_path}. Skipping it. Error: {error}",
|
||||
file_path=bytestream.meta.get("file_path"),
|
||||
error=e,
|
||||
)
|
||||
return []
|
||||
|
||||
num_pages = len(pdf)
|
||||
if num_pages == 0:
|
||||
logger.warning("PDF file is empty: {file_path}", file_path=bytestream.meta.get("file_path"))
|
||||
pdf.close()
|
||||
return []
|
||||
|
||||
all_pdf_images = []
|
||||
|
||||
resolved_page_range = page_range or range(1, num_pages + 1)
|
||||
|
||||
for page_number in resolved_page_range:
|
||||
if page_number < 1 or page_number > num_pages:
|
||||
logger.warning("Page {page_number} is out of range for the PDF file. Skipping it.", page_number=page_number)
|
||||
continue
|
||||
|
||||
# Get dimensions of the page
|
||||
page = pdf[max(page_number - 1, 0)] # Adjust for 0-based indexing
|
||||
_, _, width, height = page.get_mediabox()
|
||||
|
||||
target_resolution_dpi = 300.0
|
||||
|
||||
# From pypdfium2 docs: scale (float) – A factor scaling the number of pixels per PDF canvas unit. This defines
|
||||
# the resolution of the image. To convert a DPI value to a scale factor, multiply it by the size of 1 canvas
|
||||
# unit in inches (usually 1/72in).
|
||||
# https://pypdfium2.readthedocs.io/en/stable/python_api.html#pypdfium2._helpers.page.PdfPage.render
|
||||
target_scale = target_resolution_dpi / 72.0
|
||||
|
||||
# Calculate potential pixels for target_dpi
|
||||
pixels_for_target_scale = width * height * target_scale**2
|
||||
|
||||
pil_max_pixels = PILImage.MAX_IMAGE_PIXELS or int(1024 * 1024 * 1024 // 4 // 3)
|
||||
# 90% of PIL's default limit to prevent borderline cases
|
||||
pixel_limit = pil_max_pixels * 0.9
|
||||
|
||||
scale = target_scale
|
||||
if pixels_for_target_scale > pixel_limit:
|
||||
logger.info(
|
||||
"Large PDF detected ({pixels:.2f} pixels). Resizing the image to fit the pixel limit.",
|
||||
pixels=pixels_for_target_scale,
|
||||
)
|
||||
scale = (pixel_limit / (width * height)) ** 0.5
|
||||
|
||||
pdf_bitmap = page.render(scale=scale)
|
||||
|
||||
image: "Image" = pdf_bitmap.to_pil()
|
||||
pdf_bitmap.close()
|
||||
if size is not None:
|
||||
# Set reducing_gap=None to disable multi-step shrink; better quality.
|
||||
# https://pillow.readthedocs.io/en/latest/reference/Image.html#PIL.Image.Image.thumbnail
|
||||
image.thumbnail(size=size, reducing_gap=None)
|
||||
|
||||
all_pdf_images.append((page_number, image))
|
||||
|
||||
pdf.close()
|
||||
|
||||
if return_base64:
|
||||
return [
|
||||
(page_number, _encode_pil_image_to_base64(image, mime_type="image/jpeg"))
|
||||
for page_number, image in all_pdf_images
|
||||
]
|
||||
|
||||
return all_pdf_images
|
||||
|
||||
|
||||
class _ImageSourceInfo(TypedDict):
|
||||
path: Path
|
||||
mime_type: str | None
|
||||
page_number: NotRequired[int] # Only present for PDF documents
|
||||
|
||||
|
||||
def _extract_image_sources_info(
|
||||
documents: list[Document], file_path_meta_field: str, root_path: str
|
||||
) -> list[_ImageSourceInfo]:
|
||||
"""
|
||||
Extracts the image source information from the documents.
|
||||
|
||||
:param documents: List of documents to extract image source information from.
|
||||
:param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF.
|
||||
:param root_path: The root directory path where document files are located.
|
||||
|
||||
:returns:
|
||||
A list of _ImageSourceInfo dictionaries, each containing the path and type of the image.
|
||||
If the image is a PDF, the dictionary also contains the page number.
|
||||
:raises ValueError: If the document is missing the file_path_meta_field key in its metadata, the file path is
|
||||
invalid, the MIME type is not supported, or the page number is missing for a PDF document.
|
||||
"""
|
||||
images_source_info: list[_ImageSourceInfo] = []
|
||||
for doc in documents:
|
||||
file_path = doc.meta.get(file_path_meta_field)
|
||||
if file_path is None:
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' is missing the '{file_path_meta_field}' key in its metadata."
|
||||
f" Please ensure that the documents you are trying to convert have this key set."
|
||||
)
|
||||
|
||||
resolved_file_path = Path(root_path, file_path)
|
||||
|
||||
# When root_path is set, ensure the resolved path stays within it to block path-traversal
|
||||
# payloads (e.g. "../../etc/passwd") coming from document metadata.
|
||||
if root_path:
|
||||
resolved_file_path = resolved_file_path.resolve()
|
||||
resolved_root = Path(root_path).resolve()
|
||||
if not resolved_file_path.is_relative_to(resolved_root):
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' has a file path '{file_path}' that escapes the "
|
||||
f"configured root '{root_path}'. Resolved path: '{resolved_file_path}'."
|
||||
)
|
||||
|
||||
if not resolved_file_path.is_file():
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' has an invalid file path '{resolved_file_path}'. "
|
||||
f"Please ensure that the documents you are trying to convert have valid file paths."
|
||||
)
|
||||
|
||||
mime_type = doc.meta.get("mime_type") or mimetypes.guess_type(resolved_file_path)[0]
|
||||
if mime_type not in IMAGE_MIME_TYPES:
|
||||
raise ValueError(
|
||||
f"Document with file path '{resolved_file_path}' has an unsupported MIME type '{mime_type}'. "
|
||||
f"Please ensure that the documents you are trying to convert are of the supported "
|
||||
f"types: {', '.join(IMAGE_MIME_TYPES)}."
|
||||
)
|
||||
|
||||
image_info: _ImageSourceInfo = {"path": resolved_file_path, "mime_type": mime_type}
|
||||
|
||||
# If mimetype is PDF we also need the page number to be able to convert the right page
|
||||
if mime_type == "application/pdf":
|
||||
page_number = doc.meta.get("page_number")
|
||||
if page_number is None:
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' comes from the PDF file '{resolved_file_path}' but is missing "
|
||||
f"the 'page_number' key in its metadata. Please ensure that PDF documents you are trying to "
|
||||
f"convert have this key set."
|
||||
)
|
||||
image_info["page_number"] = page_number
|
||||
|
||||
images_source_info.append(image_info)
|
||||
|
||||
return images_source_info
|
||||
|
||||
|
||||
class _PDFPageInfo(TypedDict):
|
||||
doc_idx: int
|
||||
path: Path
|
||||
page_number: int
|
||||
|
||||
|
||||
def _batch_convert_pdf_pages_to_images(
|
||||
*, pdf_page_infos: list[_PDFPageInfo], return_base64: bool = False, size: tuple[int, int] | None = None
|
||||
) -> dict[int, str] | dict[int, "Image"]:
|
||||
"""
|
||||
Converts selected PDF pages to images, returning a mapping from document indices to images (PIL or base64).
|
||||
|
||||
Pages are grouped by file path to ensure each PDF is opened and processed only once for efficiency.
|
||||
|
||||
:param pdf_page_infos: List of _PDFPageInfo dictionaries with doc_idx, path, and page_number.
|
||||
:param size: Optional tuple of width and height to resize the images to.
|
||||
:param return_base64: If True, return base64 encoded images instead of PIL images.
|
||||
|
||||
:returns: Dictionary mapping document indices to images (PIL.Image or base64 string).
|
||||
"""
|
||||
if not pdf_page_infos:
|
||||
return {}
|
||||
|
||||
page_infos_by_pdf_path = defaultdict(list)
|
||||
for page_info in pdf_page_infos:
|
||||
page_infos_by_pdf_path[page_info["path"]].append(page_info)
|
||||
|
||||
converted_images_by_doc_index = {}
|
||||
|
||||
for pdf_path, page_infos_for_pdf in page_infos_by_pdf_path.items():
|
||||
page_numbers_to_convert = [info["page_number"] for info in page_infos_for_pdf]
|
||||
bytestream = ByteStream.from_file_path(pdf_path)
|
||||
|
||||
converted_pages = _convert_pdf_to_images(
|
||||
bytestream=bytestream, return_base64=return_base64, page_range=page_numbers_to_convert, size=size
|
||||
)
|
||||
|
||||
# Map results back to document indices
|
||||
page_number_to_image = dict(converted_pages)
|
||||
for page_info in page_infos_for_pdf:
|
||||
converted_images_by_doc_index[page_info["doc_idx"]] = page_number_to_image[page_info["page_number"]]
|
||||
|
||||
# mypy is not able to infer that we match the declared return type
|
||||
return converted_images_by_doc_index # type: ignore[return-value]
|
||||
@@ -0,0 +1,155 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.image.image_utils import _convert_pdf_to_images, pillow_import, pypdfium2_import
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
from haystack.utils import expand_page_range
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class PDFToImageContent:
|
||||
"""
|
||||
Converts PDF files to ImageContent objects.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import PDFToImageContent
|
||||
|
||||
converter = PDFToImageContent()
|
||||
|
||||
sources = ["file.pdf", "another_file.pdf"]
|
||||
|
||||
image_contents = converter.run(sources=sources)["image_contents"]
|
||||
print(image_contents)
|
||||
|
||||
# [ImageContent(base64_image='...',
|
||||
# mime_type='application/pdf',
|
||||
# detail=None,
|
||||
# meta={'file_path': 'file.pdf', 'page_number': 1}),
|
||||
# ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
page_range: list[str | int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create the PDFToImageContent component.
|
||||
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
:param page_range: List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
"""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
self.page_range = page_range
|
||||
pypdfium2_import.check()
|
||||
pillow_import.check()
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
page_range: list[str | int] | None = None,
|
||||
) -> dict[str, list[ImageContent]]:
|
||||
"""
|
||||
Converts files to ImageContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the ImageContent objects.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced ImageContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output ImageContent objects.
|
||||
:param detail:
|
||||
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
If not provided, the detail level will be the one set in the constructor.
|
||||
:param size:
|
||||
If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
If not provided, the size value will be the one set in the constructor.
|
||||
:param page_range:
|
||||
List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
If not provided, the page_range value will be the one set in the constructor.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `image_contents`: A list of ImageContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"image_contents": []}
|
||||
|
||||
resolved_detail = detail or self.detail
|
||||
resolved_size = size or self.size
|
||||
resolved_page_range = page_range or self.page_range
|
||||
|
||||
expanded_page_range = expand_page_range(resolved_page_range) if resolved_page_range else None
|
||||
|
||||
image_contents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
page_num_and_base64_images = _convert_pdf_to_images(
|
||||
bytestream=bytestream, page_range=expanded_page_range, size=resolved_size, return_base64=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
for page_number, image in page_num_and_base64_images:
|
||||
per_page_metadata = {**merged_metadata, "page_number": page_number}
|
||||
# we already know that image is a string because we set return_base64=True but mypy doesn't know that
|
||||
assert isinstance(image, str)
|
||||
image_contents.append(
|
||||
ImageContent(
|
||||
base64_image=image, mime_type="image/jpeg", meta=per_page_metadata, detail=resolved_detail
|
||||
)
|
||||
)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,289 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream, Document
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install jq'") as jq_import:
|
||||
import jq
|
||||
|
||||
|
||||
@component
|
||||
class JSONConverter:
|
||||
"""
|
||||
Converts one or more JSON files into a text document.
|
||||
|
||||
### Usage examples
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack.components.converters import JSONConverter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
source = ByteStream.from_string(json.dumps({"text": "This is the content of my document"}))
|
||||
|
||||
converter = JSONConverter(content_key="text")
|
||||
results = converter.run(sources=[source])
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'This is the content of my document'
|
||||
```
|
||||
|
||||
Optionally, you can also provide a `jq_schema` string to filter the JSON source files and `extra_meta_fields`
|
||||
to extract from the filtered data:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack.components.converters import JSONConverter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
data = {
|
||||
"laureates": [
|
||||
{
|
||||
"firstname": "Enrico",
|
||||
"surname": "Fermi",
|
||||
"motivation": "for his demonstrations of the existence of new radioactive elements produced "
|
||||
"by neutron irradiation, and for his related discovery of nuclear reactions brought about by"
|
||||
" slow neutrons",
|
||||
},
|
||||
{
|
||||
"firstname": "Rita",
|
||||
"surname": "Levi-Montalcini",
|
||||
"motivation": "for their discoveries of growth factors",
|
||||
},
|
||||
],
|
||||
}
|
||||
source = ByteStream.from_string(json.dumps(data))
|
||||
converter = JSONConverter(
|
||||
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
|
||||
)
|
||||
|
||||
results = converter.run(sources=[source])
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'for his demonstrations of the existence of new radioactive elements produced by
|
||||
# neutron irradiation, and for his related discovery of nuclear reactions brought
|
||||
# about by slow neutrons'
|
||||
|
||||
print(documents[0].meta)
|
||||
# {'firstname': 'Enrico', 'surname': 'Fermi'}
|
||||
|
||||
print(documents[1].content)
|
||||
# 'for their discoveries of growth factors'
|
||||
|
||||
print(documents[1].meta)
|
||||
# {'firstname': 'Rita', 'surname': 'Levi-Montalcini'}
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jq_schema: str | None = None,
|
||||
content_key: str | None = None,
|
||||
extra_meta_fields: set[str] | Literal["*"] | None = None,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a JSONConverter component.
|
||||
|
||||
An optional `jq_schema` can be provided to extract nested data in the JSON source files.
|
||||
See the [official jq documentation](https://jqlang.github.io/jq/) for more info on the filters syntax.
|
||||
If `jq_schema` is not set, whole JSON source files will be used to extract content.
|
||||
|
||||
Optionally, you can provide a `content_key` to specify which key in the extracted object must
|
||||
be set as the document's content.
|
||||
|
||||
If both `jq_schema` and `content_key` are set, the component will search for the `content_key` in
|
||||
the JSON object extracted by `jq_schema`. If the extracted data is not a JSON object, it will be skipped.
|
||||
|
||||
If only `jq_schema` is set, the extracted data must be a scalar value. If it's a JSON object or array,
|
||||
it will be skipped.
|
||||
|
||||
If only `content_key` is set, the source JSON file must be a JSON object, else it will be skipped.
|
||||
|
||||
`extra_meta_fields` can either be set to a set of strings or a literal `"*"` string.
|
||||
If it's a set of strings, it must specify fields in the extracted objects that must be set in
|
||||
the extracted documents. If a field is not found, the meta value will be `None`.
|
||||
If set to `"*"`, all fields that are not `content_key` found in the filtered JSON object will
|
||||
be saved as metadata.
|
||||
|
||||
Initialization will fail if neither `jq_schema` nor `content_key` are set.
|
||||
|
||||
:param jq_schema:
|
||||
Optional jq filter string to extract content.
|
||||
If not specified, whole JSON object will be used to extract information.
|
||||
:param content_key:
|
||||
Optional key to extract document content.
|
||||
If `jq_schema` is specified, the `content_key` will be extracted from that object.
|
||||
:param extra_meta_fields:
|
||||
An optional set of meta keys to extract from the content.
|
||||
If `jq_schema` is specified, all keys will be extracted from that object.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
self._compiled_filter = None
|
||||
if jq_schema:
|
||||
jq_import.check()
|
||||
self._compiled_filter = jq.compile(jq_schema)
|
||||
|
||||
self._jq_schema = jq_schema
|
||||
self._content_key = content_key
|
||||
self._meta_fields = extra_meta_fields
|
||||
self._store_full_path = store_full_path
|
||||
|
||||
if self._compiled_filter is None and self._content_key is None:
|
||||
msg = "No `jq_schema` nor `content_key` specified. Set either or both to extract data."
|
||||
raise ValueError(msg)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
jq_schema=self._jq_schema,
|
||||
content_key=self._content_key,
|
||||
extra_meta_fields=self._meta_fields,
|
||||
store_full_path=self._store_full_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "JSONConverter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _get_content_and_meta(self, source: ByteStream) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Utility function to extract text and metadata from a JSON file.
|
||||
|
||||
:param source:
|
||||
UTF-8 byte stream.
|
||||
:returns:
|
||||
Collection of text and metadata dict tuples, each corresponding
|
||||
to a different document.
|
||||
"""
|
||||
try:
|
||||
file_content = source.data.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source.meta["file_path"],
|
||||
error=exc,
|
||||
)
|
||||
return []
|
||||
|
||||
meta_fields = self._meta_fields or set()
|
||||
|
||||
if self._compiled_filter is not None:
|
||||
try:
|
||||
objects = list(self._compiled_filter.input_text(file_content))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source.meta["file_path"],
|
||||
error=exc,
|
||||
)
|
||||
return []
|
||||
else:
|
||||
# We just load the whole file as JSON if the user didn't provide a jq filter.
|
||||
# We put it in a list even if it's not to ease handling it later on.
|
||||
objects = [json.loads(file_content)]
|
||||
|
||||
result = []
|
||||
if self._content_key is not None:
|
||||
for obj in objects:
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning("Expected a dictionary but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
if self._content_key not in obj:
|
||||
logger.warning(
|
||||
"'{content_key}' not found in {obj}. Skipping it.", content_key=self._content_key, obj=obj
|
||||
)
|
||||
continue
|
||||
|
||||
text = obj[self._content_key]
|
||||
if isinstance(text, (dict, list)):
|
||||
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
|
||||
meta = {}
|
||||
if meta_fields == "*":
|
||||
meta = {k: v for k, v in obj.items() if k != self._content_key}
|
||||
else:
|
||||
for field in meta_fields:
|
||||
meta[field] = obj.get(field, None)
|
||||
result.append((text, meta))
|
||||
else:
|
||||
for obj in objects:
|
||||
if isinstance(obj, (dict, list)):
|
||||
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
result.append((str(obj), {}))
|
||||
|
||||
return result
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a list of JSON files to documents.
|
||||
|
||||
:param sources:
|
||||
A list of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, the length of the list must match the number of sources.
|
||||
If `sources` contain ByteStream objects, their `meta` will be added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of created documents.
|
||||
"""
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=exc)
|
||||
continue
|
||||
|
||||
data = self._get_content_and_meta(bytestream)
|
||||
|
||||
for text, extra_meta in data:
|
||||
merged_metadata = {**bytestream.meta, **metadata, **extra_meta}
|
||||
|
||||
if not self._store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from tqdm import tqdm
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install markdown-it-py mdit_plain'") as markdown_conversion_imports:
|
||||
from markdown_it import MarkdownIt
|
||||
from mdit_plain.renderer import RendererPlain
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FRONTMATTER_PATTERN = re.compile(r"\A---[ \t]*\r?\n(?P<frontmatter>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|$)", re.DOTALL)
|
||||
|
||||
|
||||
@component
|
||||
class MarkdownToDocument:
|
||||
"""
|
||||
Converts a Markdown file into a text Document.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters import MarkdownToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = MarkdownToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/markdown/sample.md"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'This is a text from the markdown file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_to_single_line: bool = False,
|
||||
progress_bar: bool = True,
|
||||
store_full_path: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
*,
|
||||
extract_frontmatter: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a MarkdownToDocument component.
|
||||
|
||||
:param table_to_single_line:
|
||||
If True converts table contents into a single line.
|
||||
:param progress_bar:
|
||||
If True shows a progress bar when running.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
:param encoding:
|
||||
The default encoding to use when converting Markdown files. If the encoding is specified in the metadata
|
||||
of a source ByteStream, it overrides this value.
|
||||
:param extract_frontmatter:
|
||||
If True, YAML frontmatter at the beginning of the Markdown file is
|
||||
removed from the document content and added to the document metadata.
|
||||
"""
|
||||
markdown_conversion_imports.check()
|
||||
|
||||
self.table_to_single_line = table_to_single_line
|
||||
self.progress_bar = progress_bar
|
||||
self.store_full_path = store_full_path
|
||||
self.encoding = encoding
|
||||
self.extract_frontmatter = extract_frontmatter
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a list of Markdown files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: List of created Documents
|
||||
"""
|
||||
parser = MarkdownIt(renderer_cls=RendererPlain)
|
||||
if self.table_to_single_line:
|
||||
parser.enable("table")
|
||||
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in tqdm(
|
||||
zip(sources, meta_list, strict=True),
|
||||
total=len(sources),
|
||||
desc="Converting markdown files to Documents",
|
||||
disable=not self.progress_bar,
|
||||
):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
file_content = bytestream.data.decode(encoding)
|
||||
file_content, frontmatter = self._extract_frontmatter(file_content, source)
|
||||
text = parser.render(file_content)
|
||||
except Exception as conversion_e:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source,
|
||||
error=conversion_e,
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **frontmatter, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
def _extract_frontmatter(self, file_content: str, source: str | Path | ByteStream) -> tuple[str, dict[str, Any]]:
|
||||
if not self.extract_frontmatter:
|
||||
return file_content, {}
|
||||
|
||||
match = _FRONTMATTER_PATTERN.match(file_content)
|
||||
if not match:
|
||||
return file_content, {}
|
||||
|
||||
frontmatter_text = match.group("frontmatter")
|
||||
try:
|
||||
frontmatter = json.loads(json.dumps(yaml.safe_load(frontmatter_text), default=str)) or {}
|
||||
except yaml.YAMLError as error:
|
||||
logger.warning(
|
||||
"Could not parse YAML frontmatter in {source}. Keeping it as content. Error: {error}",
|
||||
source=source,
|
||||
error=error,
|
||||
)
|
||||
return file_content, {}
|
||||
except (TypeError, ValueError) as error:
|
||||
logger.warning(
|
||||
"Could not convert YAML frontmatter in {source}. Keeping it as content. Error: {error}",
|
||||
source=source,
|
||||
error=error,
|
||||
)
|
||||
return file_content, {}
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
logger.warning(
|
||||
"Ignoring YAML frontmatter in {source}: expected a mapping, got {kind}.",
|
||||
source=source,
|
||||
kind=type(frontmatter).__name__,
|
||||
)
|
||||
return file_content, {}
|
||||
|
||||
return file_content[match.end() :], frontmatter
|
||||
@@ -0,0 +1,192 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install python-oxmsg'") as oxmsg_import:
|
||||
from oxmsg import Message, recipient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class MSGToDocument:
|
||||
"""
|
||||
Converts Microsoft Outlook .msg files into Haystack Documents.
|
||||
|
||||
This component extracts email metadata (such as sender, recipients, CC, BCC, subject) and body content from .msg
|
||||
files and converts them into structured Haystack Documents. Additionally, any file attachments within the .msg
|
||||
file are extracted as ByteStream objects.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from haystack.components.converters.msg import MSGToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = MSGToDocument()
|
||||
results = converter.run(sources=["test/test_files/msg/sample.msg"], meta={"date_added": datetime.now().isoformat()})
|
||||
documents = results["documents"]
|
||||
attachments = results["attachments"]
|
||||
print(documents[0].content)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Creates a MSGToDocument component.
|
||||
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
oxmsg_import.check()
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@staticmethod
|
||||
def _is_encrypted(msg: "Message") -> bool:
|
||||
"""
|
||||
Determines whether the provided MSG file is encrypted.
|
||||
|
||||
:param msg: The MSG file as a parsed Message object.
|
||||
:returns: True if the MSG file is encrypted, otherwise False.
|
||||
"""
|
||||
return "encrypted" in msg.message_headers.get("Content-Type", "")
|
||||
|
||||
@staticmethod
|
||||
def _create_recipient_str(recip: "recipient.Recipient") -> str:
|
||||
"""
|
||||
Formats a recipient's name and email into a single string.
|
||||
|
||||
:param recip: A recipient object extracted from the MSG file.
|
||||
:returns: A formatted string combining the recipient's name and email address.
|
||||
"""
|
||||
recip_str = ""
|
||||
if recip.name != "":
|
||||
recip_str += f"{recip.name} "
|
||||
if recip.email_address != "":
|
||||
recip_str += f"{recip.email_address}"
|
||||
return recip_str
|
||||
|
||||
def _convert(self, file_content: io.BytesIO) -> tuple[str, list[ByteStream]]:
|
||||
"""
|
||||
Converts the MSG file content into text and extracts any attachments.
|
||||
|
||||
:param file_content: The MSG file content as a binary stream.
|
||||
:returns: A tuple containing the extracted email text and a list of ByteStream objects for attachments.
|
||||
:raises ValueError: If the MSG file is encrypted and cannot be read.
|
||||
"""
|
||||
msg = Message.load(file_content)
|
||||
if self._is_encrypted(msg):
|
||||
raise ValueError("The MSG file is encrypted and cannot be read.")
|
||||
|
||||
txt = ""
|
||||
|
||||
# Sender
|
||||
if msg.sender is not None:
|
||||
txt += f"From: {msg.sender}\n"
|
||||
|
||||
# To
|
||||
recipients_str = ",".join(self._create_recipient_str(r) for r in msg.recipients)
|
||||
if recipients_str != "":
|
||||
txt += f"To: {recipients_str}\n"
|
||||
|
||||
# CC
|
||||
cc_header = msg.message_headers.get("Cc") or msg.message_headers.get("CC")
|
||||
if cc_header is not None:
|
||||
txt += f"Cc: {cc_header}\n"
|
||||
|
||||
# BCC
|
||||
bcc_header = msg.message_headers.get("Bcc") or msg.message_headers.get("BCC")
|
||||
if bcc_header is not None:
|
||||
txt += f"Bcc: {bcc_header}\n"
|
||||
|
||||
# Subject
|
||||
if msg.subject != "":
|
||||
txt += f"Subject: {msg.subject}\n"
|
||||
|
||||
# Body
|
||||
if msg.body is not None:
|
||||
txt += "\n" + msg.body
|
||||
|
||||
# attachments
|
||||
attachments = [
|
||||
ByteStream(
|
||||
data=attachment.file_bytes, meta={"file_path": attachment.file_name}, mime_type=attachment.mime_type
|
||||
)
|
||||
for attachment in msg.attachments
|
||||
if attachment.file_bytes is not None
|
||||
]
|
||||
|
||||
return txt, attachments
|
||||
|
||||
@component.output_types(documents=list[Document], attachments=list[ByteStream])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document] | list[ByteStream]]:
|
||||
"""
|
||||
Converts MSG files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents.
|
||||
- `attachments`: Created ByteStream objects from file attachments.
|
||||
"""
|
||||
if len(sources) == 0:
|
||||
return {"documents": [], "attachments": []}
|
||||
|
||||
documents = []
|
||||
all_attachments = []
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
text, attachments = self._convert(io.BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
merged_metadata["file_path"] = os.path.basename(bytestream.meta["file_path"])
|
||||
|
||||
documents.append(Document(content=text, meta=merged_metadata))
|
||||
for attachment in attachments:
|
||||
attachment_meta = {
|
||||
**merged_metadata,
|
||||
"parent_file_path": merged_metadata["file_path"],
|
||||
"file_path": attachment.meta["file_path"],
|
||||
}
|
||||
all_attachments.append(
|
||||
ByteStream(data=attachment.data, meta=attachment_meta, mime_type=attachment.mime_type)
|
||||
)
|
||||
|
||||
return {"documents": documents, "attachments": all_attachments}
|
||||
@@ -0,0 +1,133 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from haystack import Document, Pipeline, super_component
|
||||
from haystack.components.converters import (
|
||||
CSVToDocument,
|
||||
DOCXToDocument,
|
||||
HTMLToDocument,
|
||||
JSONConverter,
|
||||
PPTXToDocument,
|
||||
PyPDFToDocument,
|
||||
TextFileToDocument,
|
||||
XLSXToDocument,
|
||||
)
|
||||
from haystack.components.joiners import DocumentJoiner
|
||||
from haystack.components.routers import FileTypeRouter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class ConverterMimeType(str, Enum):
|
||||
CSV = "text/csv"
|
||||
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
HTML = "text/html"
|
||||
JSON = "application/json"
|
||||
MD = "text/markdown"
|
||||
TEXT = "text/plain"
|
||||
PDF = "application/pdf"
|
||||
PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
|
||||
@super_component
|
||||
class MultiFileConverter:
|
||||
"""
|
||||
A file converter that handles conversion of multiple file types.
|
||||
|
||||
The MultiFileConverter handles the following file types:
|
||||
- CSV
|
||||
- DOCX
|
||||
- HTML
|
||||
- JSON
|
||||
- MD
|
||||
- TEXT
|
||||
- PDF (no OCR)
|
||||
- PPTX
|
||||
- XLSX
|
||||
|
||||
Usage example:
|
||||
```
|
||||
from haystack.super_components.converters import MultiFileConverter
|
||||
|
||||
converter = MultiFileConverter()
|
||||
converter.run(sources=["test/test_files/txt/doc_1.txt", "test/test_files/pdf/sample_pdf_1.pdf"], meta={})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", json_content_key: str = "content") -> None:
|
||||
"""
|
||||
Initialize the MultiFileConverter.
|
||||
|
||||
:param encoding: The encoding to use when reading files.
|
||||
:param json_content_key: The key to use in a content field in a document when converting JSON files.
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.json_content_key = json_content_key
|
||||
|
||||
# initialize components
|
||||
router = FileTypeRouter(
|
||||
mime_types=[mime_type.value for mime_type in ConverterMimeType],
|
||||
# Ensure common extensions are registered. Tests on Windows fail otherwise.
|
||||
additional_mimetypes={
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
},
|
||||
)
|
||||
|
||||
# Create pipeline and add components
|
||||
pp = Pipeline()
|
||||
|
||||
pp.add_component("router", router)
|
||||
pp.add_component("docx", DOCXToDocument(link_format="markdown"))
|
||||
pp.add_component(
|
||||
"html",
|
||||
HTMLToDocument(
|
||||
extraction_kwargs={"output_format": "markdown", "include_tables": True, "include_links": True}
|
||||
),
|
||||
)
|
||||
pp.add_component("json", JSONConverter(content_key=self.json_content_key))
|
||||
pp.add_component("md", TextFileToDocument(encoding=self.encoding))
|
||||
pp.add_component("text", TextFileToDocument(encoding=self.encoding))
|
||||
pp.add_component("pdf", PyPDFToDocument())
|
||||
pp.add_component("pptx", PPTXToDocument())
|
||||
pp.add_component("xlsx", XLSXToDocument())
|
||||
pp.add_component("joiner", DocumentJoiner())
|
||||
pp.add_component("csv", CSVToDocument(encoding=self.encoding))
|
||||
|
||||
for mime_type in ConverterMimeType:
|
||||
pp.connect(f"router.{mime_type.value}", str(mime_type).lower().rsplit(".", maxsplit=1)[-1])
|
||||
|
||||
pp.connect("docx.documents", "joiner.documents")
|
||||
pp.connect("html.documents", "joiner.documents")
|
||||
pp.connect("json.documents", "joiner.documents")
|
||||
pp.connect("md.documents", "joiner.documents")
|
||||
pp.connect("text.documents", "joiner.documents")
|
||||
pp.connect("pdf.documents", "joiner.documents")
|
||||
pp.connect("pptx.documents", "joiner.documents")
|
||||
|
||||
pp.connect("csv.documents", "joiner.documents")
|
||||
pp.connect("xlsx.documents", "joiner.documents")
|
||||
|
||||
self.pipeline = pp
|
||||
self.output_mapping = {
|
||||
"joiner.documents": "documents",
|
||||
"router.unclassified": "unclassified",
|
||||
"router.failed": "failed",
|
||||
}
|
||||
self.input_mapping = {"sources": ["router.sources"], "meta": ["router.meta"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# fake method, never executed, but static analyzers will not complain about missing method
|
||||
def run( # noqa: D102
|
||||
self, *, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]: # noqa: D102
|
||||
...
|
||||
|
||||
def warm_up(self) -> None: # noqa: D102
|
||||
...
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
import jinja2.runtime
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from jinja2.nativetypes import NativeEnvironment
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
|
||||
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputAdaptationException(Exception):
|
||||
"""Exception raised when there is an error during output adaptation."""
|
||||
|
||||
|
||||
@component
|
||||
class OutputAdapter:
|
||||
"""
|
||||
Adapts output of a Component using Jinja templates.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.converters import OutputAdapter
|
||||
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
documents = [Document(content="Test content")]
|
||||
result = adapter.run(documents=documents)
|
||||
|
||||
assert result["output"] == "Test content"
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
output_type: TypeAlias,
|
||||
custom_filters: dict[str, Callable] | None = None,
|
||||
unsafe: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create an OutputAdapter component.
|
||||
|
||||
:param template:
|
||||
A Jinja template that defines how to adapt the input data.
|
||||
The variables in the template define the input of this instance.
|
||||
e.g.
|
||||
With this template:
|
||||
```
|
||||
{{ documents[0].content }}
|
||||
```
|
||||
The Component input will be `documents`.
|
||||
:param output_type:
|
||||
The type of output this instance will return.
|
||||
:param custom_filters:
|
||||
A dictionary of custom Jinja filters used in the template.
|
||||
:param unsafe:
|
||||
Enable execution of arbitrary code in the Jinja template.
|
||||
This should only be used if you trust the source of the template as it can be lead to remote code execution.
|
||||
"""
|
||||
self.custom_filters = {**(custom_filters or {})}
|
||||
input_types: set[str] = set()
|
||||
|
||||
self._unsafe = unsafe
|
||||
|
||||
if self._unsafe:
|
||||
msg = (
|
||||
"Unsafe mode is enabled. This allows execution of arbitrary code in the Jinja template. "
|
||||
"Use this only if you trust the source of the template."
|
||||
)
|
||||
logger.warning(msg)
|
||||
self._env = (
|
||||
NativeEnvironment() if self._unsafe else SandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
|
||||
)
|
||||
|
||||
try:
|
||||
self._env.parse(template) # Validate template syntax
|
||||
self.template = template
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValueError(f"Invalid Jinja template '{template}': {e}") from e
|
||||
|
||||
for name, filter_func in self.custom_filters.items():
|
||||
self._env.filters[name] = filter_func
|
||||
|
||||
# b) extract variables in the template
|
||||
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
||||
env=self._env, template=self.template
|
||||
)
|
||||
route_input_names = template_variables - assigned_variables
|
||||
input_types.update(route_input_names)
|
||||
|
||||
# the env is not needed, discarded automatically
|
||||
component.set_input_types(self, **dict.fromkeys(input_types, Any))
|
||||
component.set_output_types(self, output=output_type)
|
||||
self.output_type = output_type
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Renders the Jinja template with the provided inputs.
|
||||
|
||||
:param kwargs:
|
||||
Must contain all variables used in the `template` string.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `output`: Rendered Jinja template.
|
||||
|
||||
:raises OutputAdaptationException: If template rendering fails.
|
||||
"""
|
||||
# check if kwargs are empty
|
||||
if not kwargs:
|
||||
raise ValueError("No input data provided for output adaptation")
|
||||
for name, filter_func in self.custom_filters.items():
|
||||
self._env.filters[name] = filter_func
|
||||
adapted_outputs = {}
|
||||
try:
|
||||
adapted_output_template = self._env.from_string(self.template)
|
||||
output_result = adapted_output_template.render(**kwargs)
|
||||
if isinstance(output_result, jinja2.runtime.Undefined):
|
||||
raise OutputAdaptationException(f"Undefined variable in the template {self.template}; kwargs: {kwargs}") # noqa: TRY301
|
||||
|
||||
# We suppress the exception in case the output is already a string, otherwise
|
||||
# we try to evaluate it and would fail.
|
||||
# This must be done cause the output could be different literal structures.
|
||||
# This doesn't support any user types.
|
||||
with contextlib.suppress(Exception):
|
||||
if not self._unsafe:
|
||||
output_result = ast.literal_eval(output_result)
|
||||
|
||||
adapted_outputs["output"] = output_result
|
||||
except Exception as e:
|
||||
raise OutputAdaptationException(f"Error adapting {self.template} with {kwargs}: {e}") from e
|
||||
return adapted_outputs
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
se_filters = {name: serialize_callable(filter_func) for name, filter_func in self.custom_filters.items()}
|
||||
return default_to_dict(
|
||||
self,
|
||||
template=self.template,
|
||||
output_type=serialize_type(self.output_type),
|
||||
custom_filters=se_filters,
|
||||
unsafe=self._unsafe,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
init_params["output_type"] = deserialize_type(init_params["output_type"])
|
||||
|
||||
custom_filters = init_params.get("custom_filters", {})
|
||||
if custom_filters:
|
||||
init_params["custom_filters"] = {
|
||||
name: deserialize_callable(filter_func) if filter_func else None
|
||||
for name, filter_func in custom_filters.items()
|
||||
}
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,228 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pdfminer.six'") as pdfminer_import:
|
||||
from pdfminer.high_level import extract_pages
|
||||
from pdfminer.layout import LAParams, LTTextContainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CID_PATTERN = r"\(cid:\d+\)" # regex pattern to detect CID characters
|
||||
|
||||
|
||||
@component
|
||||
class PDFMinerToDocument:
|
||||
"""
|
||||
Converts PDF files to Documents.
|
||||
|
||||
Uses `pdfminer` compatible converters to convert PDF files to Documents. https://pdfminersix.readthedocs.io/en/latest/
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pdfminer import PDFMinerToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PDFMinerToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pdf/sample_pdf_1.pdf"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
|
||||
print(results["documents"][0].content)
|
||||
# >> 'This is a text from the PDF file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
line_overlap: float = 0.5,
|
||||
char_margin: float = 2.0,
|
||||
line_margin: float = 0.5,
|
||||
word_margin: float = 0.1,
|
||||
boxes_flow: float | None = 0.5,
|
||||
detect_vertical: bool = True,
|
||||
all_texts: bool = False,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a PDFMinerToDocument component.
|
||||
|
||||
:param line_overlap:
|
||||
This parameter determines whether two characters are considered to be on
|
||||
the same line based on the amount of overlap between them.
|
||||
The overlap is calculated relative to the minimum height of both characters.
|
||||
:param char_margin:
|
||||
Determines whether two characters are part of the same line based on the distance between them.
|
||||
If the distance is less than the margin specified, the characters are considered to be on the same line.
|
||||
The margin is calculated relative to the width of the character.
|
||||
:param word_margin:
|
||||
Determines whether two characters on the same line are part of the same word
|
||||
based on the distance between them. If the distance is greater than the margin specified,
|
||||
an intermediate space will be added between them to make the text more readable.
|
||||
The margin is calculated relative to the width of the character.
|
||||
:param line_margin:
|
||||
This parameter determines whether two lines are part of the same paragraph based on
|
||||
the distance between them. If the distance is less than the margin specified,
|
||||
the lines are considered to be part of the same paragraph.
|
||||
The margin is calculated relative to the height of a line.
|
||||
:param boxes_flow:
|
||||
This parameter determines the importance of horizontal and vertical position when
|
||||
determining the order of text boxes. A value between -1.0 and +1.0 can be set,
|
||||
with -1.0 indicating that only horizontal position matters and +1.0 indicating
|
||||
that only vertical position matters. Setting the value to 'None' will disable advanced
|
||||
layout analysis, and text boxes will be ordered based on the position of their bottom left corner.
|
||||
:param detect_vertical:
|
||||
This parameter determines whether vertical text should be considered during layout analysis.
|
||||
:param all_texts:
|
||||
If layout analysis should be performed on text in figures.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
|
||||
pdfminer_import.check()
|
||||
|
||||
self.layout_params = LAParams(
|
||||
line_overlap=line_overlap,
|
||||
char_margin=char_margin,
|
||||
line_margin=line_margin,
|
||||
word_margin=word_margin,
|
||||
boxes_flow=boxes_flow,
|
||||
detect_vertical=detect_vertical,
|
||||
all_texts=all_texts,
|
||||
)
|
||||
self.store_full_path = store_full_path
|
||||
self.cid_pattern = re.compile(CID_PATTERN)
|
||||
|
||||
@staticmethod
|
||||
def _converter(lt_page_objs: Iterator) -> str:
|
||||
"""
|
||||
Extracts text from PDF pages then converts the text into a single str
|
||||
|
||||
:param lt_page_objs:
|
||||
Python generator that yields PDF pages.
|
||||
|
||||
:returns:
|
||||
PDF text converted to single str
|
||||
"""
|
||||
pages = []
|
||||
for page in lt_page_objs:
|
||||
text = ""
|
||||
for container in page:
|
||||
# Keep text only
|
||||
if isinstance(container, LTTextContainer):
|
||||
container_text = container.get_text()
|
||||
if container_text:
|
||||
text += "\n\n"
|
||||
text += container_text
|
||||
pages.append(text)
|
||||
|
||||
# Add a page delimiter
|
||||
return "\f".join(pages)
|
||||
|
||||
def detect_undecoded_cid_characters(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Look for character sequences of CID, i.e.: characters that haven't been properly decoded from their CID format.
|
||||
|
||||
This is useful to detect if the text extractor is not able to extract the text correctly, e.g. if the PDF uses
|
||||
non-standard fonts.
|
||||
|
||||
A PDF font may include a ToUnicode map (mapping from character code to Unicode) to support operations like
|
||||
searching strings or copy & paste in a PDF viewer. This map immediately provides the mapping the text extractor
|
||||
needs. If that map is not available the text extractor cannot decode the CID characters and will return them
|
||||
as is.
|
||||
|
||||
see: https://pdfminersix.readthedocs.io/en/latest/faq.html#why-are-there-cid-x-values-in-the-textual-output
|
||||
|
||||
:param text: The text to check for undecoded CID characters
|
||||
:returns:
|
||||
A dictionary containing detection results
|
||||
"""
|
||||
|
||||
matches = re.findall(self.cid_pattern, text)
|
||||
total_chars = len(text)
|
||||
cid_chars = sum(len(match) for match in matches)
|
||||
percentage = (cid_chars / total_chars * 100) if total_chars > 0 else 0
|
||||
|
||||
return {"total_chars": total_chars, "cid_chars": cid_chars, "percentage": round(percentage, 2)}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts PDF files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of PDF file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents
|
||||
"""
|
||||
documents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
pages = extract_pages(io.BytesIO(bytestream.data), laparams=self.layout_params)
|
||||
text = self._converter(pages)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
if text is None or text.strip() == "":
|
||||
logger.warning(
|
||||
"PDFMinerToDocument could not extract text from the file {source}. Returning an empty document.",
|
||||
source=source,
|
||||
)
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
analysis = self.detect_undecoded_cid_characters(text)
|
||||
|
||||
if analysis["percentage"] > 0:
|
||||
logger.warning(
|
||||
"Detected {cid_chars} undecoded CID characters in {total_chars} characters"
|
||||
" ({percentage}%) in {source}.",
|
||||
cid_chars=analysis["cid_chars"],
|
||||
total_chars=analysis["total_chars"],
|
||||
percentage=analysis["percentage"],
|
||||
source=source,
|
||||
)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,158 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, default_to_dict, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install python-pptx'") as pptx_import:
|
||||
from pptx import Presentation
|
||||
from pptx.text.text import _Paragraph
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class PPTXToDocument:
|
||||
"""
|
||||
Converts PPTX files to Documents.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pptx import PPTXToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PPTXToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pptx/sample_pptx.pptx"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is the text from the PPTX file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, store_full_path: bool = False, link_format: Literal["markdown", "plain", "none"] = "none"
|
||||
) -> None:
|
||||
"""
|
||||
Create a PPTXToDocument component.
|
||||
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
:param link_format: The format for link output. Possible options:
|
||||
- `"markdown"`: `[text](url)`
|
||||
- `"plain"`: `text (url)`
|
||||
- `"none"`: Only the text is extracted, link addresses are ignored.
|
||||
"""
|
||||
pptx_import.check()
|
||||
if link_format not in ("markdown", "plain", "none"):
|
||||
msg = f"Unknown link format '{link_format}'. Supported formats are: 'markdown', 'plain', 'none'"
|
||||
raise ValueError(msg)
|
||||
self.link_format = link_format
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, link_format=self.link_format, store_full_path=self.store_full_path)
|
||||
|
||||
def _convert(self, file_content: io.BytesIO) -> str:
|
||||
"""
|
||||
Converts the PPTX file to text.
|
||||
"""
|
||||
pptx_presentation = Presentation(file_content)
|
||||
text_all_slides = []
|
||||
for slide in pptx_presentation.slides:
|
||||
text_on_slide = []
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
paragraphs = []
|
||||
for paragraph in shape.text_frame.paragraphs:
|
||||
paragraphs.append(self._process_paragraph(paragraph))
|
||||
text_on_slide.append("\n".join(paragraphs))
|
||||
elif hasattr(shape, "text"):
|
||||
text_on_slide.append(shape.text)
|
||||
text_all_slides.append("\n".join(text_on_slide))
|
||||
return "\f".join(text_all_slides)
|
||||
|
||||
def _process_paragraph(self, paragraph: "_Paragraph") -> str:
|
||||
"""
|
||||
Processes a paragraph and formats hyperlinks according to the specified link format.
|
||||
|
||||
:param paragraph: The PPTX paragraph to process.
|
||||
:returns: A string with links formatted according to the specified format.
|
||||
"""
|
||||
if self.link_format == "none":
|
||||
return paragraph.text
|
||||
parts = []
|
||||
for run in paragraph.runs:
|
||||
if run.hyperlink and run.hyperlink.address:
|
||||
if self.link_format == "markdown":
|
||||
parts.append(f"[{run.text}]({run.hyperlink.address})")
|
||||
else:
|
||||
parts.append(f"{run.text} ({run.hyperlink.address})")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
return "".join(parts)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts PPTX files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the Documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced Documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output Documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents
|
||||
"""
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
text = self._convert(io.BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
documents.append(Document(content=text, meta=merged_metadata))
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,228 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pypdf'") as pypdf_import:
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyPDFExtractionMode(Enum):
|
||||
"""
|
||||
The mode to use for extracting text from a PDF.
|
||||
"""
|
||||
|
||||
PLAIN = "plain"
|
||||
LAYOUT = "layout"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Convert a PyPDFExtractionMode enum to a string.
|
||||
"""
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "PyPDFExtractionMode":
|
||||
"""
|
||||
Convert a string to a PyPDFExtractionMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in PyPDFExtractionMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown extraction mode '{string}'. Supported modes are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class PyPDFToDocument:
|
||||
"""
|
||||
Converts PDF files to documents your pipeline can query.
|
||||
|
||||
This component uses the PyPDF library.
|
||||
You can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pypdf import PyPDFToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PyPDFToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pdf/sample_pdf_1.pdf"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is a text from the PDF file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
extraction_mode: str | PyPDFExtractionMode = PyPDFExtractionMode.PLAIN,
|
||||
plain_mode_orientations: tuple = (0, 90, 180, 270),
|
||||
plain_mode_space_width: float = 200.0,
|
||||
layout_mode_space_vertically: bool = True,
|
||||
layout_mode_scale_weight: float = 1.25,
|
||||
layout_mode_strip_rotated: bool = True,
|
||||
layout_mode_font_height_weight: float = 1.0,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create an PyPDFToDocument component.
|
||||
|
||||
:param extraction_mode:
|
||||
The mode to use for extracting text from a PDF.
|
||||
Layout mode is an experimental mode that adheres to the rendered layout of the PDF.
|
||||
:param plain_mode_orientations:
|
||||
Tuple of orientations to look for when extracting text from a PDF in plain mode.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.LAYOUT`.
|
||||
:param plain_mode_space_width:
|
||||
Forces default space width if not extracted from font.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.LAYOUT`.
|
||||
:param layout_mode_space_vertically:
|
||||
Whether to include blank lines inferred from y distance + font height.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_scale_weight:
|
||||
Multiplier for string length when calculating weighted average character width.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_strip_rotated:
|
||||
Layout mode does not support rotated text. Set to `False` to include rotated text anyway.
|
||||
If rotated text is discovered, layout will be degraded and a warning will be logged.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_font_height_weight:
|
||||
Multiplier for font height when calculating blank line height.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
pypdf_import.check()
|
||||
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
if isinstance(extraction_mode, str):
|
||||
extraction_mode = PyPDFExtractionMode.from_str(extraction_mode)
|
||||
self.extraction_mode = extraction_mode
|
||||
self.plain_mode_orientations = plain_mode_orientations
|
||||
self.plain_mode_space_width = plain_mode_space_width
|
||||
self.layout_mode_space_vertically = layout_mode_space_vertically
|
||||
self.layout_mode_scale_weight = layout_mode_scale_weight
|
||||
self.layout_mode_strip_rotated = layout_mode_strip_rotated
|
||||
self.layout_mode_font_height_weight = layout_mode_font_height_weight
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
extraction_mode=str(self.extraction_mode),
|
||||
plain_mode_orientations=self.plain_mode_orientations,
|
||||
plain_mode_space_width=self.plain_mode_space_width,
|
||||
layout_mode_space_vertically=self.layout_mode_space_vertically,
|
||||
layout_mode_scale_weight=self.layout_mode_scale_weight,
|
||||
layout_mode_strip_rotated=self.layout_mode_strip_rotated,
|
||||
layout_mode_font_height_weight=self.layout_mode_font_height_weight,
|
||||
store_full_path=self.store_full_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PyPDFToDocument":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary with serialized data.
|
||||
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _default_convert(self, reader: "PdfReader") -> str:
|
||||
texts = []
|
||||
for page in reader.pages:
|
||||
extracted_text = page.extract_text(
|
||||
orientations=self.plain_mode_orientations,
|
||||
extraction_mode=self.extraction_mode.value,
|
||||
space_width=self.plain_mode_space_width,
|
||||
layout_mode_space_vertically=self.layout_mode_space_vertically,
|
||||
layout_mode_scale_weight=self.layout_mode_scale_weight,
|
||||
layout_mode_strip_rotated=self.layout_mode_strip_rotated,
|
||||
layout_mode_font_height_weight=self.layout_mode_font_height_weight,
|
||||
)
|
||||
texts.append(extracted_text)
|
||||
return "\f".join(texts)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Converts PDF files to documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, its length must match the number of sources, as they are zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of converted documents.
|
||||
"""
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
pdf_reader = PdfReader(io.BytesIO(bytestream.data))
|
||||
text = self._default_convert(pdf_reader)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
if text is None or text.strip() == "":
|
||||
logger.warning(
|
||||
"PyPDFToDocument could not extract text from the file {source}. Returning an empty document.",
|
||||
source=source,
|
||||
)
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class TextFileToDocument:
|
||||
"""
|
||||
Converts text files to documents your pipeline can query.
|
||||
|
||||
By default, it uses UTF-8 encoding when converting files but
|
||||
you can also set custom encoding.
|
||||
It can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.txt import TextFileToDocument
|
||||
|
||||
converter = TextFileToDocument()
|
||||
results = converter.run(sources=["test/test_files/txt/doc_1.txt"])
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is the content from the txt file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Creates a TextFileToDocument component.
|
||||
|
||||
:param encoding:
|
||||
The encoding of the text files to convert.
|
||||
If the encoding is specified in the metadata of a source ByteStream,
|
||||
it overrides this value.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Converts text files to documents.
|
||||
|
||||
:param sources:
|
||||
List of text file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of converted documents.
|
||||
"""
|
||||
documents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
try:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
text = bytestream.data.decode(encoding)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
def get_bytestream_from_source(source: str | Path | ByteStream, guess_mime_type: bool = False) -> ByteStream:
|
||||
"""
|
||||
Creates a ByteStream object from a source.
|
||||
|
||||
:param source:
|
||||
A source to convert to a ByteStream. Can be a string (path to a file), a Path object, or a ByteStream.
|
||||
:param guess_mime_type:
|
||||
Whether to guess the mime type from the file.
|
||||
:return:
|
||||
A ByteStream object.
|
||||
"""
|
||||
|
||||
if isinstance(source, ByteStream):
|
||||
return source
|
||||
if isinstance(source, (str, Path)):
|
||||
bs = ByteStream.from_file_path(Path(source), guess_mime_type=guess_mime_type)
|
||||
bs.meta["file_path"] = str(source)
|
||||
return bs
|
||||
raise ValueError(f"Unsupported source type {type(source)}")
|
||||
|
||||
|
||||
def normalize_metadata(meta: dict[str, Any] | list[dict[str, Any]] | None, sources_count: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize the metadata input for a converter.
|
||||
|
||||
Given all the possible value of the meta input for a converter (None, dictionary or list of dicts),
|
||||
makes sure to return a list of dictionaries of the correct length for the converter to use.
|
||||
|
||||
:param meta: the meta input of the converter, as-is
|
||||
:param sources_count: the number of sources the converter received
|
||||
:returns: a list of dictionaries of the make length as the sources list
|
||||
"""
|
||||
if meta is None:
|
||||
return [{}] * sources_count
|
||||
if isinstance(meta, dict):
|
||||
return [meta] * sources_count
|
||||
if isinstance(meta, list):
|
||||
if sources_count != len(meta):
|
||||
raise ValueError("The length of the metadata list must match the number of sources.")
|
||||
return meta
|
||||
raise ValueError("meta must be either None, a dictionary or a list of dictionaries.")
|
||||
@@ -0,0 +1,236 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install pandas openpyxl'") as pandas_xlsx_import:
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
|
||||
with LazyImport("Run 'pip install tabulate'") as tabulate_import:
|
||||
from tabulate import tabulate # noqa: F401 # the library is used but not directly referenced
|
||||
|
||||
|
||||
@component
|
||||
class XLSXToDocument:
|
||||
"""
|
||||
Converts XLSX (Excel) files into Documents.
|
||||
|
||||
Supports reading data from specific sheets or all sheets in the Excel file. If all sheets are read, a Document is
|
||||
created for each sheet. The content of the Document is the table which can be saved in CSV or Markdown format.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.xlsx import XLSXToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = XLSXToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/xlsx/basic_tables_two_sheets.xlsx"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> ",A,B\\n1,col_a,col_b\\n2,1.5,test\\n"
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_format: Literal["csv", "markdown"] = "csv",
|
||||
sheet_name: str | int | list[str | int] | None = None,
|
||||
read_excel_kwargs: dict[str, Any] | None = None,
|
||||
table_format_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
link_format: Literal["markdown", "plain", "none"] = "none",
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a XLSXToDocument component.
|
||||
|
||||
:param table_format: The format to convert the Excel file to.
|
||||
:param sheet_name: The name of the sheet to read. If None, all sheets are read.
|
||||
:param read_excel_kwargs: Additional arguments to pass to `pandas.read_excel`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.read_excel.html#pandas-read-excel
|
||||
:param table_format_kwargs: Additional keyword arguments to pass to the table format function.
|
||||
- If `table_format` is "csv", these arguments are passed to `pandas.DataFrame.to_csv`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_csv.html#pandas-dataframe-to-csv
|
||||
- If `table_format` is "markdown", these arguments are passed to `pandas.DataFrame.to_markdown`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_markdown.html#pandas-dataframe-to-markdown
|
||||
:param link_format: The format for link output. Possible options:
|
||||
- `"markdown"`: `[text](url)`
|
||||
- `"plain"`: `text (url)`
|
||||
- `"none"`: Only the text is extracted, link addresses are ignored.
|
||||
:param store_full_path:
|
||||
If True, the full path of the file is stored in the metadata of the document.
|
||||
If False, only the file name is stored.
|
||||
"""
|
||||
pandas_xlsx_import.check()
|
||||
self.table_format = table_format
|
||||
if table_format not in ["csv", "markdown"]:
|
||||
raise ValueError(f"Unsupported export format: {table_format}. Choose either 'csv' or 'markdown'.")
|
||||
if link_format not in ("markdown", "plain", "none"):
|
||||
msg = f"Unknown link format '{link_format}'. Supported formats are: 'markdown', 'plain', 'none'"
|
||||
raise ValueError(msg)
|
||||
if table_format == "markdown":
|
||||
tabulate_import.check()
|
||||
self.link_format = link_format
|
||||
self.sheet_name = sheet_name
|
||||
self.read_excel_kwargs = read_excel_kwargs or {}
|
||||
self.table_format_kwargs = table_format_kwargs or {}
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Converts a XLSX file to a Document.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be either a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced documents.
|
||||
If it's a list, the length of the list must match the number of sources, because the two lists will
|
||||
be zipped.
|
||||
If `sources` contains ByteStream objects, their `meta` will be added to the output documents.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created documents
|
||||
"""
|
||||
documents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
try:
|
||||
tables, tables_metadata = self._extract_tables(bytestream)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to a Document, skipping. Error: {error}",
|
||||
source=source,
|
||||
error=e,
|
||||
)
|
||||
continue
|
||||
|
||||
# Loop over tables and create a Document for each table
|
||||
for table, excel_metadata in zip(tables, tables_metadata, strict=True):
|
||||
merged_metadata = {**bytestream.meta, **metadata, **excel_metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
file_path = bytestream.meta["file_path"]
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=table, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
@staticmethod
|
||||
def _generate_excel_column_names(n_cols: int) -> list[str]:
|
||||
result = []
|
||||
for i in range(n_cols):
|
||||
col_name = ""
|
||||
num = i
|
||||
while num >= 0:
|
||||
col_name = chr(num % 26 + 65) + col_name
|
||||
num = num // 26 - 1
|
||||
result.append(col_name)
|
||||
return result
|
||||
|
||||
def _extract_tables(self, bytestream: ByteStream) -> tuple[list[str], list[dict]]:
|
||||
"""
|
||||
Extract tables from an Excel file.
|
||||
"""
|
||||
file_bytes = io.BytesIO(bytestream.data)
|
||||
resolved_read_excel_kwargs = {
|
||||
**self.read_excel_kwargs,
|
||||
"sheet_name": self.sheet_name,
|
||||
"header": None, # Don't assign any pandas column labels
|
||||
"engine": "openpyxl", # Use openpyxl as the engine to read the Excel file
|
||||
}
|
||||
sheet_to_dataframe = pd.read_excel(io=file_bytes, **resolved_read_excel_kwargs)
|
||||
if isinstance(sheet_to_dataframe, pd.DataFrame):
|
||||
sheet_to_dataframe = {self.sheet_name: sheet_to_dataframe}
|
||||
|
||||
# If link extraction is enabled, load the workbook with openpyxl to read hyperlinks
|
||||
hyperlinks_by_sheet: dict[str | int | None, dict[tuple[int, int], str]] = {}
|
||||
if self.link_format != "none":
|
||||
file_bytes.seek(0)
|
||||
wb = openpyxl.load_workbook(file_bytes, data_only=True)
|
||||
for sheet_key in sheet_to_dataframe:
|
||||
if isinstance(sheet_key, int):
|
||||
ws = wb.worksheets[sheet_key]
|
||||
elif sheet_key is None:
|
||||
ws = wb.active
|
||||
else:
|
||||
ws = wb[sheet_key]
|
||||
cell_links: dict[tuple[int, int], str] = {}
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.hyperlink and cell.hyperlink.target:
|
||||
# Convert to 0-based indices to match DataFrame positions
|
||||
cell_links[(cell.row - 1, cell.column - 1)] = cell.hyperlink.target
|
||||
hyperlinks_by_sheet[sheet_key] = cell_links
|
||||
wb.close()
|
||||
|
||||
updated_sheet_to_dataframe = {}
|
||||
for key in sheet_to_dataframe:
|
||||
df = sheet_to_dataframe[key]
|
||||
# Row starts at 1 in Excel
|
||||
df.index = df.index + 1
|
||||
# Excel column names are Alphabet Characters
|
||||
header = self._generate_excel_column_names(df.shape[1])
|
||||
df.columns = header
|
||||
|
||||
# Apply hyperlinks to cell values
|
||||
if key in hyperlinks_by_sheet:
|
||||
for (row_idx, col_idx), url in hyperlinks_by_sheet[key].items():
|
||||
if row_idx < len(df) and col_idx < len(df.columns):
|
||||
cell_value = df.iat[row_idx, col_idx]
|
||||
text = str(cell_value) if pd.notna(cell_value) else ""
|
||||
if self.link_format == "markdown":
|
||||
df.iat[row_idx, col_idx] = f"[{text}]({url})"
|
||||
else:
|
||||
df.iat[row_idx, col_idx] = f"{text} ({url})"
|
||||
|
||||
updated_sheet_to_dataframe[key] = df
|
||||
|
||||
tables = []
|
||||
metadata = []
|
||||
for key, value in updated_sheet_to_dataframe.items():
|
||||
if self.table_format == "csv":
|
||||
resolved_kwargs = {"index": True, "header": True, "lineterminator": "\n", **self.table_format_kwargs}
|
||||
tables.append(value.to_csv(**resolved_kwargs))
|
||||
else:
|
||||
resolved_kwargs = {
|
||||
"index": True,
|
||||
"headers": value.columns,
|
||||
"tablefmt": "pipe",
|
||||
**self.table_format_kwargs,
|
||||
}
|
||||
# to_markdown uses tabulate
|
||||
tables.append(value.to_markdown(**resolved_kwargs))
|
||||
# add sheet_name to metadata
|
||||
metadata.append({"xlsx": {"sheet_name": key}})
|
||||
return tables, metadata
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"azure_document_embedder": ["AzureOpenAIDocumentEmbedder"],
|
||||
"azure_text_embedder": ["AzureOpenAITextEmbedder"],
|
||||
"mock_document_embedder": ["MockDocumentEmbedder"],
|
||||
"mock_text_embedder": ["MockTextEmbedder"],
|
||||
"openai_document_embedder": ["OpenAIDocumentEmbedder"],
|
||||
"openai_text_embedder": ["OpenAITextEmbedder"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .azure_document_embedder import AzureOpenAIDocumentEmbedder as AzureOpenAIDocumentEmbedder
|
||||
from .azure_text_embedder import AzureOpenAITextEmbedder as AzureOpenAITextEmbedder
|
||||
from .mock_document_embedder import MockDocumentEmbedder as MockDocumentEmbedder
|
||||
from .mock_text_embedder import MockTextEmbedder as MockTextEmbedder
|
||||
from .openai_document_embedder import OpenAIDocumentEmbedder as OpenAIDocumentEmbedder
|
||||
from .openai_text_embedder import OpenAITextEmbedder as OpenAITextEmbedder
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,250 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from openai.lib.azure import AsyncAzureOpenAI, AzureADTokenProvider, AzureOpenAI
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.embedders import OpenAIDocumentEmbedder
|
||||
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class AzureOpenAIDocumentEmbedder(OpenAIDocumentEmbedder):
|
||||
"""
|
||||
Calculates document embeddings using OpenAI models deployed on Azure.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import AzureOpenAIDocumentEmbedder
|
||||
|
||||
doc = Document(content="I love pizza!")
|
||||
document_embedder = AzureOpenAIDocumentEmbedder()
|
||||
|
||||
result = document_embedder.run([doc])
|
||||
print(result['documents'][0].embedding)
|
||||
|
||||
# [0.017020374536514282, -0.023255806416273117, ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913 (too-many-arguments)
|
||||
self,
|
||||
azure_endpoint: str | None = None,
|
||||
api_version: str | None = "2023-05-15",
|
||||
azure_deployment: str = "text-embedding-ada-002",
|
||||
dimensions: int | None = None,
|
||||
api_key: Secret | None = Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False),
|
||||
azure_ad_token: Secret | None = Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False),
|
||||
organization: str | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
batch_size: int = 32,
|
||||
progress_bar: bool = True,
|
||||
meta_fields_to_embed: list[str] | None = None,
|
||||
embedding_separator: str = "\n",
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
*,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
azure_ad_token_provider: AzureADTokenProvider | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
raise_on_failure: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an AzureOpenAIDocumentEmbedder component.
|
||||
|
||||
:param azure_endpoint:
|
||||
The endpoint of the model deployed on Azure.
|
||||
:param api_version:
|
||||
The version of the API to use.
|
||||
:param azure_deployment:
|
||||
The name of the model deployed on Azure. The default model is text-embedding-ada-002.
|
||||
:param dimensions:
|
||||
The number of dimensions of the resulting embeddings. Only supported in text-embedding-3
|
||||
and later models.
|
||||
:param api_key:
|
||||
The Azure OpenAI API key.
|
||||
You can set it with an environment variable `AZURE_OPENAI_API_KEY`, or pass with this
|
||||
parameter during initialization.
|
||||
:param azure_ad_token:
|
||||
Microsoft Entra ID token, see Microsoft's
|
||||
[Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id)
|
||||
documentation for more information. You can set it with an environment variable
|
||||
`AZURE_OPENAI_AD_TOKEN`, or pass with this parameter during initialization.
|
||||
Previously called Azure Active Directory.
|
||||
:param organization:
|
||||
Your organization ID. See OpenAI's
|
||||
[Setting Up Your Organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization)
|
||||
for more information.
|
||||
:param prefix:
|
||||
A string to add at the beginning of each text.
|
||||
:param suffix:
|
||||
A string to add at the end of each text.
|
||||
:param batch_size:
|
||||
Number of documents to embed at once.
|
||||
:param progress_bar:
|
||||
If `True`, shows a progress bar when running.
|
||||
:param meta_fields_to_embed:
|
||||
List of metadata fields to embed along with the document text.
|
||||
:param embedding_separator:
|
||||
Separator used to concatenate the metadata fields to the document text.
|
||||
:param timeout: The timeout for `AzureOpenAI` client calls, in seconds.
|
||||
If not set, defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries: Maximum number of retries to contact AzureOpenAI after an internal error.
|
||||
If not set, defaults to either the `OPENAI_MAX_RETRIES` environment variable or to 5 retries.
|
||||
:param default_headers: Default headers to send to the AzureOpenAI client.
|
||||
:param azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on
|
||||
every request.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
:param raise_on_failure:
|
||||
Whether to raise an exception if the embedding request fails. If `False`, the component will log the error
|
||||
and continue processing the remaining documents. If `True`, it will raise an exception on failure.
|
||||
"""
|
||||
# We intentionally do not call super().__init__ here because we only need to instantiate the client to interact
|
||||
# with the API.
|
||||
|
||||
# if not provided as a parameter, azure_endpoint is read from the env var AZURE_OPENAI_ENDPOINT
|
||||
azure_endpoint = azure_endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not azure_endpoint:
|
||||
raise ValueError("Please provide an Azure endpoint or set the environment variable AZURE_OPENAI_ENDPOINT.")
|
||||
|
||||
if api_key is None and azure_ad_token is None:
|
||||
raise ValueError("Please provide an API key or an Azure Active Directory token.")
|
||||
|
||||
self.api_key = api_key # type: ignore[assignment] # mypy does not understand that api_key can be None
|
||||
self.azure_ad_token = azure_ad_token
|
||||
self.api_version = api_version
|
||||
self.azure_endpoint = azure_endpoint
|
||||
self.azure_deployment = azure_deployment
|
||||
self.model = azure_deployment
|
||||
self.dimensions = dimensions
|
||||
self.organization = organization
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.batch_size = batch_size
|
||||
self.progress_bar = progress_bar
|
||||
self.meta_fields_to_embed = meta_fields_to_embed or []
|
||||
self.embedding_separator = embedding_separator
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.default_headers = default_headers or {}
|
||||
self.azure_ad_token_provider = azure_ad_token_provider
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
self.raise_on_failure = raise_on_failure
|
||||
|
||||
self.client: AzureOpenAI | None = None
|
||||
self.async_client: AsyncAzureOpenAI | None = None
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_version": self.api_version,
|
||||
"azure_endpoint": self.azure_endpoint,
|
||||
"azure_deployment": self.azure_deployment,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
"api_key": self.api_key.resolve_value() if self.api_key is not None else None,
|
||||
"azure_ad_token": self.azure_ad_token.resolve_value() if self.azure_ad_token is not None else None,
|
||||
"organization": self.organization,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
"default_headers": self.default_headers,
|
||||
}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous AzureOpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous AzureOpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous AzureOpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous AzureOpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
azure_ad_token_provider_name = None
|
||||
if self.azure_ad_token_provider:
|
||||
azure_ad_token_provider_name = serialize_callable(self.azure_ad_token_provider)
|
||||
return default_to_dict(
|
||||
self,
|
||||
azure_endpoint=self.azure_endpoint,
|
||||
azure_deployment=self.azure_deployment,
|
||||
dimensions=self.dimensions,
|
||||
organization=self.organization,
|
||||
api_version=self.api_version,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
batch_size=self.batch_size,
|
||||
progress_bar=self.progress_bar,
|
||||
meta_fields_to_embed=self.meta_fields_to_embed,
|
||||
embedding_separator=self.embedding_separator,
|
||||
api_key=self.api_key,
|
||||
azure_ad_token=self.azure_ad_token,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
default_headers=self.default_headers,
|
||||
azure_ad_token_provider=azure_ad_token_provider_name,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIDocumentEmbedder":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
serialized_azure_ad_token_provider = data["init_parameters"].get("azure_ad_token_provider")
|
||||
if serialized_azure_ad_token_provider:
|
||||
data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable(
|
||||
serialized_azure_ad_token_provider
|
||||
)
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,226 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from openai.lib.azure import AsyncAzureOpenAI, AzureADTokenProvider, AzureOpenAI
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.components.embedders import OpenAITextEmbedder
|
||||
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
|
||||
@component
|
||||
class AzureOpenAITextEmbedder(OpenAITextEmbedder):
|
||||
"""
|
||||
Embeds strings using OpenAI models deployed on Azure.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.embedders import AzureOpenAITextEmbedder
|
||||
|
||||
text_to_embed = "I love pizza!"
|
||||
text_embedder = AzureOpenAITextEmbedder()
|
||||
|
||||
print(text_embedder.run(text_to_embed))
|
||||
|
||||
# {'embedding': [0.017020374536514282, -0.023255806416273117, ...],
|
||||
# 'meta': {'model': 'text-embedding-ada-002-v2',
|
||||
# 'usage': {'prompt_tokens': 4, 'total_tokens': 4}}}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
azure_endpoint: str | None = None,
|
||||
api_version: str | None = "2023-05-15",
|
||||
azure_deployment: str = "text-embedding-ada-002",
|
||||
dimensions: int | None = None,
|
||||
api_key: Secret | None = Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False),
|
||||
azure_ad_token: Secret | None = Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False),
|
||||
organization: str | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
*,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
azure_ad_token_provider: AzureADTokenProvider | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an AzureOpenAITextEmbedder component.
|
||||
|
||||
:param azure_endpoint:
|
||||
The endpoint of the model deployed on Azure.
|
||||
:param api_version:
|
||||
The version of the API to use.
|
||||
:param azure_deployment:
|
||||
The name of the model deployed on Azure. The default model is text-embedding-ada-002.
|
||||
:param dimensions:
|
||||
The number of dimensions the resulting output embeddings should have. Only supported in text-embedding-3
|
||||
and later models.
|
||||
:param api_key:
|
||||
The Azure OpenAI API key.
|
||||
You can set it with an environment variable `AZURE_OPENAI_API_KEY`, or pass with this
|
||||
parameter during initialization.
|
||||
:param azure_ad_token:
|
||||
Microsoft Entra ID token, see Microsoft's
|
||||
[Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id)
|
||||
documentation for more information. You can set it with an environment variable
|
||||
`AZURE_OPENAI_AD_TOKEN`, or pass with this parameter during initialization.
|
||||
Previously called Azure Active Directory.
|
||||
:param organization:
|
||||
Your organization ID. See OpenAI's
|
||||
[Setting Up Your Organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization)
|
||||
for more information.
|
||||
:param timeout: The timeout for `AzureOpenAI` client calls, in seconds.
|
||||
If not set, defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries: Maximum number of retries to contact AzureOpenAI after an internal error.
|
||||
If not set, defaults to either the `OPENAI_MAX_RETRIES` environment variable, or to 5 retries.
|
||||
:param prefix:
|
||||
A string to add at the beginning of each text.
|
||||
:param suffix:
|
||||
A string to add at the end of each text.
|
||||
:param default_headers: Default headers to send to the AzureOpenAI client.
|
||||
:param azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on
|
||||
every request.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
|
||||
"""
|
||||
# We intentionally do not call super().__init__ here because we only need to instantiate the client to interact
|
||||
# with the API.
|
||||
|
||||
# Why is this here?
|
||||
# AzureOpenAI init is forcing us to use an init method that takes either base_url or azure_endpoint as not
|
||||
# None init parameters. This way we accommodate the use case where env var AZURE_OPENAI_ENDPOINT is set instead
|
||||
# of passing it as a parameter.
|
||||
azure_endpoint = azure_endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not azure_endpoint:
|
||||
raise ValueError("Please provide an Azure endpoint or set the environment variable AZURE_OPENAI_ENDPOINT.")
|
||||
|
||||
if api_key is None and azure_ad_token is None:
|
||||
raise ValueError("Please provide an API key or an Azure Active Directory token.")
|
||||
|
||||
self.api_key = api_key # type: ignore[assignment] # mypy does not understand that api_key can be None
|
||||
self.azure_ad_token = azure_ad_token
|
||||
self.api_version = api_version
|
||||
self.azure_endpoint = azure_endpoint
|
||||
self.azure_deployment = azure_deployment
|
||||
self.model = azure_deployment
|
||||
self.dimensions = dimensions
|
||||
self.organization = organization
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.default_headers = default_headers or {}
|
||||
self.azure_ad_token_provider = azure_ad_token_provider
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
self.client: AzureOpenAI | None = None
|
||||
self.async_client: AsyncAzureOpenAI | None = None
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_version": self.api_version,
|
||||
"azure_endpoint": self.azure_endpoint,
|
||||
"azure_deployment": self.azure_deployment,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
"api_key": self.api_key.resolve_value() if self.api_key is not None else None,
|
||||
"azure_ad_token": self.azure_ad_token.resolve_value() if self.azure_ad_token is not None else None,
|
||||
"organization": self.organization,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
"default_headers": self.default_headers,
|
||||
}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous Azure OpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous Azure OpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous Azure OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous Azure OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
azure_ad_token_provider_name = None
|
||||
if self.azure_ad_token_provider:
|
||||
azure_ad_token_provider_name = serialize_callable(self.azure_ad_token_provider)
|
||||
return default_to_dict(
|
||||
self,
|
||||
azure_endpoint=self.azure_endpoint,
|
||||
azure_deployment=self.azure_deployment,
|
||||
dimensions=self.dimensions,
|
||||
organization=self.organization,
|
||||
api_version=self.api_version,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
api_key=self.api_key,
|
||||
azure_ad_token=self.azure_ad_token,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
default_headers=self.default_headers,
|
||||
azure_ad_token_provider=azure_ad_token_provider_name,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAITextEmbedder":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
serialized_azure_ad_token_provider = data["init_parameters"].get("azure_ad_token_provider")
|
||||
if serialized_azure_ad_token_provider:
|
||||
data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable(
|
||||
serialized_azure_ad_token_provider
|
||||
)
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,194 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict
|
||||
from haystack.components.embedders.mock_utils import (
|
||||
EmbeddingFn,
|
||||
_coerce_embedding,
|
||||
_deterministic_embedding,
|
||||
_estimate_usage,
|
||||
)
|
||||
from haystack.utils import deserialize_callable, serialize_callable
|
||||
|
||||
|
||||
@component
|
||||
class MockDocumentEmbedder:
|
||||
"""
|
||||
A Document Embedder that returns deterministic embeddings without calling any API.
|
||||
|
||||
It is a drop-in replacement for real Document Embedders (such as `OpenAIDocumentEmbedder`) in tests, smoke tests,
|
||||
and quick prototypes. It implements the same interface (`run`, `run_async`, serialization) but never contacts an
|
||||
external service, so it is fully deterministic and free to run.
|
||||
|
||||
The embedding is selected based on how the component is configured:
|
||||
|
||||
- **Deterministic (default)**: with no configuration, each document's embedding is derived from a hash of its
|
||||
(prepared) text. The same text always yields the same embedding, and different texts yield different
|
||||
embeddings, so the mock works in retrieval pipelines and is reproducible across runs and processes.
|
||||
- **Fixed embedding**: pass an `embedding` vector. The same vector is assigned to every document.
|
||||
- **Dynamic embedding**: pass an `embedding_fn` callable that receives the (prepared) text of a document and
|
||||
returns the embedding. This is useful when the embedding should depend on the input in a custom way.
|
||||
|
||||
Like real Document Embedders, the metadata fields listed in `meta_fields_to_embed` are concatenated with the
|
||||
document content before embedding, so the deterministic embedding reflects the embedded metadata.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import MockDocumentEmbedder
|
||||
|
||||
embedder = MockDocumentEmbedder(dimension=8)
|
||||
result = embedder.run([Document(content="I love pizza!")])
|
||||
print(result["documents"][0].embedding) # a deterministic list of 8 floats
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embedding: list[float] | None = None,
|
||||
*,
|
||||
embedding_fn: EmbeddingFn | None = None,
|
||||
dimension: int = 768,
|
||||
model: str = "mock-model",
|
||||
meta: dict[str, Any] | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
meta_fields_to_embed: list[str] | None = None,
|
||||
embedding_separator: str = "\n",
|
||||
progress_bar: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of MockDocumentEmbedder.
|
||||
|
||||
:param embedding: An optional fixed embedding assigned to every document. Mutually exclusive with
|
||||
`embedding_fn`. If neither is provided, a deterministic embedding is derived from each document's text.
|
||||
:param embedding_fn: An optional callable that receives the prepared text of a document and returns the
|
||||
embedding as a list of floats. Mutually exclusive with `embedding`. To support serialization, pass a
|
||||
named function (lambdas and nested functions cannot be serialized).
|
||||
:param dimension: The number of dimensions of the deterministic embedding. Ignored when `embedding` or
|
||||
`embedding_fn` is provided, since their length is determined by the value or callable.
|
||||
:param model: The model name reported in the metadata. Purely cosmetic; no model is loaded.
|
||||
:param meta: Additional metadata merged into the output `meta`.
|
||||
:param prefix: A string to add at the beginning of each text before embedding.
|
||||
:param suffix: A string to add at the end of each text before embedding.
|
||||
:param meta_fields_to_embed: List of metadata fields to embed along with the document text.
|
||||
:param embedding_separator: Separator used to concatenate the metadata fields to the document text.
|
||||
:param progress_bar: Accepted for interface compatibility with real Document Embedders and ignored.
|
||||
:raises ValueError: If both `embedding` and `embedding_fn` are provided, if `dimension` is not positive, or
|
||||
if `embedding` is an empty list.
|
||||
:raises TypeError: If `embedding` is not a sequence of numbers.
|
||||
"""
|
||||
if embedding is not None and embedding_fn is not None:
|
||||
raise ValueError("Pass either 'embedding' or 'embedding_fn', not both.")
|
||||
if dimension <= 0:
|
||||
raise ValueError("'dimension' must be a positive integer.")
|
||||
|
||||
self.embedding = _coerce_embedding(embedding, name="'embedding'") if embedding is not None else None
|
||||
self.embedding_fn = embedding_fn
|
||||
self.dimension = dimension
|
||||
self.model = model
|
||||
self.meta = meta or {}
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.meta_fields_to_embed = meta_fields_to_embed or []
|
||||
self.embedding_separator = embedding_separator
|
||||
self.progress_bar = progress_bar
|
||||
self._is_warmed_up = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the component to a dictionary."""
|
||||
embedding_fn = serialize_callable(self.embedding_fn) if self.embedding_fn is not None else None
|
||||
return default_to_dict(
|
||||
self,
|
||||
embedding=self.embedding,
|
||||
embedding_fn=embedding_fn,
|
||||
dimension=self.dimension,
|
||||
model=self.model,
|
||||
meta=self.meta,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
meta_fields_to_embed=self.meta_fields_to_embed,
|
||||
embedding_separator=self.embedding_separator,
|
||||
progress_bar=self.progress_bar,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> MockDocumentEmbedder:
|
||||
"""Deserialize the component from a dictionary."""
|
||||
init_params = data.get("init_parameters", {})
|
||||
embedding_fn = init_params.get("embedding_fn")
|
||||
if embedding_fn:
|
||||
init_params["embedding_fn"] = deserialize_callable(embedding_fn)
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""No-op warm up, provided for interface compatibility with real Embedders."""
|
||||
self._is_warmed_up = True
|
||||
|
||||
def _prepare_text_to_embed(self, document: Document) -> str:
|
||||
"""Concatenate the document content with the metadata fields to embed, mirroring real Document Embedders."""
|
||||
meta_values_to_embed = [
|
||||
str(document.meta[key])
|
||||
for key in self.meta_fields_to_embed
|
||||
if key in document.meta and document.meta[key] is not None
|
||||
]
|
||||
return (
|
||||
self.prefix + self.embedding_separator.join([*meta_values_to_embed, document.content or ""]) + self.suffix
|
||||
)
|
||||
|
||||
def _embed(self, text: str) -> list[float]:
|
||||
"""Produce the embedding for the prepared text according to the configured mode."""
|
||||
if self.embedding_fn is not None:
|
||||
return _coerce_embedding(self.embedding_fn(text), name="the return value of 'embedding_fn'")
|
||||
if self.embedding is not None:
|
||||
return list(self.embedding)
|
||||
return _deterministic_embedding(text, self.dimension)
|
||||
|
||||
@component.output_types(documents=list[Document], meta=dict[str, Any])
|
||||
def run(self, documents: list[Document]) -> dict[str, Any]:
|
||||
"""
|
||||
Return the input documents with deterministic embeddings added, without calling any API.
|
||||
|
||||
:param documents: A list of documents to embed.
|
||||
:returns: A dictionary with the following keys:
|
||||
- `documents`: A list of documents with embeddings.
|
||||
- `meta`: Metadata about the (mock) model.
|
||||
:raises TypeError: If `documents` is not a list of `Document` objects.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError(
|
||||
"MockDocumentEmbedder expects a list of Documents as input. "
|
||||
"In case you want to embed a string, please use the MockTextEmbedder."
|
||||
)
|
||||
|
||||
texts_to_embed = [self._prepare_text_to_embed(document) for document in documents]
|
||||
new_documents = [
|
||||
replace(document, embedding=self._embed(text))
|
||||
for document, text in zip(documents, texts_to_embed, strict=True)
|
||||
]
|
||||
|
||||
meta: dict[str, Any] = {"model": self.model, "usage": _estimate_usage(texts_to_embed)}
|
||||
meta.update(self.meta)
|
||||
return {"documents": new_documents, "meta": meta}
|
||||
|
||||
@component.output_types(documents=list[Document], meta=dict[str, Any])
|
||||
async def run_async(self, documents: list[Document]) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously return the input documents with deterministic embeddings added, without calling any API.
|
||||
|
||||
:param documents: A list of documents to embed.
|
||||
:returns: A dictionary with the following keys:
|
||||
- `documents`: A list of documents with embeddings.
|
||||
- `meta`: Metadata about the (mock) model.
|
||||
:raises TypeError: If `documents` is not a list of `Document` objects.
|
||||
"""
|
||||
return self.run(documents=documents)
|
||||
@@ -0,0 +1,161 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.components.embedders.mock_utils import (
|
||||
EmbeddingFn,
|
||||
_coerce_embedding,
|
||||
_deterministic_embedding,
|
||||
_estimate_usage,
|
||||
)
|
||||
from haystack.utils import deserialize_callable, serialize_callable
|
||||
|
||||
|
||||
@component
|
||||
class MockTextEmbedder:
|
||||
"""
|
||||
A Text Embedder that returns deterministic embeddings without calling any API.
|
||||
|
||||
It is a drop-in replacement for real Text Embedders (such as `OpenAITextEmbedder`) in tests, smoke tests, and
|
||||
quick prototypes. It implements the same interface (`run`, `run_async`, serialization) but never contacts an
|
||||
external service, so it is fully deterministic and free to run.
|
||||
|
||||
The embedding is selected based on how the component is configured:
|
||||
|
||||
- **Deterministic (default)**: with no configuration, the embedding is derived from a hash of the input text.
|
||||
The same text always yields the same embedding, and different texts yield different embeddings, so the mock
|
||||
works in retrieval pipelines and is reproducible across runs and processes.
|
||||
- **Fixed embedding**: pass an `embedding` vector. The same vector is returned for every input.
|
||||
- **Dynamic embedding**: pass an `embedding_fn` callable that receives the (prepared) text and returns the
|
||||
embedding. This is useful when the embedding should depend on the input in a custom way.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.embedders import MockTextEmbedder
|
||||
|
||||
embedder = MockTextEmbedder(dimension=8)
|
||||
result = embedder.run("I love pizza!")
|
||||
print(result["embedding"]) # a deterministic list of 8 floats
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embedding: list[float] | None = None,
|
||||
*,
|
||||
embedding_fn: EmbeddingFn | None = None,
|
||||
dimension: int = 768,
|
||||
model: str = "mock-model",
|
||||
meta: dict[str, Any] | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of MockTextEmbedder.
|
||||
|
||||
:param embedding: An optional fixed embedding returned for every input. Mutually exclusive with
|
||||
`embedding_fn`. If neither is provided, a deterministic embedding is derived from the input text.
|
||||
:param embedding_fn: An optional callable that receives the prepared text (after `prefix`/`suffix` are
|
||||
applied) and returns the embedding as a list of floats. Mutually exclusive with `embedding`. To support
|
||||
serialization, pass a named function (lambdas and nested functions cannot be serialized).
|
||||
:param dimension: The number of dimensions of the deterministic embedding. Ignored when `embedding` or
|
||||
`embedding_fn` is provided, since their length is determined by the value or callable.
|
||||
:param model: The model name reported in the metadata. Purely cosmetic; no model is loaded.
|
||||
:param meta: Additional metadata merged into the output `meta`.
|
||||
:param prefix: A string to add at the beginning of the text before embedding.
|
||||
:param suffix: A string to add at the end of the text before embedding.
|
||||
:raises ValueError: If both `embedding` and `embedding_fn` are provided, if `dimension` is not positive, or
|
||||
if `embedding` is an empty list.
|
||||
:raises TypeError: If `embedding` is not a sequence of numbers.
|
||||
"""
|
||||
if embedding is not None and embedding_fn is not None:
|
||||
raise ValueError("Pass either 'embedding' or 'embedding_fn', not both.")
|
||||
if dimension <= 0:
|
||||
raise ValueError("'dimension' must be a positive integer.")
|
||||
|
||||
self.embedding = _coerce_embedding(embedding, name="'embedding'") if embedding is not None else None
|
||||
self.embedding_fn = embedding_fn
|
||||
self.dimension = dimension
|
||||
self.model = model
|
||||
self.meta = meta or {}
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self._is_warmed_up = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the component to a dictionary."""
|
||||
embedding_fn = serialize_callable(self.embedding_fn) if self.embedding_fn is not None else None
|
||||
return default_to_dict(
|
||||
self,
|
||||
embedding=self.embedding,
|
||||
embedding_fn=embedding_fn,
|
||||
dimension=self.dimension,
|
||||
model=self.model,
|
||||
meta=self.meta,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> MockTextEmbedder:
|
||||
"""Deserialize the component from a dictionary."""
|
||||
init_params = data.get("init_parameters", {})
|
||||
embedding_fn = init_params.get("embedding_fn")
|
||||
if embedding_fn:
|
||||
init_params["embedding_fn"] = deserialize_callable(embedding_fn)
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""No-op warm up, provided for interface compatibility with real Embedders."""
|
||||
self._is_warmed_up = True
|
||||
|
||||
def _embed(self, text: str) -> list[float]:
|
||||
"""Produce the embedding for the prepared text according to the configured mode."""
|
||||
if self.embedding_fn is not None:
|
||||
return _coerce_embedding(self.embedding_fn(text), name="the return value of 'embedding_fn'")
|
||||
if self.embedding is not None:
|
||||
return list(self.embedding)
|
||||
return _deterministic_embedding(text, self.dimension)
|
||||
|
||||
@component.output_types(embedding=list[float], meta=dict[str, Any])
|
||||
def run(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Return a deterministic embedding for the input text without calling any API.
|
||||
|
||||
:param text: The text to embed.
|
||||
:returns: A dictionary with the following keys:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Metadata about the (mock) model.
|
||||
:raises TypeError: If `text` is not a string.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(
|
||||
"MockTextEmbedder expects a string as an input. "
|
||||
"In case you want to embed a list of Documents, please use the MockDocumentEmbedder."
|
||||
)
|
||||
|
||||
text_to_embed = self.prefix + text + self.suffix
|
||||
meta: dict[str, Any] = {"model": self.model, "usage": _estimate_usage([text_to_embed])}
|
||||
meta.update(self.meta)
|
||||
return {"embedding": self._embed(text_to_embed), "meta": meta}
|
||||
|
||||
@component.output_types(embedding=list[float], meta=dict[str, Any])
|
||||
async def run_async(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously return a deterministic embedding for the input text without calling any API.
|
||||
|
||||
:param text: The text to embed.
|
||||
:returns: A dictionary with the following keys:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Metadata about the (mock) model.
|
||||
:raises TypeError: If `text` is not a string.
|
||||
"""
|
||||
return self.run(text=text)
|
||||
@@ -0,0 +1,63 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
|
||||
# A callable that derives an embedding from the (prepared) text to embed. It receives the text and returns the
|
||||
# embedding as a list of floats.
|
||||
EmbeddingFn = Callable[[str], list[float]]
|
||||
|
||||
|
||||
def _l2_normalize(vector: list[float]) -> list[float]:
|
||||
"""Return the L2-normalized vector, so that mock embeddings behave like real (unit-length) ones."""
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
if norm == 0.0:
|
||||
return vector
|
||||
return [value / norm for value in vector]
|
||||
|
||||
|
||||
def _deterministic_embedding(text: str, dimension: int) -> list[float]:
|
||||
"""
|
||||
Generate a deterministic, unit-length embedding from the given text.
|
||||
|
||||
The same text always yields the same embedding, and different texts yield different embeddings, which makes mock
|
||||
embeddings usable in retrieval pipelines and reproducible across runs and processes. The seed is derived from a
|
||||
SHA-256 digest of the text (not the process-salted built-in `hash`) to guarantee cross-process stability.
|
||||
|
||||
:param text: The text to embed.
|
||||
:param dimension: The number of dimensions of the resulting embedding.
|
||||
:returns: A deterministic, L2-normalized embedding of length `dimension`.
|
||||
"""
|
||||
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
||||
seed = int.from_bytes(digest[:8], "big")
|
||||
rng = random.Random(seed)
|
||||
vector = [rng.uniform(-1.0, 1.0) for _ in range(dimension)]
|
||||
return _l2_normalize(vector)
|
||||
|
||||
|
||||
def _coerce_embedding(value: object, *, name: str) -> list[float]:
|
||||
"""
|
||||
Validate that `value` is a non-empty sequence of numbers and coerce it into a list of floats.
|
||||
|
||||
:param value: The value to validate, e.g. a user-provided fixed embedding or the output of an `embedding_fn`.
|
||||
:param name: How to refer to `value` in error messages, e.g. ``"'embedding'"``.
|
||||
"""
|
||||
if not isinstance(value, (list, tuple)) or not all(isinstance(item, (int, float)) for item in value):
|
||||
raise TypeError(f"{name} must be a sequence of numbers, got {type(value)}.")
|
||||
if len(value) == 0:
|
||||
raise ValueError(f"{name} must not be empty.")
|
||||
return [float(item) for item in value]
|
||||
|
||||
|
||||
def _estimate_usage(texts: list[str]) -> dict[str, int]:
|
||||
"""
|
||||
Roughly estimate token usage as whitespace-separated word counts.
|
||||
|
||||
This is an approximation (not real tokenization) intended to give downstream code realistic-looking metadata.
|
||||
"""
|
||||
prompt_tokens = sum(len(text.split()) for text in texts)
|
||||
return {"prompt_tokens": prompt_tokens, "total_tokens": prompt_tokens}
|
||||
@@ -0,0 +1,390 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from more_itertools import batched
|
||||
from openai import APIError, AsyncOpenAI, OpenAI
|
||||
from tqdm import tqdm
|
||||
from tqdm.asyncio import tqdm as async_tqdm
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.utils import Secret
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class OpenAIDocumentEmbedder:
|
||||
"""
|
||||
Computes document embeddings using OpenAI models.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import OpenAIDocumentEmbedder
|
||||
|
||||
doc = Document(content="I love pizza!")
|
||||
document_embedder = OpenAIDocumentEmbedder()
|
||||
result = document_embedder.run([doc])
|
||||
|
||||
print(result['documents'][0].embedding)
|
||||
|
||||
# [0.017020374536514282, -0.023255806416273117, ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913 (too-many-arguments)
|
||||
self,
|
||||
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
|
||||
model: str = "text-embedding-ada-002",
|
||||
dimensions: int | None = None,
|
||||
api_base_url: str | None = None,
|
||||
organization: str | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
batch_size: int = 32,
|
||||
progress_bar: bool = True,
|
||||
meta_fields_to_embed: list[str] | None = None,
|
||||
embedding_separator: str = "\n",
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
raise_on_failure: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an OpenAIDocumentEmbedder component.
|
||||
|
||||
Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
|
||||
environment variables to override the `timeout` and `max_retries` parameters respectively
|
||||
in the OpenAI client.
|
||||
|
||||
:param api_key:
|
||||
The OpenAI API key.
|
||||
You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
|
||||
during initialization.
|
||||
:param model:
|
||||
The name of the model to use for calculating embeddings.
|
||||
The default model is `text-embedding-ada-002`.
|
||||
:param dimensions:
|
||||
The number of dimensions of the resulting embeddings. Only `text-embedding-3` and
|
||||
later models support this parameter.
|
||||
:param api_base_url:
|
||||
Overrides the default base URL for all HTTP requests.
|
||||
:param organization:
|
||||
Your OpenAI organization ID. See OpenAI's
|
||||
[Setting Up Your Organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization)
|
||||
for more information.
|
||||
:param prefix:
|
||||
A string to add at the beginning of each text.
|
||||
:param suffix:
|
||||
A string to add at the end of each text.
|
||||
:param batch_size:
|
||||
Number of documents to embed at once.
|
||||
:param progress_bar:
|
||||
If `True`, shows a progress bar when running.
|
||||
:param meta_fields_to_embed:
|
||||
List of metadata fields to embed along with the document text.
|
||||
:param embedding_separator:
|
||||
Separator used to concatenate the metadata fields to the document text.
|
||||
:param timeout:
|
||||
Timeout for OpenAI client calls. If not set, it defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries:
|
||||
Maximum number of retries to contact OpenAI after an internal error.
|
||||
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or 5 retries.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
:param raise_on_failure:
|
||||
Whether to raise an exception if the embedding request fails. If `False`, the component will log the error
|
||||
and continue processing the remaining documents. If `True`, it will raise an exception on failure.
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.dimensions = dimensions
|
||||
self.api_base_url = api_base_url
|
||||
self.organization = organization
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.batch_size = batch_size
|
||||
self.progress_bar = progress_bar
|
||||
self.meta_fields_to_embed = meta_fields_to_embed or []
|
||||
self.embedding_separator = embedding_separator
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
self.raise_on_failure = raise_on_failure
|
||||
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_key": self.api_key.resolve_value(),
|
||||
"organization": self.organization,
|
||||
"base_url": self.api_base_url,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = OpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"model": self.model}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
api_key=self.api_key,
|
||||
model=self.model,
|
||||
dimensions=self.dimensions,
|
||||
api_base_url=self.api_base_url,
|
||||
organization=self.organization,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
batch_size=self.batch_size,
|
||||
progress_bar=self.progress_bar,
|
||||
meta_fields_to_embed=self.meta_fields_to_embed,
|
||||
embedding_separator=self.embedding_separator,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OpenAIDocumentEmbedder":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _prepare_texts_to_embed(self, documents: list[Document]) -> dict[str, str]:
|
||||
"""
|
||||
Prepare the texts to embed by concatenating the Document text with the metadata fields to embed.
|
||||
"""
|
||||
texts_to_embed = {}
|
||||
for doc in documents:
|
||||
meta_values_to_embed = [
|
||||
str(doc.meta[key]) for key in self.meta_fields_to_embed if key in doc.meta and doc.meta[key] is not None
|
||||
]
|
||||
|
||||
texts_to_embed[doc.id] = (
|
||||
self.prefix + self.embedding_separator.join(meta_values_to_embed + [doc.content or ""]) + self.suffix
|
||||
)
|
||||
|
||||
return texts_to_embed
|
||||
|
||||
def _embed_batch(
|
||||
self, texts_to_embed: dict[str, str], batch_size: int
|
||||
) -> tuple[dict[str, list[float]], dict[str, Any]]:
|
||||
"""
|
||||
Embed a list of texts in batches.
|
||||
"""
|
||||
|
||||
doc_ids_to_embeddings: dict[str, list[float]] = {}
|
||||
meta: dict[str, Any] = {}
|
||||
for batch in tqdm(
|
||||
batched(texts_to_embed.items(), batch_size), disable=not self.progress_bar, desc="Calculating embeddings"
|
||||
):
|
||||
args: dict[str, Any] = {"model": self.model, "input": [b[1] for b in batch], "encoding_format": "float"}
|
||||
|
||||
if self.dimensions is not None:
|
||||
args["dimensions"] = self.dimensions
|
||||
|
||||
try:
|
||||
# this method is invoked after warm_up, so client is not None
|
||||
assert self.client is not None
|
||||
response = self.client.embeddings.create(**args)
|
||||
except APIError as exc:
|
||||
ids = ", ".join(b[0] for b in batch)
|
||||
msg = "Failed embedding of documents {ids} caused by {exc}"
|
||||
logger.exception(msg, ids=ids, exc=exc)
|
||||
if self.raise_on_failure:
|
||||
raise exc
|
||||
continue
|
||||
|
||||
embeddings = [el.embedding for el in response.data]
|
||||
doc_ids_to_embeddings.update(dict(zip((b[0] for b in batch), embeddings, strict=True)))
|
||||
|
||||
if "model" not in meta:
|
||||
meta["model"] = response.model
|
||||
if "usage" not in meta:
|
||||
meta["usage"] = dict(response.usage)
|
||||
else:
|
||||
meta["usage"]["prompt_tokens"] += response.usage.prompt_tokens
|
||||
meta["usage"]["total_tokens"] += response.usage.total_tokens
|
||||
|
||||
return doc_ids_to_embeddings, meta
|
||||
|
||||
async def _embed_batch_async(
|
||||
self, texts_to_embed: dict[str, str], batch_size: int
|
||||
) -> tuple[dict[str, list[float]], dict[str, Any]]:
|
||||
"""
|
||||
Embed a list of texts in batches asynchronously.
|
||||
"""
|
||||
|
||||
doc_ids_to_embeddings: dict[str, list[float]] = {}
|
||||
meta: dict[str, Any] = {}
|
||||
|
||||
batches = list(batched(texts_to_embed.items(), batch_size))
|
||||
if self.progress_bar:
|
||||
batches = async_tqdm(batches, desc="Calculating embeddings")
|
||||
|
||||
for batch in batches:
|
||||
args: dict[str, Any] = {"model": self.model, "input": [b[1] for b in batch]}
|
||||
|
||||
if self.dimensions is not None:
|
||||
args["dimensions"] = self.dimensions
|
||||
|
||||
try:
|
||||
# this method is invoked after warm_up_async, so async_client is not None
|
||||
assert self.async_client is not None
|
||||
response = await self.async_client.embeddings.create(**args)
|
||||
except APIError as exc:
|
||||
ids = ", ".join(b[0] for b in batch)
|
||||
msg = "Failed embedding of documents {ids} caused by {exc}"
|
||||
logger.exception(msg, ids=ids, exc=exc)
|
||||
if self.raise_on_failure:
|
||||
raise exc
|
||||
continue
|
||||
|
||||
embeddings = [el.embedding for el in response.data]
|
||||
doc_ids_to_embeddings.update(dict(zip((b[0] for b in batch), embeddings, strict=True)))
|
||||
|
||||
if "model" not in meta:
|
||||
meta["model"] = response.model
|
||||
if "usage" not in meta:
|
||||
meta["usage"] = dict(response.usage)
|
||||
else:
|
||||
meta["usage"]["prompt_tokens"] += response.usage.prompt_tokens
|
||||
meta["usage"]["total_tokens"] += response.usage.total_tokens
|
||||
|
||||
return doc_ids_to_embeddings, meta
|
||||
|
||||
@component.output_types(documents=list[Document], meta=dict[str, Any])
|
||||
def run(self, documents: list[Document]) -> dict[str, Any]:
|
||||
"""
|
||||
Embeds a list of documents.
|
||||
|
||||
:param documents:
|
||||
A list of documents to embed.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of documents with embeddings.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
if not isinstance(documents, list) or documents and not isinstance(documents[0], Document):
|
||||
raise TypeError(
|
||||
"OpenAIDocumentEmbedder expects a list of Documents as input."
|
||||
"In case you want to embed a string, please use the OpenAITextEmbedder."
|
||||
)
|
||||
|
||||
self.warm_up()
|
||||
|
||||
texts_to_embed = self._prepare_texts_to_embed(documents=documents)
|
||||
|
||||
doc_ids_to_embeddings, meta = self._embed_batch(texts_to_embed=texts_to_embed, batch_size=self.batch_size)
|
||||
|
||||
new_documents = []
|
||||
for doc in documents:
|
||||
if doc.id in doc_ids_to_embeddings:
|
||||
new_documents.append(replace(doc, embedding=doc_ids_to_embeddings[doc.id]))
|
||||
else:
|
||||
new_documents.append(replace(doc))
|
||||
|
||||
return {"documents": new_documents, "meta": meta}
|
||||
|
||||
@component.output_types(documents=list[Document], meta=dict[str, Any])
|
||||
async def run_async(self, documents: list[Document]) -> dict[str, Any]:
|
||||
"""
|
||||
Embeds a list of documents asynchronously.
|
||||
|
||||
:param documents:
|
||||
A list of documents to embed.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of documents with embeddings.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
if not isinstance(documents, list) or documents and not isinstance(documents[0], Document):
|
||||
raise TypeError(
|
||||
"OpenAIDocumentEmbedder expects a list of Documents as input. "
|
||||
"In case you want to embed a string, please use the OpenAITextEmbedder."
|
||||
)
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
texts_to_embed = self._prepare_texts_to_embed(documents=documents)
|
||||
|
||||
doc_ids_to_embeddings, meta = await self._embed_batch_async(
|
||||
texts_to_embed=texts_to_embed, batch_size=self.batch_size
|
||||
)
|
||||
|
||||
new_documents = []
|
||||
for doc in documents:
|
||||
if doc.id in doc_ids_to_embeddings:
|
||||
new_documents.append(replace(doc, embedding=doc_ids_to_embeddings[doc.id]))
|
||||
else:
|
||||
new_documents.append(replace(doc))
|
||||
|
||||
return {"documents": new_documents, "meta": meta}
|
||||
@@ -0,0 +1,245 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
from openai.types import CreateEmbeddingResponse
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.utils import Secret
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
|
||||
@component
|
||||
class OpenAITextEmbedder:
|
||||
"""
|
||||
Embeds strings using OpenAI models.
|
||||
|
||||
You can use it to embed user query and send it to an embedding Retriever.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.embedders import OpenAITextEmbedder
|
||||
|
||||
text_to_embed = "I love pizza!"
|
||||
text_embedder = OpenAITextEmbedder()
|
||||
|
||||
print(text_embedder.run(text_to_embed))
|
||||
|
||||
# {'embedding': [0.017020374536514282, -0.023255806416273117, ...],
|
||||
# 'meta': {'model': 'text-embedding-ada-002-v2',
|
||||
# 'usage': {'prompt_tokens': 4, 'total_tokens': 4}}}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
|
||||
model: str = "text-embedding-ada-002",
|
||||
dimensions: int | None = None,
|
||||
api_base_url: str | None = None,
|
||||
organization: str | None = None,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an OpenAITextEmbedder component.
|
||||
|
||||
Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
|
||||
environment variables to override the `timeout` and `max_retries` parameters respectively
|
||||
in the OpenAI client.
|
||||
|
||||
:param api_key:
|
||||
The OpenAI API key.
|
||||
You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
|
||||
during initialization.
|
||||
:param model:
|
||||
The name of the model to use for calculating embeddings.
|
||||
The default model is `text-embedding-ada-002`.
|
||||
:param dimensions:
|
||||
The number of dimensions of the resulting embeddings. Only `text-embedding-3` and
|
||||
later models support this parameter.
|
||||
:param api_base_url:
|
||||
Overrides default base URL for all HTTP requests.
|
||||
:param organization:
|
||||
Your organization ID. See OpenAI's
|
||||
[production best practices](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization)
|
||||
for more information.
|
||||
:param prefix:
|
||||
A string to add at the beginning of each text to embed.
|
||||
:param suffix:
|
||||
A string to add at the end of each text to embed.
|
||||
:param timeout:
|
||||
Timeout for OpenAI client calls. If not set, it defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries:
|
||||
Maximum number of retries to contact OpenAI after an internal error.
|
||||
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
"""
|
||||
self.model = model
|
||||
self.dimensions = dimensions
|
||||
self.api_base_url = api_base_url
|
||||
self.organization = organization
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_key": self.api_key.resolve_value(),
|
||||
"organization": self.organization,
|
||||
"base_url": self.api_base_url,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = OpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"model": self.model}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
api_key=self.api_key,
|
||||
model=self.model,
|
||||
dimensions=self.dimensions,
|
||||
api_base_url=self.api_base_url,
|
||||
organization=self.organization,
|
||||
prefix=self.prefix,
|
||||
suffix=self.suffix,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OpenAITextEmbedder":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _prepare_input(self, text: str) -> dict[str, Any]:
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(
|
||||
"OpenAITextEmbedder expects a string as an input."
|
||||
"In case you want to embed a list of Documents, please use the OpenAIDocumentEmbedder."
|
||||
)
|
||||
|
||||
text_to_embed = self.prefix + text + self.suffix
|
||||
|
||||
kwargs: dict[str, Any] = {"model": self.model, "input": text_to_embed, "encoding_format": "float"}
|
||||
if self.dimensions is not None:
|
||||
kwargs["dimensions"] = self.dimensions
|
||||
return kwargs
|
||||
|
||||
def _prepare_output(self, result: CreateEmbeddingResponse) -> dict[str, Any]:
|
||||
return {"embedding": result.data[0].embedding, "meta": {"model": result.model, "usage": dict(result.usage)}}
|
||||
|
||||
@component.output_types(embedding=list[float], meta=dict[str, Any])
|
||||
def run(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Embeds a single string.
|
||||
|
||||
:param text:
|
||||
Text to embed.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
self.warm_up()
|
||||
create_kwargs = self._prepare_input(text=text)
|
||||
assert self.client is not None # mypy: client is built by warm_up above
|
||||
response = self.client.embeddings.create(**create_kwargs)
|
||||
return self._prepare_output(result=response)
|
||||
|
||||
@component.output_types(embedding=list[float], meta=dict[str, Any])
|
||||
async def run_async(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously embed a single string.
|
||||
|
||||
This is the asynchronous version of the `run` method. It has the same parameters and return values
|
||||
but can be used with `await` in async code.
|
||||
|
||||
:param text:
|
||||
Text to embed.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
create_kwargs = self._prepare_input(text=text)
|
||||
assert self.async_client is not None # mypy: async_client is built by warm_up_async above
|
||||
response = await self.async_client.embeddings.create(**create_kwargs)
|
||||
return self._prepare_output(result=response)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .protocol import DocumentEmbedder, TextEmbedder
|
||||
|
||||
__all__ = ["DocumentEmbedder", "TextEmbedder"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from haystack import Document
|
||||
|
||||
|
||||
class TextEmbedder(Protocol):
|
||||
"""
|
||||
Protocol for Text Embedders.
|
||||
"""
|
||||
|
||||
def run(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate embeddings for the input text.
|
||||
|
||||
Implementing classes may accept additional optional parameters in their run method.
|
||||
For example: `def run (self, text: str, param_a="default", param_b="another_default")`.
|
||||
|
||||
:param text:
|
||||
The input text to be embedded.
|
||||
:returns:
|
||||
A dictionary containing the keys:
|
||||
- 'embedding', which is expected to be a list[float] representing the embedding.
|
||||
- any optional keys such as 'metadata'.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DocumentEmbedder(Protocol):
|
||||
"""
|
||||
Protocol for Document Embedders.
|
||||
"""
|
||||
|
||||
def run(self, documents: list[Document]) -> dict[str, Any]:
|
||||
"""
|
||||
Generate embeddings for the input documents.
|
||||
|
||||
Implementing classes may accept additional optional parameters in their run method.
|
||||
For example: `def run (self, documents: list[Document], param_a="default", param_b="another_default")`.
|
||||
|
||||
:param documents:
|
||||
The input documents to be embedded.
|
||||
:returns:
|
||||
A dictionary containing the keys:
|
||||
- 'documents', which is expected to be a list[Document] with embeddings added to each document.
|
||||
- any optional keys such as 'metadata'.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"answer_exact_match": ["AnswerExactMatchEvaluator"],
|
||||
"context_relevance": ["ContextRelevanceEvaluator"],
|
||||
"document_map": ["DocumentMAPEvaluator"],
|
||||
"document_mrr": ["DocumentMRREvaluator"],
|
||||
"document_ndcg": ["DocumentNDCGEvaluator"],
|
||||
"document_recall": ["DocumentRecallEvaluator"],
|
||||
"faithfulness": ["FaithfulnessEvaluator"],
|
||||
"llm_evaluator": ["LLMEvaluator"],
|
||||
"sas_evaluator": ["SASEvaluator"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .answer_exact_match import AnswerExactMatchEvaluator as AnswerExactMatchEvaluator
|
||||
from .context_relevance import ContextRelevanceEvaluator as ContextRelevanceEvaluator
|
||||
from .document_map import DocumentMAPEvaluator as DocumentMAPEvaluator
|
||||
from .document_mrr import DocumentMRREvaluator as DocumentMRREvaluator
|
||||
from .document_ndcg import DocumentNDCGEvaluator as DocumentNDCGEvaluator
|
||||
from .document_recall import DocumentRecallEvaluator as DocumentRecallEvaluator
|
||||
from .faithfulness import FaithfulnessEvaluator as FaithfulnessEvaluator
|
||||
from .llm_evaluator import LLMEvaluator as LLMEvaluator
|
||||
from .sas_evaluator import SASEvaluator as SASEvaluator
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack.core.component import component
|
||||
|
||||
|
||||
@component
|
||||
class AnswerExactMatchEvaluator:
|
||||
"""
|
||||
An answer exact match evaluator class.
|
||||
|
||||
The evaluator that checks if the predicted answers matches any of the ground truth answers exactly.
|
||||
The result is a number from 0.0 to 1.0, it represents the proportion of predicted answers
|
||||
that matched one of the ground truth answers.
|
||||
There can be multiple ground truth answers and multiple predicted answers as input.
|
||||
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.evaluators import AnswerExactMatchEvaluator
|
||||
|
||||
evaluator = AnswerExactMatchEvaluator()
|
||||
result = evaluator.run(
|
||||
ground_truth_answers=["Berlin", "Paris"],
|
||||
predicted_answers=["Berlin", "Lyon"],
|
||||
)
|
||||
|
||||
print(result["individual_scores"])
|
||||
# [1, 0]
|
||||
print(result["score"])
|
||||
# 0.5
|
||||
```
|
||||
"""
|
||||
|
||||
@component.output_types(individual_scores=list[int], score=float)
|
||||
def run(self, ground_truth_answers: list[str], predicted_answers: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
Run the AnswerExactMatchEvaluator on the given inputs.
|
||||
|
||||
The `ground_truth_answers` and `retrieved_answers` must have the same length.
|
||||
|
||||
:param ground_truth_answers:
|
||||
A list of expected answers.
|
||||
:param predicted_answers:
|
||||
A list of predicted answers.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `individual_scores` - A list of 0s and 1s, where 1 means that the predicted answer matched one of the
|
||||
ground truth.
|
||||
- `score` - A number from 0.0 to 1.0 that represents the proportion of questions where any predicted
|
||||
answer matched one of the ground truth answers.
|
||||
"""
|
||||
if not len(ground_truth_answers) == len(predicted_answers):
|
||||
raise ValueError("The length of ground_truth_answers and predicted_answers must be the same.")
|
||||
|
||||
matches = []
|
||||
for truth, extracted in zip(ground_truth_answers, predicted_answers, strict=True):
|
||||
if truth == extracted:
|
||||
matches.append(1)
|
||||
else:
|
||||
matches.append(0)
|
||||
|
||||
# The proportion of questions where any predicted answer matched one of the ground truth answers
|
||||
average = sum(matches) / len(predicted_answers)
|
||||
|
||||
return {"individual_scores": matches, "score": average}
|
||||
@@ -0,0 +1,257 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import math
|
||||
from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.evaluators.llm_evaluator import LLMEvaluator
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.utils import deserialize_chatgenerator_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Private global variable for default examples to include in the prompt if the user does not provide any examples
|
||||
_DEFAULT_EXAMPLES = [
|
||||
{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of Germany?",
|
||||
"contexts": ["Berlin is the capital of Germany. Berlin and was founded in 1244."],
|
||||
},
|
||||
"outputs": {"relevant_statements": ["Berlin is the capital of Germany."]},
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of France?",
|
||||
"contexts": [
|
||||
"Berlin is the capital of Germany and was founded in 1244.",
|
||||
"Europe is a continent with 44 countries.",
|
||||
"Madrid is the capital of Spain.",
|
||||
],
|
||||
},
|
||||
"outputs": {"relevant_statements": []},
|
||||
},
|
||||
{
|
||||
"inputs": {"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."]},
|
||||
"outputs": {"relevant_statements": ["Rome is the capital of Italy."]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@component
|
||||
class ContextRelevanceEvaluator(LLMEvaluator):
|
||||
"""
|
||||
Evaluator that checks if a provided context is relevant to the question.
|
||||
|
||||
An LLM breaks up a context into multiple statements and checks whether each statement
|
||||
is relevant for answering a question.
|
||||
The score for each context is either binary score of 1 or 0, where 1 indicates that the context is relevant
|
||||
to the question and 0 indicates that the context is not relevant.
|
||||
The evaluator also provides the relevant statements from the context and an average score over all the provided
|
||||
input questions contexts pairs.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.evaluators import ContextRelevanceEvaluator
|
||||
|
||||
questions = ["Who created the Python language?", "Why does Java needs a JVM?", "Is C++ better than Python?"]
|
||||
contexts = [
|
||||
[(
|
||||
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
|
||||
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
|
||||
"programmers write clear, logical code for both small and large-scale software projects."
|
||||
)],
|
||||
[(
|
||||
"Java is a high-level, class-based, object-oriented programming language that is designed to have as few "
|
||||
"implementation dependencies as possible. The JVM has two primary functions: to allow Java programs to run"
|
||||
"on any device or operating system (known as the 'write once, run anywhere' principle), and to manage and"
|
||||
"optimize program memory."
|
||||
)],
|
||||
[(
|
||||
"C++ is a general-purpose programming language created by Bjarne Stroustrup as an extension of the C "
|
||||
"programming language."
|
||||
)],
|
||||
]
|
||||
|
||||
evaluator = ContextRelevanceEvaluator()
|
||||
result = evaluator.run(questions=questions, contexts=contexts)
|
||||
print(result["score"])
|
||||
# 0.67
|
||||
print(result["individual_scores"])
|
||||
# [1,1,0]
|
||||
print(result["results"])
|
||||
# [{
|
||||
# 'relevant_statements': ['Python, created by Guido van Rossum in the late 1980s.'],
|
||||
# 'score': 1.0
|
||||
# },
|
||||
# {
|
||||
# 'relevant_statements': ['The JVM has two primary functions: to allow Java programs to run on any device or
|
||||
# operating system (known as the "write once, run anywhere" principle), and to manage and
|
||||
# optimize program memory'],
|
||||
# 'score': 1.0
|
||||
# },
|
||||
# {
|
||||
# 'relevant_statements': [],
|
||||
# 'score': 0.0
|
||||
# }]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
examples: list[dict[str, Any]] | None = None,
|
||||
progress_bar: bool = True,
|
||||
raise_on_failure: bool = True,
|
||||
chat_generator: ChatGenerator | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of ContextRelevanceEvaluator.
|
||||
|
||||
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
|
||||
|
||||
:param examples:
|
||||
Optional few-shot examples conforming to the expected input and output format of ContextRelevanceEvaluator.
|
||||
Default examples will be used if none are provided.
|
||||
Each example must be a dictionary with keys "inputs" and "outputs".
|
||||
"inputs" must be a dictionary with keys "questions" and "contexts".
|
||||
"outputs" must be a dictionary with "relevant_statements".
|
||||
Expected format:
|
||||
```python
|
||||
[{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."],
|
||||
},
|
||||
"outputs": {
|
||||
"relevant_statements": ["Rome is the capital of Italy."],
|
||||
},
|
||||
}]
|
||||
```
|
||||
:param progress_bar:
|
||||
Whether to show a progress bar during the evaluation.
|
||||
:param raise_on_failure:
|
||||
Whether to raise an exception if the API call fails.
|
||||
:param chat_generator:
|
||||
a ChatGenerator instance which represents the LLM.
|
||||
In order for the component to work, the LLM should be configured to return a JSON object. For example,
|
||||
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
|
||||
`generation_kwargs`.
|
||||
"""
|
||||
|
||||
self.instructions = (
|
||||
"Please extract only sentences from the provided context which are absolutely relevant and "
|
||||
"required to answer the following question. If no relevant sentences are found, or if you "
|
||||
"believe the question cannot be answered from the given context, return an empty list, example: []"
|
||||
)
|
||||
self.inputs = [("questions", list[str]), ("contexts", list[list[str]])]
|
||||
self.outputs = ["relevant_statements"]
|
||||
self.examples = examples or _DEFAULT_EXAMPLES
|
||||
|
||||
super(ContextRelevanceEvaluator, self).__init__( # noqa: UP008
|
||||
instructions=self.instructions,
|
||||
inputs=self.inputs,
|
||||
outputs=self.outputs,
|
||||
examples=self.examples,
|
||||
chat_generator=chat_generator,
|
||||
raise_on_failure=raise_on_failure,
|
||||
progress_bar=progress_bar,
|
||||
)
|
||||
|
||||
@component.output_types(score=float, results=list[dict[str, Any]])
|
||||
def run(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator.
|
||||
|
||||
:param questions:
|
||||
A list of questions.
|
||||
:param contexts:
|
||||
A list of lists of contexts. Each list of contexts corresponds to one question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score`: Mean context relevance score over all the provided input questions.
|
||||
- `results`: A list of dictionaries with `relevant_statements` and `score` for each input context.
|
||||
"""
|
||||
result = super(ContextRelevanceEvaluator, self).run(**inputs) # noqa: UP008
|
||||
# Post-process the raw results to calculate relevance metrics and scores
|
||||
return self._postprocess_results(result)
|
||||
|
||||
@component.output_types(score=float, results=list[dict[str, Any]])
|
||||
async def run_async(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator asynchronously.
|
||||
|
||||
:param questions:
|
||||
A list of questions.
|
||||
:param contexts:
|
||||
A list of lists of contexts. Each list of contexts corresponds to one question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score`: Mean context relevance score over all the provided input questions.
|
||||
- `results`: A list of dictionaries with `relevant_statements` and `score` for each input context.
|
||||
"""
|
||||
result = await super(ContextRelevanceEvaluator, self).run_async(**inputs) # noqa: UP008
|
||||
# Post-process the raw results to calculate relevance metrics and scores
|
||||
return self._postprocess_results(result)
|
||||
|
||||
def _postprocess_results(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Post-processes raw LLM evaluator outputs to compute context relevance scores.
|
||||
|
||||
Calculates binary scores based on whether relevant statements were found,
|
||||
averages the scores across all successful queries, and updates the result payload.
|
||||
|
||||
:param result:
|
||||
The raw evaluation dictionary from the base LLM evaluator.
|
||||
:returns:
|
||||
The updated dictionary containing final scores and tracking metrics.
|
||||
"""
|
||||
for idx, res in enumerate(result["results"]):
|
||||
if res is None:
|
||||
result["results"][idx] = {"relevant_statements": [], "score": float("nan")}
|
||||
continue
|
||||
if len(res["relevant_statements"]) > 0:
|
||||
res["score"] = 1
|
||||
else:
|
||||
res["score"] = 0
|
||||
|
||||
# calculate average context relevance score over all queries
|
||||
scores = [res["score"] for res in result["results"]]
|
||||
valid_scores = [s for s in scores if not math.isnan(s)]
|
||||
skipped = len(scores) - len(valid_scores)
|
||||
if skipped:
|
||||
logger.warning("{skipped} query(s) failed and were excluded from the score.", skipped=skipped)
|
||||
result["score"] = mean(valid_scores) if valid_scores else float("nan")
|
||||
result["individual_scores"] = scores # useful for the EvaluationRunResult
|
||||
|
||||
return result
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
examples=self.examples,
|
||||
progress_bar=self.progress_bar,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ContextRelevanceEvaluator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
if data["init_parameters"].get("chat_generator"):
|
||||
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,136 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_to_dict
|
||||
|
||||
|
||||
@component
|
||||
class DocumentMAPEvaluator:
|
||||
"""
|
||||
A Mean Average Precision (MAP) evaluator for documents.
|
||||
|
||||
Evaluator that calculates the mean average precision of the retrieved documents, a metric
|
||||
that measures how high retrieved documents are ranked.
|
||||
Each question can have multiple ground truth documents and multiple retrieved documents.
|
||||
|
||||
`DocumentMAPEvaluator` doesn't normalize its inputs, the `DocumentCleaner` component
|
||||
should be used to clean and normalize the documents before passing them to this evaluator.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.evaluators import DocumentMAPEvaluator
|
||||
|
||||
evaluator = DocumentMAPEvaluator()
|
||||
result = evaluator.run(
|
||||
ground_truth_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="9th")],
|
||||
],
|
||||
retrieved_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
|
||||
],
|
||||
)
|
||||
|
||||
print(result["individual_scores"])
|
||||
# [1.0, 0.8333333333333333]
|
||||
print(result["score"])
|
||||
# 0.9166666666666666
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, document_comparison_field: str = "content") -> None:
|
||||
"""
|
||||
Create a DocumentMAPEvaluator component.
|
||||
|
||||
:param document_comparison_field:
|
||||
The Document field to use for comparison. Possible options:
|
||||
- `"content"`: uses `doc.content`
|
||||
- `"id"`: uses `doc.id`
|
||||
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
|
||||
(e.g. `"meta.file_id"`, `"meta.page_number"`)
|
||||
Nested keys are supported (e.g. `"meta.source.url"`).
|
||||
"""
|
||||
self.document_comparison_field = document_comparison_field
|
||||
|
||||
def _get_comparison_value(self, doc: Document) -> Any:
|
||||
"""
|
||||
Extract the comparison value from a document based on the configured field.
|
||||
"""
|
||||
if self.document_comparison_field == "content":
|
||||
return doc.content
|
||||
if self.document_comparison_field == "id":
|
||||
return doc.id
|
||||
if self.document_comparison_field.startswith("meta."):
|
||||
parts = self.document_comparison_field[5:].split(".")
|
||||
value = doc.meta
|
||||
for part in parts:
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
msg = (
|
||||
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
|
||||
"Use 'content', 'id', or 'meta.<key>'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
|
||||
|
||||
# Refer to https://www.pinecone.io/learn/offline-evaluation/ for the algorithm.
|
||||
@component.output_types(score=float, individual_scores=list[float])
|
||||
def run(
|
||||
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run the DocumentMAPEvaluator on the given inputs.
|
||||
|
||||
All lists must have the same length.
|
||||
|
||||
:param ground_truth_documents:
|
||||
A list of expected documents for each question.
|
||||
:param retrieved_documents:
|
||||
A list of retrieved documents for each question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score` - The average of calculated scores.
|
||||
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents how high retrieved documents
|
||||
are ranked.
|
||||
"""
|
||||
if len(ground_truth_documents) != len(retrieved_documents):
|
||||
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
|
||||
raise ValueError(msg)
|
||||
|
||||
individual_scores = []
|
||||
|
||||
for ground_truth, retrieved in zip(ground_truth_documents, retrieved_documents, strict=True):
|
||||
average_precision = 0.0
|
||||
average_precision_numerator = 0.0
|
||||
relevant_documents = 0
|
||||
|
||||
ground_truth_values = [val for doc in ground_truth if (val := self._get_comparison_value(doc)) is not None]
|
||||
for rank, retrieved_document in enumerate(retrieved):
|
||||
retrieved_value = self._get_comparison_value(retrieved_document)
|
||||
if retrieved_value is None:
|
||||
continue
|
||||
|
||||
if retrieved_value in ground_truth_values:
|
||||
relevant_documents += 1
|
||||
average_precision_numerator += relevant_documents / (rank + 1)
|
||||
if relevant_documents > 0:
|
||||
average_precision = average_precision_numerator / relevant_documents
|
||||
individual_scores.append(average_precision)
|
||||
|
||||
score = sum(individual_scores) / len(ground_truth_documents)
|
||||
return {"score": score, "individual_scores": individual_scores}
|
||||
@@ -0,0 +1,130 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_to_dict
|
||||
|
||||
|
||||
@component
|
||||
class DocumentMRREvaluator:
|
||||
"""
|
||||
Evaluator that calculates the mean reciprocal rank of the retrieved documents.
|
||||
|
||||
MRR measures how high the first retrieved document is ranked.
|
||||
Each question can have multiple ground truth documents and multiple retrieved documents.
|
||||
|
||||
`DocumentMRREvaluator` doesn't normalize its inputs, the `DocumentCleaner` component
|
||||
should be used to clean and normalize the documents before passing them to this evaluator.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.evaluators import DocumentMRREvaluator
|
||||
|
||||
evaluator = DocumentMRREvaluator()
|
||||
result = evaluator.run(
|
||||
ground_truth_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="9th")],
|
||||
],
|
||||
retrieved_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
|
||||
],
|
||||
)
|
||||
print(result["individual_scores"])
|
||||
# [1.0, 1.0]
|
||||
print(result["score"])
|
||||
# 1.0
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, document_comparison_field: str = "content") -> None:
|
||||
"""
|
||||
Create a DocumentMRREvaluator component.
|
||||
|
||||
:param document_comparison_field:
|
||||
The Document field to use for comparison. Possible options:
|
||||
- `"content"`: uses `doc.content`
|
||||
- `"id"`: uses `doc.id`
|
||||
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
|
||||
(e.g. `"meta.file_id"`, `"meta.page_number"`)
|
||||
Nested keys are supported (e.g. `"meta.source.url"`).
|
||||
"""
|
||||
self.document_comparison_field = document_comparison_field
|
||||
|
||||
def _get_comparison_value(self, doc: Document) -> Any:
|
||||
"""
|
||||
Extract the comparison value from a document based on the configured field.
|
||||
"""
|
||||
if self.document_comparison_field == "content":
|
||||
return doc.content
|
||||
if self.document_comparison_field == "id":
|
||||
return doc.id
|
||||
if self.document_comparison_field.startswith("meta."):
|
||||
parts = self.document_comparison_field[5:].split(".")
|
||||
value = doc.meta
|
||||
for part in parts:
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
msg = (
|
||||
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
|
||||
"Use 'content', 'id', or 'meta.<key>'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
|
||||
|
||||
# Refer to https://www.pinecone.io/learn/offline-evaluation/ for the algorithm.
|
||||
@component.output_types(score=float, individual_scores=list[float])
|
||||
def run(
|
||||
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run the DocumentMRREvaluator on the given inputs.
|
||||
|
||||
`ground_truth_documents` and `retrieved_documents` must have the same length.
|
||||
|
||||
:param ground_truth_documents:
|
||||
A list of expected documents for each question.
|
||||
:param retrieved_documents:
|
||||
A list of retrieved documents for each question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score` - The average of calculated scores.
|
||||
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents how high the first retrieved
|
||||
document is ranked.
|
||||
"""
|
||||
if len(ground_truth_documents) != len(retrieved_documents):
|
||||
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
|
||||
raise ValueError(msg)
|
||||
|
||||
individual_scores = []
|
||||
|
||||
for ground_truth, retrieved in zip(ground_truth_documents, retrieved_documents, strict=True):
|
||||
reciprocal_rank = 0.0
|
||||
|
||||
ground_truth_values = [val for doc in ground_truth if (val := self._get_comparison_value(doc)) is not None]
|
||||
for rank, retrieved_document in enumerate(retrieved):
|
||||
retrieved_value = self._get_comparison_value(retrieved_document)
|
||||
if retrieved_value is None:
|
||||
continue
|
||||
if retrieved_value in ground_truth_values:
|
||||
reciprocal_rank = 1 / (rank + 1)
|
||||
break
|
||||
individual_scores.append(reciprocal_rank)
|
||||
|
||||
score = sum(individual_scores) / len(ground_truth_documents)
|
||||
|
||||
return {"score": score, "individual_scores": individual_scores}
|
||||
@@ -0,0 +1,193 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from math import log2
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_to_dict
|
||||
|
||||
|
||||
@component
|
||||
class DocumentNDCGEvaluator:
|
||||
"""
|
||||
Evaluator that calculates the normalized discounted cumulative gain (NDCG) of retrieved documents.
|
||||
|
||||
Each question can have multiple ground truth documents and multiple retrieved documents.
|
||||
If the ground truth documents have relevance scores, the NDCG calculation uses these scores.
|
||||
Otherwise, it assumes binary relevance of all ground truth documents.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.evaluators import DocumentNDCGEvaluator
|
||||
|
||||
evaluator = DocumentNDCGEvaluator()
|
||||
result = evaluator.run(
|
||||
ground_truth_documents=[[Document(content="France", score=1.0), Document(content="Paris", score=0.5)]],
|
||||
retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]],
|
||||
)
|
||||
print(result["individual_scores"])
|
||||
# [0.8869]
|
||||
print(result["score"])
|
||||
# 0.8869
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, document_comparison_field: str = "content") -> None:
|
||||
"""
|
||||
Create a DocumentNDCGEvaluator component.
|
||||
|
||||
:param document_comparison_field:
|
||||
The Document field to use for comparison. Possible options:
|
||||
- `"content"`: uses `doc.content`
|
||||
- `"id"`: uses `doc.id`
|
||||
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
|
||||
(e.g. `"meta.file_id"`, `"meta.page_number"`)
|
||||
Nested keys are supported (e.g. `"meta.source.url"`).
|
||||
"""
|
||||
self.document_comparison_field = document_comparison_field
|
||||
|
||||
def _get_comparison_value(self, doc: Document) -> Any:
|
||||
"""
|
||||
Extract the comparison value from a document based on the configured field.
|
||||
"""
|
||||
if self.document_comparison_field == "content":
|
||||
return doc.content
|
||||
if self.document_comparison_field == "id":
|
||||
return doc.id
|
||||
if self.document_comparison_field.startswith("meta."):
|
||||
parts = self.document_comparison_field[5:].split(".")
|
||||
value = doc.meta
|
||||
for part in parts:
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
msg = (
|
||||
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
|
||||
"Use 'content', 'id', or 'meta.<key>'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, document_comparison_field=self.document_comparison_field)
|
||||
|
||||
@component.output_types(score=float, individual_scores=list[float])
|
||||
def run(
|
||||
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run the DocumentNDCGEvaluator on the given inputs.
|
||||
|
||||
`ground_truth_documents` and `retrieved_documents` must have the same length.
|
||||
The list items within `ground_truth_documents` and `retrieved_documents` can differ in length.
|
||||
|
||||
:param ground_truth_documents:
|
||||
Lists of expected documents, one list per question. Binary relevance is used if documents have no scores.
|
||||
:param retrieved_documents:
|
||||
Lists of retrieved documents, one list per question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score` - The average of calculated scores.
|
||||
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents the NDCG for each question.
|
||||
"""
|
||||
self.validate_inputs(ground_truth_documents, retrieved_documents)
|
||||
|
||||
individual_scores = []
|
||||
|
||||
for gt_docs, ret_docs in zip(ground_truth_documents, retrieved_documents, strict=True):
|
||||
dcg = self.calculate_dcg(gt_docs, ret_docs)
|
||||
idcg = self.calculate_idcg(gt_docs)
|
||||
ndcg = dcg / idcg if idcg > 0 else 0
|
||||
individual_scores.append(ndcg)
|
||||
|
||||
score = sum(individual_scores) / len(ground_truth_documents)
|
||||
|
||||
return {"score": score, "individual_scores": individual_scores}
|
||||
|
||||
@staticmethod
|
||||
def validate_inputs(gt_docs: list[list[Document]], ret_docs: list[list[Document]]) -> None:
|
||||
"""
|
||||
Validate the input parameters.
|
||||
|
||||
:param gt_docs:
|
||||
The ground_truth_documents to validate.
|
||||
:param ret_docs:
|
||||
The retrieved_documents to validate.
|
||||
|
||||
:raises ValueError:
|
||||
If the ground_truth_documents or the retrieved_documents are an empty list.
|
||||
If the length of ground_truth_documents and retrieved_documents differs.
|
||||
If any list of documents in ground_truth_documents contains a mix of documents with and without a score.
|
||||
"""
|
||||
if len(gt_docs) == 0 or len(ret_docs) == 0:
|
||||
msg = "ground_truth_documents and retrieved_documents must be provided."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(gt_docs) != len(ret_docs):
|
||||
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
|
||||
raise ValueError(msg)
|
||||
|
||||
for docs in gt_docs:
|
||||
if any(doc.score is not None for doc in docs) and any(doc.score is None for doc in docs):
|
||||
msg = "Either none or all documents in each list of ground_truth_documents must have a score."
|
||||
raise ValueError(msg)
|
||||
|
||||
def calculate_dcg(self, gt_docs: list[Document], ret_docs: list[Document]) -> float:
|
||||
"""
|
||||
Calculate the discounted cumulative gain (DCG) of the retrieved documents.
|
||||
|
||||
:param gt_docs:
|
||||
The ground truth documents.
|
||||
:param ret_docs:
|
||||
The retrieved documents.
|
||||
:returns:
|
||||
The discounted cumulative gain (DCG) of the retrieved
|
||||
documents based on the ground truth documents.
|
||||
"""
|
||||
dcg = 0.0
|
||||
# Build lookup from comparison value -> relevance score, skipping documents
|
||||
# whose comparison value cannot be determined (e.g. missing meta key)
|
||||
relevant_value_to_score: dict[Any, float] = {}
|
||||
for doc in gt_docs:
|
||||
value = self._get_comparison_value(doc)
|
||||
if value is not None:
|
||||
relevant_value_to_score[value] = doc.score if doc.score is not None else 1
|
||||
|
||||
for i, doc in enumerate(ret_docs):
|
||||
value = self._get_comparison_value(doc)
|
||||
if value is not None and value in relevant_value_to_score:
|
||||
dcg += relevant_value_to_score[value] / log2(i + 2) # i + 2 because i is 0-indexed
|
||||
return dcg
|
||||
|
||||
def calculate_idcg(self, gt_docs: list[Document]) -> float:
|
||||
"""
|
||||
Calculate the ideal discounted cumulative gain (IDCG) of the ground truth documents.
|
||||
|
||||
Ground truth documents whose comparison value cannot be determined (e.g. missing meta key)
|
||||
are excluded, since they can never be matched in `calculate_dcg` either. Including them here
|
||||
would inflate the IDCG and make it impossible for NDCG to reach 1.0 for a perfect retrieval.
|
||||
|
||||
:param gt_docs:
|
||||
The ground truth documents.
|
||||
:returns:
|
||||
The ideal discounted cumulative gain (IDCG) of the ground truth documents.
|
||||
"""
|
||||
# Filter out documents that cannot be matched, consistent with calculate_dcg
|
||||
matchable_docs = [doc for doc in gt_docs if self._get_comparison_value(doc) is not None]
|
||||
|
||||
idcg = 0.0
|
||||
for i, doc in enumerate(
|
||||
sorted(matchable_docs, key=lambda x: x.score if x.score is not None else 1, reverse=True)
|
||||
):
|
||||
# If the document has a score, use it; otherwise, use 1 for binary relevance.
|
||||
relevance = doc.score if doc.score is not None else 1
|
||||
idcg += relevance / log2(i + 2) # i + 2 because i is 0-indexed
|
||||
return idcg
|
||||
@@ -0,0 +1,181 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_to_dict, logging
|
||||
from haystack.dataclasses import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecallMode(Enum):
|
||||
"""
|
||||
Enum for the mode to use for calculating the recall score.
|
||||
"""
|
||||
|
||||
# Score is based on whether any document is retrieved.
|
||||
SINGLE_HIT = "single_hit"
|
||||
# Score is based on how many documents were retrieved.
|
||||
MULTI_HIT = "multi_hit"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "RecallMode":
|
||||
"""
|
||||
Convert a string to a RecallMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in RecallMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown recall mode '{string}'. Supported modes are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class DocumentRecallEvaluator:
|
||||
"""
|
||||
Evaluator that calculates the Recall score for a list of documents.
|
||||
|
||||
Returns both a list of scores for each question and the average.
|
||||
There can be multiple ground truth documents and multiple predicted documents as input.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.evaluators import DocumentRecallEvaluator
|
||||
|
||||
evaluator = DocumentRecallEvaluator()
|
||||
result = evaluator.run(
|
||||
ground_truth_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="9th")],
|
||||
],
|
||||
retrieved_documents=[
|
||||
[Document(content="France")],
|
||||
[Document(content="9th century"), Document(content="10th century"), Document(content="9th")],
|
||||
],
|
||||
)
|
||||
print(result["individual_scores"])
|
||||
# [1.0, 1.0]
|
||||
print(result["score"])
|
||||
# 1.0
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, mode: str | RecallMode = RecallMode.SINGLE_HIT, document_comparison_field: str = "content"
|
||||
) -> None:
|
||||
"""
|
||||
Create a DocumentRecallEvaluator component.
|
||||
|
||||
:param mode:
|
||||
Mode to use for calculating the recall score.
|
||||
:param document_comparison_field:
|
||||
The Document field to use for comparison. Possible options:
|
||||
- `"content"`: uses `doc.content`
|
||||
- `"id"`: uses `doc.id`
|
||||
- A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]`
|
||||
(e.g. `"meta.file_id"`, `"meta.page_number"`)
|
||||
Nested keys are supported (e.g. `"meta.source.url"`).
|
||||
"""
|
||||
if isinstance(mode, str):
|
||||
mode = RecallMode.from_str(mode)
|
||||
|
||||
self.mode = mode
|
||||
self.document_comparison_field = document_comparison_field
|
||||
|
||||
def _get_comparison_value(self, doc: Document) -> Any:
|
||||
"""
|
||||
Extract the comparison value from a document based on the configured field.
|
||||
"""
|
||||
if self.document_comparison_field == "content":
|
||||
return doc.content
|
||||
if self.document_comparison_field == "id":
|
||||
return doc.id
|
||||
if self.document_comparison_field.startswith("meta."):
|
||||
parts = self.document_comparison_field[5:].split(".")
|
||||
value = doc.meta
|
||||
for part in parts:
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
msg = (
|
||||
f"Unsupported document_comparison_field: '{self.document_comparison_field}'. "
|
||||
"Use 'content', 'id', or 'meta.<key>'."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def _recall_single_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
|
||||
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
|
||||
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
|
||||
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
|
||||
|
||||
return float(len(retrieved_ground_truths) > 0)
|
||||
|
||||
def _recall_multi_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
|
||||
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
|
||||
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
|
||||
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
|
||||
|
||||
if not unique_truths or unique_truths <= {"", None}:
|
||||
logger.warning(
|
||||
"There are no ground truth documents or none of them contain a valid comparison value. "
|
||||
"Score will be set to 0."
|
||||
)
|
||||
return 0.0
|
||||
|
||||
if not unique_retrievals or unique_retrievals <= {"", None}:
|
||||
logger.warning(
|
||||
"There are no retrieved documents or none of them contain a valid comparison value. "
|
||||
"Score will be set to 0."
|
||||
)
|
||||
return 0.0
|
||||
|
||||
return len(retrieved_ground_truths) / len(unique_truths)
|
||||
|
||||
@component.output_types(score=float, individual_scores=list[float])
|
||||
def run(
|
||||
self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run the DocumentRecallEvaluator on the given inputs.
|
||||
|
||||
`ground_truth_documents` and `retrieved_documents` must have the same length.
|
||||
|
||||
:param ground_truth_documents:
|
||||
A list of expected documents for each question.
|
||||
:param retrieved_documents:
|
||||
A list of retrieved documents for each question.
|
||||
A dictionary with the following outputs:
|
||||
- `score` - The average of calculated scores.
|
||||
- `individual_scores` - A list of numbers from 0.0 to 1.0 that represents the proportion of matching
|
||||
documents retrieved. If the mode is `single_hit`, the individual scores are 0 or 1.
|
||||
"""
|
||||
if len(ground_truth_documents) != len(retrieved_documents):
|
||||
msg = "The length of ground_truth_documents and retrieved_documents must be the same."
|
||||
raise ValueError(msg)
|
||||
|
||||
if self.mode == RecallMode.SINGLE_HIT:
|
||||
mode_function = self._recall_single_hit
|
||||
elif self.mode == RecallMode.MULTI_HIT:
|
||||
mode_function = self._recall_multi_hit
|
||||
|
||||
scores = [mode_function(gt, ret) for gt, ret in zip(ground_truth_documents, retrieved_documents, strict=True)]
|
||||
|
||||
return {"score": sum(scores) / len(retrieved_documents), "individual_scores": scores}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, mode=str(self.mode), document_comparison_field=self.document_comparison_field)
|
||||
@@ -0,0 +1,255 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from numpy import mean as np_mean
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.evaluators.llm_evaluator import LLMEvaluator
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.utils import deserialize_chatgenerator_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default examples to include in the prompt if the user does not provide any examples
|
||||
_DEFAULT_EXAMPLES = [
|
||||
{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of Germany and when was it founded?",
|
||||
"contexts": ["Berlin is the capital of Germany and was founded in 1244."],
|
||||
"predicted_answers": "The capital of Germany, Berlin, was founded in the 13th century.",
|
||||
},
|
||||
"outputs": {
|
||||
"statements": ["Berlin is the capital of Germany.", "Berlin was founded in 1244."],
|
||||
"statement_scores": [1, 1],
|
||||
},
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of France?",
|
||||
"contexts": ["Berlin is the capital of Germany."],
|
||||
"predicted_answers": "Paris",
|
||||
},
|
||||
"outputs": {"statements": ["Paris is the capital of France."], "statement_scores": [0]},
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of Italy?",
|
||||
"contexts": ["Rome is the capital of Italy."],
|
||||
"predicted_answers": "Rome is the capital of Italy with more than 4 million inhabitants.",
|
||||
},
|
||||
"outputs": {
|
||||
"statements": ["Rome is the capital of Italy.", "Rome has more than 4 million inhabitants."],
|
||||
"statement_scores": [1, 0],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@component
|
||||
class FaithfulnessEvaluator(LLMEvaluator):
|
||||
"""
|
||||
Evaluator that checks if a generated answer can be inferred from the provided contexts.
|
||||
|
||||
An LLM separates the answer into multiple statements and checks whether the statement can be inferred from the
|
||||
context or not. The final score for the full answer is a number from 0.0 to 1.0. It represents the proportion of
|
||||
statements that can be inferred from the provided contexts.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.evaluators import FaithfulnessEvaluator
|
||||
|
||||
questions = ["Who created the Python language?"]
|
||||
contexts = [
|
||||
[(
|
||||
"Python, created by Guido van Rossum in the late 1980s, is a high-level general-purpose programming "
|
||||
"language. Its design philosophy emphasizes code readability, and its language constructs aim to help "
|
||||
"programmers write clear, logical code for both small and large-scale software projects."
|
||||
)],
|
||||
]
|
||||
predicted_answers = [
|
||||
"Python is a high-level general-purpose programming language that was created by George Lucas."
|
||||
]
|
||||
evaluator = FaithfulnessEvaluator()
|
||||
result = evaluator.run(questions=questions, contexts=contexts, predicted_answers=predicted_answers)
|
||||
|
||||
print(result["individual_scores"])
|
||||
# [0.5]
|
||||
print(result["score"])
|
||||
# 0.5
|
||||
print(result["results"])
|
||||
# [{'statements': ['Python is a high-level general-purpose programming language.',
|
||||
# 'Python was created by George Lucas.'], 'statement_scores': [1, 0], 'score': 0.5}]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
examples: list[dict[str, Any]] | None = None,
|
||||
progress_bar: bool = True,
|
||||
raise_on_failure: bool = True,
|
||||
chat_generator: ChatGenerator | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of FaithfulnessEvaluator.
|
||||
|
||||
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
|
||||
|
||||
:param examples:
|
||||
Optional few-shot examples conforming to the expected input and output format of FaithfulnessEvaluator.
|
||||
Default examples will be used if none are provided.
|
||||
Each example must be a dictionary with keys "inputs" and "outputs".
|
||||
"inputs" must be a dictionary with keys "questions", "contexts", and "predicted_answers".
|
||||
"outputs" must be a dictionary with "statements" and "statement_scores".
|
||||
Expected format:
|
||||
```python
|
||||
[{
|
||||
"inputs": {
|
||||
"questions": "What is the capital of Italy?", "contexts": ["Rome is the capital of Italy."],
|
||||
"predicted_answers": "Rome is the capital of Italy with more than 4 million inhabitants.",
|
||||
},
|
||||
"outputs": {
|
||||
"statements": ["Rome is the capital of Italy.", "Rome has more than 4 million inhabitants."],
|
||||
"statement_scores": [1, 0],
|
||||
},
|
||||
}]
|
||||
```
|
||||
:param progress_bar:
|
||||
Whether to show a progress bar during the evaluation.
|
||||
:param raise_on_failure:
|
||||
Whether to raise an exception if the API call fails.
|
||||
:param chat_generator:
|
||||
a ChatGenerator instance which represents the LLM.
|
||||
In order for the component to work, the LLM should be configured to return a JSON object. For example,
|
||||
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
|
||||
`generation_kwargs`.
|
||||
"""
|
||||
self.instructions = (
|
||||
"Your task is to judge the faithfulness or groundedness of statements based "
|
||||
"on context information. First, please extract statements from a provided "
|
||||
"predicted answer to a question. Second, calculate a faithfulness score for each "
|
||||
"statement made in the predicted answer. The score is 1 if the statement can be "
|
||||
"inferred from the provided context or 0 if it cannot be inferred."
|
||||
)
|
||||
self.inputs = [("questions", list[str]), ("contexts", list[list[str]]), ("predicted_answers", list[str])]
|
||||
self.outputs = ["statements", "statement_scores"]
|
||||
self.examples = examples or _DEFAULT_EXAMPLES
|
||||
|
||||
super(FaithfulnessEvaluator, self).__init__( # noqa: UP008
|
||||
instructions=self.instructions,
|
||||
inputs=self.inputs,
|
||||
outputs=self.outputs,
|
||||
examples=self.examples,
|
||||
chat_generator=chat_generator,
|
||||
raise_on_failure=raise_on_failure,
|
||||
progress_bar=progress_bar,
|
||||
)
|
||||
|
||||
@component.output_types(individual_scores=list[float], score=float, results=list[dict[str, Any]])
|
||||
def run(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator.
|
||||
|
||||
:param questions:
|
||||
A list of questions.
|
||||
:param contexts:
|
||||
A nested list of contexts that correspond to the questions.
|
||||
:param predicted_answers:
|
||||
A list of predicted answers.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score`: Mean faithfulness score over all the provided input answers.
|
||||
- `individual_scores`: A list of faithfulness scores for each input answer.
|
||||
- `results`: A list of dictionaries with `statements` and `statement_scores` for each input answer.
|
||||
"""
|
||||
result = super(FaithfulnessEvaluator, self).run(**inputs) # noqa: UP008
|
||||
# Post-process the raw results to calculate relevance metrics and scores
|
||||
return self._postprocess_results(result)
|
||||
|
||||
@component.output_types(individual_scores=list[float], score=float, results=list[dict[str, Any]])
|
||||
async def run_async(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator asynchronously.
|
||||
|
||||
:param questions:
|
||||
A list of questions.
|
||||
:param contexts:
|
||||
A nested list of contexts that correspond to the questions.
|
||||
:param predicted_answers:
|
||||
A list of predicted answers.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score`: Mean faithfulness score over all the provided input answers.
|
||||
- `individual_scores`: A list of faithfulness scores for each input answer.
|
||||
- `results`: A list of dictionaries with `statements` and `statement_scores` for each input answer.
|
||||
"""
|
||||
result = await super(FaithfulnessEvaluator, self).run_async(**inputs) # noqa: UP008
|
||||
# Post-process the raw results to calculate relevance metrics and scores
|
||||
return self._postprocess_results(result)
|
||||
|
||||
def _postprocess_results(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Post-processes raw LLM evaluator outputs to compute faithfulness scores.
|
||||
|
||||
Calculates statement-level score averages, computes the overall mean faithfulness
|
||||
score across successful queries, and updates the result payload.
|
||||
|
||||
:param result:
|
||||
The raw evaluation dictionary from the base LLM evaluator.
|
||||
:returns:
|
||||
The updated dictionary containing final scores and tracking metrics.
|
||||
"""
|
||||
|
||||
# calculate average statement faithfulness score per query
|
||||
for idx, res in enumerate(result["results"]):
|
||||
if res is None:
|
||||
result["results"][idx] = {"statements": [], "statement_scores": [], "score": float("nan")}
|
||||
continue
|
||||
if not res["statements"]:
|
||||
res["score"] = 0
|
||||
else:
|
||||
res["score"] = np_mean(res["statement_scores"])
|
||||
|
||||
# calculate average answer faithfulness score over all queries
|
||||
scores = [res["score"] for res in result["results"]]
|
||||
valid_scores = [s for s in scores if not math.isnan(s)]
|
||||
skipped = len(scores) - len(valid_scores)
|
||||
if skipped:
|
||||
logger.warning("{skipped} query(s) failed and were excluded from the score.", skipped=skipped)
|
||||
result["score"] = np_mean(valid_scores) if valid_scores else float("nan")
|
||||
result["individual_scores"] = scores
|
||||
|
||||
return result
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
examples=self.examples,
|
||||
progress_bar=self.progress_bar,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "FaithfulnessEvaluator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
if data["init_parameters"].get("chat_generator"):
|
||||
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,462 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tqdm import tqdm
|
||||
from tqdm.asyncio import tqdm as async_tqdm
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.components.generators.chat.openai import OpenAIChatGenerator
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses.chat_message import ChatMessage
|
||||
from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type
|
||||
from haystack.utils.misc import _parse_dict_from_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class LLMEvaluator:
|
||||
"""
|
||||
Uses an LLM to evaluate inputs based on a prompt containing instructions and examples.
|
||||
|
||||
The default API requires an OpenAI API key to be provided as an environment variable "OPENAI_API_KEY".
|
||||
The inputs are lists that are user-defined depending on the desired metric.
|
||||
The output is a dictionary with a key `results` containing a list of evaluation results.
|
||||
Each result is a dictionary with user-defined keys and values of either 0 for FALSE or 1 for TRUE respectively.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.evaluators import LLMEvaluator
|
||||
evaluator = LLMEvaluator(
|
||||
instructions="Is this answer problematic for children?",
|
||||
inputs=[("predicted_answers", list[str])],
|
||||
outputs=["score"],
|
||||
examples=[
|
||||
{"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"score": 1}},
|
||||
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}},
|
||||
],
|
||||
)
|
||||
predicted_answers = [
|
||||
"Football is the most popular sport with around 4 billion followers worldwide",
|
||||
"Python language was created by Guido van Rossum.",
|
||||
]
|
||||
results = evaluator.run(predicted_answers=predicted_answers)
|
||||
print(results)
|
||||
# {'results': [{'score': 0}, {'score': 0}]}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instructions: str,
|
||||
inputs: list[tuple[str, type[list]]],
|
||||
outputs: list[str],
|
||||
examples: list[dict[str, Any]],
|
||||
progress_bar: bool = True,
|
||||
*,
|
||||
raise_on_failure: bool = True,
|
||||
chat_generator: ChatGenerator | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of LLMEvaluator.
|
||||
|
||||
If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode.
|
||||
|
||||
:param instructions:
|
||||
The prompt instructions to use for evaluation.
|
||||
Should be a question about the inputs that can be answered with yes or no.
|
||||
:param inputs:
|
||||
The inputs that the component expects as incoming connections and that it evaluates.
|
||||
Each input is a tuple of an input name and input type. Input types must be lists.
|
||||
:param outputs:
|
||||
Output names of the evaluation results. They correspond to keys in the output dictionary.
|
||||
:param examples:
|
||||
Few-shot examples conforming to the expected input and output format as defined in the `inputs` and
|
||||
`outputs` parameters.
|
||||
Each example is a dictionary with keys "inputs" and "outputs"
|
||||
They contain the input and output as dictionaries respectively.
|
||||
:param raise_on_failure:
|
||||
If True, the component will raise an exception on an unsuccessful API call.
|
||||
:param progress_bar:
|
||||
Whether to show a progress bar during the evaluation.
|
||||
:param chat_generator:
|
||||
a ChatGenerator instance which represents the LLM.
|
||||
In order for the component to work, the LLM should be configured to return a JSON object. For example,
|
||||
when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the
|
||||
`generation_kwargs`.
|
||||
"""
|
||||
self.validate_init_parameters(inputs, outputs, examples)
|
||||
component.set_input_types(self, **dict(inputs))
|
||||
|
||||
self.raise_on_failure = raise_on_failure
|
||||
self.instructions = instructions
|
||||
self.inputs = inputs
|
||||
self.outputs = outputs
|
||||
self.examples = examples
|
||||
self.progress_bar = progress_bar
|
||||
|
||||
template = self.prepare_template()
|
||||
self.builder = PromptBuilder(template=template)
|
||||
|
||||
if chat_generator is not None:
|
||||
self._chat_generator = chat_generator
|
||||
else:
|
||||
generation_kwargs = {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
self._chat_generator = OpenAIChatGenerator(generation_kwargs=generation_kwargs)
|
||||
|
||||
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 validate_init_parameters(
|
||||
inputs: list[tuple[str, type[list]]], outputs: list[str], examples: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""
|
||||
Validate the init parameters.
|
||||
|
||||
:param inputs:
|
||||
The inputs to validate.
|
||||
:param outputs:
|
||||
The outputs to validate.
|
||||
:param examples:
|
||||
The examples to validate.
|
||||
|
||||
:raises ValueError:
|
||||
If the inputs are not a list of tuples with a string and a type of list.
|
||||
If the outputs are not a list of strings.
|
||||
If the examples are not a list of dictionaries.
|
||||
If any example does not have keys "inputs" and "outputs" with values that are dictionaries with string keys.
|
||||
"""
|
||||
# Validate inputs
|
||||
if (
|
||||
not isinstance(inputs, list)
|
||||
or not all(isinstance(_input, tuple) for _input in inputs)
|
||||
or not all(isinstance(_input[0], str) and _input[1] is not list and len(_input) == 2 for _input in inputs)
|
||||
):
|
||||
msg = (
|
||||
f"LLM evaluator expects inputs to be a list of tuples. Each tuple must contain an input name and "
|
||||
f"type of list but received {inputs}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate outputs
|
||||
if not isinstance(outputs, list) or not all(isinstance(output, str) for output in outputs):
|
||||
msg = f"LLM evaluator expects outputs to be a list of str but received {outputs}."
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate examples are lists of dicts
|
||||
if not isinstance(examples, list) or not all(isinstance(example, dict) for example in examples):
|
||||
msg = f"LLM evaluator expects examples to be a list of dictionaries but received {examples}."
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate each example
|
||||
for example in examples:
|
||||
if (
|
||||
{"inputs", "outputs"} != example.keys()
|
||||
or not all(isinstance(example[param], dict) for param in ["inputs", "outputs"])
|
||||
or not all(isinstance(key, str) for param in ["inputs", "outputs"] for key in example[param])
|
||||
):
|
||||
msg = (
|
||||
f"LLM evaluator expects each example to have keys `inputs` and `outputs` with values that are "
|
||||
f"dictionaries with str keys but received {example}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
@component.output_types(results=list[dict[str, Any]])
|
||||
def run(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator.
|
||||
|
||||
:param inputs:
|
||||
The input values to evaluate. The keys are the input names and the values are lists of input values.
|
||||
:returns:
|
||||
A dictionary with a `results` entry that contains a list of results.
|
||||
Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator
|
||||
and the evaluation results as the values. If an exception occurs for a particular input value, the result
|
||||
will be `None` for that entry.
|
||||
If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included
|
||||
in the output dictionary, under the key "meta".
|
||||
:raises ValueError:
|
||||
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
|
||||
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
self.validate_input_parameters(dict(self.inputs), inputs)
|
||||
|
||||
# inputs is a dictionary with keys being input names and values being a list of input values
|
||||
# We need to iterate through the lists in parallel for all keys of the dictionary
|
||||
input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True))
|
||||
list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values]
|
||||
|
||||
results: list[dict[str, Any] | None] = []
|
||||
metadata = []
|
||||
errors = 0
|
||||
for input_names_to_values in tqdm(list_of_input_names_to_values, disable=not self.progress_bar):
|
||||
prompt = self.builder.run(**input_names_to_values)
|
||||
messages = [ChatMessage.from_user(prompt["prompt"])]
|
||||
try:
|
||||
result = self._chat_generator.run(messages=messages)
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
|
||||
logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e)
|
||||
results.append(None)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
parsed_result = _parse_dict_from_json(
|
||||
result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure
|
||||
)
|
||||
if parsed_result is None:
|
||||
results.append(None)
|
||||
errors += 1
|
||||
else:
|
||||
results.append(parsed_result)
|
||||
|
||||
if result["replies"][0].meta:
|
||||
metadata.append(result["replies"][0].meta)
|
||||
|
||||
if errors > 0:
|
||||
logger.warning(
|
||||
"LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.",
|
||||
errors=errors,
|
||||
len=len(list_of_input_names_to_values),
|
||||
)
|
||||
|
||||
return {"results": results, "meta": metadata or None}
|
||||
|
||||
@component.output_types(results=list[dict[str, Any]])
|
||||
async def run_async(self, **inputs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Run the LLM evaluator asynchronously
|
||||
|
||||
:param inputs:
|
||||
The input values to evaluate. The keys are the input names and the values are lists of input values.
|
||||
:returns:
|
||||
A dictionary with a `results` entry that contains a list of results.
|
||||
Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator
|
||||
and the evaluation results as the values. If an exception occurs for a particular input value, the result
|
||||
will be `None` for that entry.
|
||||
If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included
|
||||
in the output dictionary, under the key "meta".
|
||||
:raises TypeError:
|
||||
If the chat generator does not support async execution.
|
||||
:raises ValueError:
|
||||
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
|
||||
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
|
||||
"""
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
self.validate_input_parameters(dict(self.inputs), inputs)
|
||||
|
||||
# inputs is a dictionary with keys being input names and values being a list of input values
|
||||
# We need to iterate through the lists in parallel for all keys of the dictionary
|
||||
input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True))
|
||||
list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values]
|
||||
|
||||
results: list[dict[str, Any] | None] = []
|
||||
metadata = []
|
||||
errors = 0
|
||||
|
||||
generator_has_async = hasattr(self._chat_generator, "run_async")
|
||||
for input_names_to_values in async_tqdm(list_of_input_names_to_values, disable=not self.progress_bar):
|
||||
prompt = self.builder.run(**input_names_to_values)
|
||||
messages = [ChatMessage.from_user(prompt["prompt"])]
|
||||
try:
|
||||
if generator_has_async:
|
||||
result = await self._chat_generator.run_async(messages=messages) # type: ignore[attr-defined]
|
||||
else:
|
||||
logger.debug(
|
||||
"{generator_type} does not implement 'run_async'."
|
||||
" Running the synchronous 'run' method in a thread to avoid blocking the event loop.",
|
||||
generator_type=type(self._chat_generator).__name__,
|
||||
)
|
||||
result = await asyncio.to_thread(self._chat_generator.run, messages=messages)
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e
|
||||
logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e)
|
||||
results.append(None)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
parsed_result = _parse_dict_from_json(
|
||||
result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure
|
||||
)
|
||||
if parsed_result is None:
|
||||
results.append(None)
|
||||
errors += 1
|
||||
else:
|
||||
results.append(parsed_result)
|
||||
|
||||
if result["replies"][0].meta:
|
||||
metadata.append(result["replies"][0].meta)
|
||||
|
||||
if errors > 0:
|
||||
logger.warning(
|
||||
"LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.",
|
||||
errors=errors,
|
||||
len=len(list_of_input_names_to_values),
|
||||
)
|
||||
|
||||
return {"results": results, "meta": metadata or None}
|
||||
|
||||
def prepare_template(self) -> str:
|
||||
"""
|
||||
Prepare the prompt template.
|
||||
|
||||
Combine instructions, inputs, outputs, and examples into one prompt template with the following format:
|
||||
Instructions:
|
||||
`<instructions>`
|
||||
|
||||
Generate the response in JSON format with the following keys:
|
||||
`<list of output keys>`
|
||||
Consider the instructions and the examples below to determine those values.
|
||||
|
||||
Examples:
|
||||
`<examples>`
|
||||
|
||||
Inputs:
|
||||
`<inputs>`
|
||||
Outputs:
|
||||
|
||||
:returns:
|
||||
The prompt template.
|
||||
"""
|
||||
inputs_section = (
|
||||
"{" + ", ".join([f'"{input_socket[0]}": {{{{ {input_socket[0]} }}}}' for input_socket in self.inputs]) + "}"
|
||||
)
|
||||
|
||||
examples_section = "\n".join(
|
||||
[
|
||||
"Inputs:\n" + json.dumps(example["inputs"]) + "\nOutputs:\n" + json.dumps(example["outputs"])
|
||||
for example in self.examples
|
||||
]
|
||||
)
|
||||
return (
|
||||
f"Instructions:\n"
|
||||
f"{self.instructions}\n\n"
|
||||
f"Generate the response in JSON format with the following keys:\n"
|
||||
f"{json.dumps(self.outputs)}\n"
|
||||
f"Consider the instructions and the examples below to determine those values.\n\n"
|
||||
f"Examples:\n"
|
||||
f"{examples_section}\n\n"
|
||||
f"Inputs:\n"
|
||||
f"{inputs_section}\n"
|
||||
f"Outputs:\n"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
# Since we cannot currently serialize tuples, convert the inputs to a list.
|
||||
inputs = [[name, serialize_type(type_)] for name, type_ in self.inputs]
|
||||
return default_to_dict(
|
||||
self,
|
||||
instructions=self.instructions,
|
||||
inputs=inputs,
|
||||
outputs=self.outputs,
|
||||
examples=self.examples,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
progress_bar=self.progress_bar,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "LLMEvaluator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
data["init_parameters"]["inputs"] = [
|
||||
(name, deserialize_type(type_)) for name, type_ in data["init_parameters"]["inputs"]
|
||||
]
|
||||
|
||||
if data["init_parameters"].get("chat_generator"):
|
||||
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@staticmethod
|
||||
def validate_input_parameters(expected: dict[str, Any], received: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate the input parameters.
|
||||
|
||||
:param expected:
|
||||
The expected input parameters.
|
||||
:param received:
|
||||
The received input parameters.
|
||||
|
||||
:raises ValueError:
|
||||
If not all expected inputs are present in the received inputs
|
||||
If the received inputs are not lists or have different lengths
|
||||
"""
|
||||
# Validate that all expected inputs are present in the received inputs
|
||||
for param in expected:
|
||||
if param not in received:
|
||||
msg = f"LLM evaluator expected input parameter '{param}' but received only {received.keys()}."
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate that all received inputs are lists
|
||||
if not all(isinstance(_input, list) for _input in received.values()):
|
||||
msg = (
|
||||
"LLM evaluator expects all input values to be lists but received "
|
||||
f"{[type(_input) for _input in received.values()]}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate that all received inputs are of the same length
|
||||
inputs = received.values()
|
||||
length = len(next(iter(inputs)))
|
||||
if not all(len(_input) == length for _input in inputs):
|
||||
msg = (
|
||||
f"LLM evaluator expects all input lists to have the same length but received {inputs} with lengths "
|
||||
f"{[len(_input) for _input in inputs]}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
@@ -0,0 +1,188 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from numpy import mean as np_mean
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.lazy_imports import LazyImport
|
||||
from haystack.utils import ComponentDevice, expit
|
||||
from haystack.utils.auth import Secret
|
||||
|
||||
with LazyImport(message="Run 'pip install \"sentence-transformers>=5.0.0\"'") as sas_import:
|
||||
from sentence_transformers import CrossEncoder, SentenceTransformer, util
|
||||
from transformers import AutoConfig
|
||||
|
||||
|
||||
@component
|
||||
class SASEvaluator:
|
||||
"""
|
||||
SASEvaluator computes the Semantic Answer Similarity (SAS) between a list of predictions and a one of ground truths.
|
||||
|
||||
It's usually used in Retrieval Augmented Generation (RAG) pipelines to evaluate the quality of the generated
|
||||
answers. The SAS is computed using a pre-trained model from the Hugging Face model hub. The model can be either a
|
||||
Bi-Encoder or a Cross-Encoder. The choice of the model is based on the `model` parameter.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.evaluators.sas_evaluator import SASEvaluator
|
||||
|
||||
evaluator = SASEvaluator(model="cross-encoder/ms-marco-MiniLM-L-6-v2")
|
||||
ground_truths = [
|
||||
"A construction budget of US $2.3 billion",
|
||||
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
|
||||
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
|
||||
]
|
||||
predictions = [
|
||||
"A construction budget of US $2.3 billion",
|
||||
"The Eiffel Tower, completed in 1889, symbolizes Paris's cultural magnificence.",
|
||||
"The Meiji Restoration in 1868 transformed Japan into a modernized world power.",
|
||||
]
|
||||
result = evaluator.run(
|
||||
ground_truth_answers=ground_truths, predicted_answers=predictions
|
||||
)
|
||||
|
||||
print(result["score"])
|
||||
# 0.9999673763910929
|
||||
|
||||
print(result["individual_scores"])
|
||||
# [0.9999765157699585, 0.999968409538269, 0.9999572038650513]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
||||
batch_size: int = 32,
|
||||
device: ComponentDevice | None = None,
|
||||
token: Secret = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
|
||||
) -> None:
|
||||
"""
|
||||
Creates a new instance of SASEvaluator.
|
||||
|
||||
:param model:
|
||||
SentenceTransformers semantic textual similarity model, should be path or string pointing to a downloadable
|
||||
model.
|
||||
:param batch_size:
|
||||
Number of prediction-label pairs to encode at once.
|
||||
:param device:
|
||||
The device on which the model is loaded. If `None`, the default device is automatically selected.
|
||||
:param token:
|
||||
The Hugging Face token for HTTP bearer authorization.
|
||||
You can find your HF token in your [account settings](https://huggingface.co/settings/tokens)
|
||||
"""
|
||||
sas_import.check()
|
||||
|
||||
self._model = model
|
||||
self._batch_size = batch_size
|
||||
self._device = device
|
||||
self._token = token
|
||||
self._similarity_model: SentenceTransformer | CrossEncoder | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, model=self._model, batch_size=self._batch_size, device=self._device, token=self._token
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SASEvaluator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the component.
|
||||
"""
|
||||
if self._similarity_model:
|
||||
return
|
||||
|
||||
token = self._token.resolve_value() if self._token else None
|
||||
config = AutoConfig.from_pretrained(self._model, use_auth_token=token)
|
||||
cross_encoder_used = False
|
||||
if config.architectures:
|
||||
cross_encoder_used = any(arch.endswith("ForSequenceClassification") for arch in config.architectures)
|
||||
device = ComponentDevice.resolve_device(self._device).to_torch_str()
|
||||
# Based on the Model string we can load either Bi-Encoders or Cross Encoders.
|
||||
# Similarity computation changes for both approaches
|
||||
if cross_encoder_used:
|
||||
self._similarity_model = CrossEncoder(self._model, device=device, token=token)
|
||||
else:
|
||||
self._similarity_model = SentenceTransformer(self._model, device=device, token=token)
|
||||
|
||||
@component.output_types(score=float, individual_scores=list[float])
|
||||
def run(self, ground_truth_answers: list[str], predicted_answers: list[str]) -> dict[str, float | list[float]]:
|
||||
"""
|
||||
SASEvaluator component run method.
|
||||
|
||||
Run the SASEvaluator to compute the Semantic Answer Similarity (SAS) between a list of predicted answers
|
||||
and a list of ground truth answers. Both must be list of strings of same length.
|
||||
|
||||
:param ground_truth_answers:
|
||||
A list of expected answers for each question.
|
||||
:param predicted_answers:
|
||||
A list of generated answers for each question.
|
||||
:returns:
|
||||
A dictionary with the following outputs:
|
||||
- `score`: Mean SAS score over all the predictions/ground-truth pairs.
|
||||
- `individual_scores`: A list of similarity scores for each prediction/ground-truth pair.
|
||||
"""
|
||||
if len(ground_truth_answers) != len(predicted_answers):
|
||||
raise ValueError("The number of predictions and labels must be the same.")
|
||||
|
||||
if any(answer is None for answer in predicted_answers):
|
||||
raise ValueError("Predicted answers must not contain None values.")
|
||||
|
||||
if len(predicted_answers) == 0:
|
||||
return {"score": 0.0, "individual_scores": [0.0]}
|
||||
|
||||
if not self._similarity_model:
|
||||
self.warm_up()
|
||||
|
||||
if isinstance(self._similarity_model, CrossEncoder):
|
||||
# For Cross Encoders we create a list of pairs of predictions and labels
|
||||
sentence_pairs = list(zip(predicted_answers, ground_truth_answers, strict=True))
|
||||
similarity_scores = self._similarity_model.predict(
|
||||
sentence_pairs, batch_size=self._batch_size, convert_to_numpy=True
|
||||
)
|
||||
|
||||
# All Cross Encoders do not return a set of logits scores that are normalized
|
||||
# We normalize scores if they are larger than 1
|
||||
if (similarity_scores > 1).any():
|
||||
similarity_scores = expit(similarity_scores)
|
||||
|
||||
# Convert scores to list of floats from numpy array
|
||||
similarity_scores = similarity_scores.tolist()
|
||||
|
||||
elif isinstance(self._similarity_model, SentenceTransformer):
|
||||
# For Bi-encoders we create embeddings separately for predictions and labels
|
||||
predictions_embeddings = self._similarity_model.encode(
|
||||
predicted_answers, batch_size=self._batch_size, convert_to_tensor=True
|
||||
)
|
||||
label_embeddings = self._similarity_model.encode(
|
||||
ground_truth_answers, batch_size=self._batch_size, convert_to_tensor=True
|
||||
)
|
||||
|
||||
# Compute cosine-similarities
|
||||
similarity_scores = [
|
||||
float(util.cos_sim(pred_embedding, label_embedding).cpu().squeeze().numpy())
|
||||
for pred_embedding, label_embedding in zip(predictions_embeddings, label_embeddings, strict=True)
|
||||
]
|
||||
|
||||
sas_score = np_mean(similarity_scores)
|
||||
|
||||
return {"score": sas_score, "individual_scores": similarity_scores}
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"llm_metadata_extractor": ["LLMMetadataExtractor"], "regex_text_extractor": ["RegexTextExtractor"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .llm_metadata_extractor import LLMMetadataExtractor as LLMMetadataExtractor
|
||||
from .regex_text_extractor import RegexTextExtractor as RegexTextExtractor
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"llm_document_content_extractor": ["LLMDocumentContentExtractor"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .llm_document_content_extractor import LLMDocumentContentExtractor as LLMDocumentContentExtractor
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,422 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
|
||||
from jinja2 import meta
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.converters.image.document_to_image import DocumentToImageContent
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses import ImageContent, TextContent
|
||||
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__)
|
||||
|
||||
|
||||
# Reserved key in the LLM JSON response that holds the main document text.
|
||||
DOCUMENT_CONTENT_KEY = "document_content"
|
||||
|
||||
|
||||
DEFAULT_PROMPT_TEMPLATE = """
|
||||
You are part of an information extraction pipeline that extracts the content of image-based documents.
|
||||
|
||||
Extract the content from the provided image.
|
||||
You need to extract the content exactly.
|
||||
Format everything as markdown.
|
||||
Make sure to retain the reading order of the document.
|
||||
|
||||
**Visual Elements**
|
||||
Do not extract figures, drawings, maps, graphs or any other visual elements.
|
||||
Instead, add a caption that describes briefly what you see in the visual element.
|
||||
You must describe each visual element.
|
||||
If you only see a visual element without other content, you must describe this visual element.
|
||||
Enclose each image caption with [img-caption][/img-caption]
|
||||
|
||||
**Tables**
|
||||
Make sure to format the table in markdown.
|
||||
Add a short caption below the table that describes the table's content.
|
||||
Enclose each table caption with [table-caption][/table-caption].
|
||||
The caption must be placed below the extracted table.
|
||||
|
||||
**Forms**
|
||||
Reproduce checkbox selections with markdown.
|
||||
|
||||
Return a single JSON object. It must contain the key "document_content" with the extracted text as value.
|
||||
|
||||
No markdown, no code fence, only raw JSON.
|
||||
|
||||
Document:"""
|
||||
|
||||
|
||||
@component
|
||||
class LLMDocumentContentExtractor:
|
||||
"""
|
||||
Extracts textual content and optionally metadata from image-based documents using a vision-enabled LLM.
|
||||
|
||||
One prompt and one LLM call per document. The component converts each document to an image via
|
||||
DocumentToImageContent and sends it to the ChatGenerator. The prompt must not contain Jinja variables.
|
||||
|
||||
Response handling:
|
||||
- If the LLM returns a **plain string** (non-JSON or not a JSON object), it is written to the document's content.
|
||||
- If the LLM returns a **JSON object with only the key** `document_content`, that value is written to content.
|
||||
- If the LLM returns a **JSON object with multiple keys**, the value of ``document_content`` (if present) is
|
||||
written to content and all other keys are merged into the document's metadata.
|
||||
|
||||
The ChatGenerator can be configured to return JSON (e.g. ``response_format={"type": "json_object"}``
|
||||
in ``generation_kwargs``).
|
||||
|
||||
Documents that fail extraction are returned in ``failed_documents`` with ``content_extraction_error`` in metadata.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.extractors.image import LLMDocumentContentExtractor
|
||||
|
||||
prompt = \"\"\"
|
||||
Extract the content from the provided image.
|
||||
Format everything as markdown. Return only the extracted content as a JSON object with the key 'document_content'.
|
||||
No markdown, no code fence, only raw JSON.
|
||||
|
||||
Extract metadata about the image like source of the image, date of creation, etc. if you can.
|
||||
Return this metadata as additional key-value pairs in the same JSON object.
|
||||
\"\"\"
|
||||
|
||||
chat_generator = OpenAIChatGenerator(
|
||||
generation_kwargs={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "entity_extraction",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"document_content": {"type": "string"},
|
||||
"author": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"document_type": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
extractor = LLMDocumentContentExtractor(
|
||||
chat_generator=chat_generator,
|
||||
file_path_meta_field="file_path",
|
||||
raise_on_failure=False
|
||||
)
|
||||
|
||||
documents = [
|
||||
Document(content="", meta={"file_path": "test/test_files/images/image_metadata.png"}),
|
||||
Document(content="", meta={"file_path": "test/test_files/images/apple.jpg", "page_number": 1})
|
||||
]
|
||||
result = extractor.run(documents=documents)
|
||||
updated_documents = result["documents"]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chat_generator: ChatGenerator,
|
||||
prompt: str = DEFAULT_PROMPT_TEMPLATE,
|
||||
file_path_meta_field: str = "file_path",
|
||||
root_path: str | None = None,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
raise_on_failure: bool = False,
|
||||
max_workers: int = 3,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the LLMDocumentContentExtractor component.
|
||||
|
||||
:param chat_generator: A ChatGenerator that supports vision input. Optionally configured for JSON
|
||||
(e.g. ``response_format={"type": "json_object"}`` in ``generation_kwargs``).
|
||||
:param prompt: Prompt for extraction. Must not contain Jinja variables.
|
||||
:param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF.
|
||||
:param root_path: The root directory path where document files are located. If provided, file paths in
|
||||
document metadata will be resolved relative to this path. If None, file paths are treated as absolute paths.
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). Can be "auto", "high", or "low".
|
||||
:param size: If provided, resizes the image to fit within (width, height) while keeping aspect ratio.
|
||||
:param raise_on_failure: If True, exceptions from the LLM are raised. If False, failed documents are returned.
|
||||
:param max_workers: Maximum number of threads for parallel LLM calls.
|
||||
"""
|
||||
self._chat_generator = chat_generator
|
||||
self.prompt = prompt
|
||||
self.file_path_meta_field = file_path_meta_field
|
||||
self.root_path = root_path or ""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
LLMDocumentContentExtractor._validate_prompt_no_variables(prompt)
|
||||
self.raise_on_failure = raise_on_failure
|
||||
self.max_workers = max_workers
|
||||
self._document_to_image_content = DocumentToImageContent(
|
||||
file_path_meta_field=file_path_meta_field, root_path=root_path, detail=detail, size=size
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
prompt=self.prompt,
|
||||
file_path_meta_field=self.file_path_meta_field,
|
||||
root_path=self.root_path,
|
||||
detail=self.detail,
|
||||
size=self.size,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
max_workers=self.max_workers,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "LLMDocumentContentExtractor":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary with serialized data.
|
||||
:returns:
|
||||
An instance of the component.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
deserialize_chatgenerator_inplace(init_params, key="chat_generator")
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@staticmethod
|
||||
def _validate_prompt_no_variables(prompt: str) -> None:
|
||||
ast = SandboxedEnvironment().parse(prompt)
|
||||
template_variables = meta.find_undeclared_variables(ast)
|
||||
variables = list(template_variables)
|
||||
if variables:
|
||||
raise ValueError(
|
||||
f"The prompt must not have any variables, only instructions on how to extract the content of the "
|
||||
f"the image-based document. Found {','.join(variables)} in the prompt."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _process_response(response_text: str) -> tuple[str | None, dict[str, Any], str | None]:
|
||||
"""
|
||||
Parse LLM response. Returns (content, meta_updates, error).
|
||||
|
||||
- Plain string (non-JSON): use entire response as document content;
|
||||
- Valid JSON object: use key ``document_content`` for Document.content and all other keys for Document.metadata;
|
||||
- Valid JSON but not an object (e.g. array or primitive), report an error;
|
||||
"""
|
||||
try:
|
||||
parsed = _parse_dict_from_json(response_text, raise_on_failure=True)
|
||||
except json.JSONDecodeError:
|
||||
return response_text, {}, None
|
||||
except ValueError:
|
||||
return None, {}, "Response must be a JSON object, not an array or primitive."
|
||||
|
||||
content = parsed.get(DOCUMENT_CONTENT_KEY)
|
||||
meta_updates = {k: v for k, v in parsed.items() if k != DOCUMENT_CONTENT_KEY}
|
||||
return content, meta_updates, None
|
||||
|
||||
def _run_on_thread(self, image_content: ImageContent | None) -> dict[str, Any]:
|
||||
"""
|
||||
Execute the LLM inference in a separate thread for each document.
|
||||
|
||||
:param image_content: The image content for one document, or None if conversion failed.
|
||||
:returns:
|
||||
The LLM response if successful, or a dictionary with an "error" key on failure.
|
||||
"""
|
||||
if image_content is None:
|
||||
return {"error": "Document has no content, skipping LLM call."}
|
||||
|
||||
# the prompt is the same for all documents, so we can set it up once here for each document/thread
|
||||
message = ChatMessage.from_user(content_parts=[TextContent(text=self.prompt), image_content])
|
||||
|
||||
try:
|
||||
result = self._chat_generator.run(messages=[message])
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
logger.exception(
|
||||
"LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.",
|
||||
class_name=self._chat_generator.__class__.__name__,
|
||||
error=e,
|
||||
)
|
||||
result = {"error": "LLM failed with exception: " + str(e)}
|
||||
|
||||
return result
|
||||
|
||||
async def _run_async(self, image_content: ImageContent | None) -> dict[str, Any]:
|
||||
"""
|
||||
Execute the LLM inference asynchronously for each document.
|
||||
|
||||
:param image_content: The image content for one document, or None if conversion failed.
|
||||
:returns:
|
||||
The LLM response if successful, or a dictionary with an "error" key on failure.
|
||||
"""
|
||||
if image_content is None:
|
||||
return {"error": "Document has no content, skipping LLM call."}
|
||||
|
||||
# the prompt is the same for all documents, so we can set it up once here for each document
|
||||
message = ChatMessage.from_user(content_parts=[TextContent(text=self.prompt), image_content])
|
||||
|
||||
try:
|
||||
result = await _execute_component_async(self._chat_generator, messages=[message])
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
logger.exception(
|
||||
"LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.",
|
||||
class_name=self._chat_generator.__class__.__name__,
|
||||
error=e,
|
||||
)
|
||||
result = {"error": "LLM failed with exception: " + str(e)}
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _process_llm_results(document: Document, result: dict[str, Any]) -> tuple[Document, bool]:
|
||||
"""
|
||||
Process one document's LLM result using the unified response logic.
|
||||
|
||||
Returns (updated_document, True if success else False).
|
||||
"""
|
||||
if "error" in result:
|
||||
new_meta = {**document.meta, "extraction_error": result["error"]}
|
||||
return replace(document, meta=new_meta), False
|
||||
|
||||
# remove potentially existing error metadata from previous runs
|
||||
new_meta = {**document.meta}
|
||||
new_meta.pop("extraction_error", None)
|
||||
|
||||
# process the LLM response considering the possible response formats
|
||||
response_text = result["replies"][0].text
|
||||
content, meta_updates, error = LLMDocumentContentExtractor._process_response(response_text)
|
||||
|
||||
if error:
|
||||
new_meta["extraction_error"] = error
|
||||
return replace(document, meta=new_meta), False
|
||||
|
||||
new_meta.update(meta_updates)
|
||||
final_content = document.content if content is None else content
|
||||
return replace(document, content=final_content, meta=new_meta), True
|
||||
|
||||
@component.output_types(documents=list[Document], failed_documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run extraction on image-based documents. One LLM call per document.
|
||||
|
||||
:param documents: A list of image-based documents to process. Each must have a valid file path in its metadata.
|
||||
:returns:
|
||||
A dictionary with "documents" (successfully processed) and "failed_documents" (with failure metadata).
|
||||
"""
|
||||
if not documents:
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
self.warm_up()
|
||||
|
||||
image_contents = self._document_to_image_content.run(documents=documents)["image_contents"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
results = executor.map(self._run_on_thread, image_contents)
|
||||
|
||||
successful_documents = []
|
||||
failed_documents = []
|
||||
for document, result in zip(documents, results, strict=True):
|
||||
doc, success = self._process_llm_results(document, result)
|
||||
if success:
|
||||
successful_documents.append(doc)
|
||||
else:
|
||||
failed_documents.append(doc)
|
||||
|
||||
return {"documents": successful_documents, "failed_documents": failed_documents}
|
||||
|
||||
@component.output_types(documents=list[Document], failed_documents=list[Document])
|
||||
async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Asynchronously run extraction on image-based documents. One LLM call per document.
|
||||
|
||||
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. LLM calls are made concurrently, bounded by `max_workers`.
|
||||
If the chat generator only implements a synchronous `run` method, it is executed in a thread to avoid
|
||||
blocking the event loop.
|
||||
|
||||
:param documents: A list of image-based documents to process. Each must have a valid file path in its metadata.
|
||||
:returns:
|
||||
A dictionary with "documents" (successfully processed) and "failed_documents" (with failure metadata).
|
||||
"""
|
||||
if not documents:
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
image_contents = self._document_to_image_content.run(documents=documents)["image_contents"]
|
||||
|
||||
# Run the LLM on each image content, bounding concurrency per task so max_workers is enforced.
|
||||
sem = asyncio.Semaphore(max(1, self.max_workers))
|
||||
|
||||
async def _bounded_run(image_content: ImageContent | None) -> dict[str, Any]:
|
||||
async with sem:
|
||||
return await self._run_async(image_content)
|
||||
|
||||
results = await asyncio.gather(*[_bounded_run(image_content) for image_content in image_contents])
|
||||
|
||||
successful_documents = []
|
||||
failed_documents = []
|
||||
for document, result in zip(documents, results, strict=True):
|
||||
doc, success = self._process_llm_results(document, result)
|
||||
if success:
|
||||
successful_documents.append(doc)
|
||||
else:
|
||||
failed_documents.append(doc)
|
||||
|
||||
return {"documents": successful_documents, "failed_documents": failed_documents}
|
||||
@@ -0,0 +1,474 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import copy
|
||||
import json
|
||||
from asyncio import Semaphore, gather
|
||||
from collections.abc import Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import meta
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.utils import deserialize_chatgenerator_inplace, expand_page_range
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.misc import _parse_dict_from_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class LLMMetadataExtractor:
|
||||
"""
|
||||
Extracts metadata from documents using a Large Language Model (LLM).
|
||||
|
||||
The metadata is extracted by providing a prompt to an LLM that generates the metadata.
|
||||
|
||||
This component expects as input a list of documents and a prompt. The prompt must have exactly one variable, called
|
||||
`document`, that points to a single document in the list of documents. So to access the content of the document,
|
||||
you can use `{{ document.content }}` in the prompt.
|
||||
|
||||
The component will run the LLM on each document in the list and extract metadata from the document. The metadata
|
||||
will be added to the document's metadata field. If the LLM fails to extract metadata from a document, the document
|
||||
will be added to the `failed_documents` list. The failed documents will have the keys `metadata_extraction_error` and
|
||||
`metadata_extraction_response` in their metadata. These documents can be re-run with another extractor to
|
||||
extract metadata by using the `metadata_extraction_response` and `metadata_extraction_error` in the prompt.
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.extractors.llm_metadata_extractor import LLMMetadataExtractor
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
|
||||
NER_PROMPT = '''
|
||||
-Goal-
|
||||
Given text and a list of entity types, identify all entities of those types from the text.
|
||||
|
||||
-Steps-
|
||||
1. Identify all entities. For each identified entity, extract the following information:
|
||||
- entity: Name of the entity
|
||||
- entity_type: One of the following types: [organization, product, service, industry]
|
||||
Format each entity as a JSON like: {"entity": <entity_name>, "entity_type": <entity_type>}
|
||||
|
||||
2. Return output in a single list with all the entities identified in steps 1.
|
||||
|
||||
-Examples-
|
||||
######################
|
||||
Example 1:
|
||||
entity_types: [organization, person, partnership, financial metric, product, service, industry, investment strategy, market trend]
|
||||
text: Another area of strength is our co-brand issuance. Visa is the primary network partner for eight of the top
|
||||
10 co-brand partnerships in the US today and we are pleased that Visa has finalized a multi-year extension of
|
||||
our successful credit co-branded partnership with Alaska Airlines, a portfolio that benefits from a loyal customer
|
||||
base and high cross-border usage.
|
||||
We have also had significant co-brand momentum in CEMEA. First, we launched a new co-brand card in partnership
|
||||
with Qatar Airways, British Airways and the National Bank of Kuwait. Second, we expanded our strong global
|
||||
Marriott relationship to launch Qatar's first hospitality co-branded card with Qatar Islamic Bank. Across the
|
||||
United Arab Emirates, we now have exclusive agreements with all the leading airlines marked by a recent
|
||||
agreement with Emirates Skywards.
|
||||
And we also signed an inaugural Airline co-brand agreement in Morocco with Royal Air Maroc. Now newer digital
|
||||
issuers are equally
|
||||
------------------------
|
||||
output:
|
||||
{"entities": [{"entity": "Visa", "entity_type": "company"}, {"entity": "Alaska Airlines", "entity_type": "company"}, {"entity": "Qatar Airways", "entity_type": "company"}, {"entity": "British Airways", "entity_type": "company"}, {"entity": "National Bank of Kuwait", "entity_type": "company"}, {"entity": "Marriott", "entity_type": "company"}, {"entity": "Qatar Islamic Bank", "entity_type": "company"}, {"entity": "Emirates Skywards", "entity_type": "company"}, {"entity": "Royal Air Maroc", "entity_type": "company"}]}
|
||||
#############################
|
||||
-Real Data-
|
||||
######################
|
||||
entity_types: [company, organization, person, country, product, service]
|
||||
text: {{ document.content }}
|
||||
######################
|
||||
output:
|
||||
'''
|
||||
|
||||
docs = [
|
||||
Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"),
|
||||
Document(content="Hugging Face is a company that was founded in New York, USA and is known for its Transformers library")
|
||||
]
|
||||
|
||||
chat_generator = OpenAIChatGenerator(
|
||||
generation_kwargs={
|
||||
"max_completion_tokens": 500,
|
||||
"temperature": 0.0,
|
||||
"seed": 0,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "entity_extraction",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity": {"type": "string"},
|
||||
"entity_type": {"type": "string"}
|
||||
},
|
||||
"required": ["entity", "entity_type"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["entities"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
max_retries=1,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
extractor = LLMMetadataExtractor(
|
||||
prompt=NER_PROMPT,
|
||||
chat_generator=chat_generator,
|
||||
expected_keys=["entities"],
|
||||
raise_on_failure=False,
|
||||
)
|
||||
|
||||
extractor.run(documents=docs)
|
||||
# >> {'documents': [
|
||||
# Document(id=.., content: 'deepset was founded in 2018 in Berlin, and is known for its Haystack framework',
|
||||
# meta: {'entities': [{'entity': 'deepset', 'entity_type': 'company'}, {'entity': 'Berlin', 'entity_type': 'city'},
|
||||
# {'entity': 'Haystack', 'entity_type': 'product'}]}),
|
||||
# Document(id=.., content: 'Hugging Face is a company that was founded in New York, USA and is known for its Transformers library',
|
||||
# meta: {'entities': [
|
||||
# {'entity': 'Hugging Face', 'entity_type': 'company'}, {'entity': 'New York', 'entity_type': 'city'},
|
||||
# {'entity': 'USA', 'entity_type': 'country'}, {'entity': 'Transformers', 'entity_type': 'product'}
|
||||
# ]})
|
||||
# ]
|
||||
# 'failed_documents': []
|
||||
# }
|
||||
# >>
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt: str,
|
||||
chat_generator: ChatGenerator,
|
||||
expected_keys: list[str] | None = None,
|
||||
page_range: list[str | int] | None = None,
|
||||
raise_on_failure: bool = False,
|
||||
max_workers: int = 3,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the LLMMetadataExtractor.
|
||||
|
||||
:param prompt: The prompt to be used for the LLM. It must contain exactly one variable, called `document`,
|
||||
which points to a single document in the list of documents. For example, to access the content of the
|
||||
document, use `{{ document.content }}` in the prompt.
|
||||
:param chat_generator: a ChatGenerator instance which represents the LLM. In order for the component to work,
|
||||
the LLM should be configured to return a JSON object. For example, when using the OpenAIChatGenerator, you
|
||||
should pass `{"response_format": {"type": "json_object"}}` in the `generation_kwargs`.
|
||||
:param expected_keys: The keys expected in the JSON output from the LLM.
|
||||
:param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract
|
||||
metadata from the first and third pages of each document. It also accepts printable range strings, e.g.:
|
||||
['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10,11, 12.
|
||||
If None, metadata will be extracted from the entire document for each document in the documents list.
|
||||
This parameter is optional and can be overridden in the `run` method.
|
||||
:param raise_on_failure: Whether to raise an error on failure during the execution of the Generator or
|
||||
validation of the JSON output.
|
||||
:param max_workers: The maximum number of workers to use in the thread pool executor.
|
||||
This parameter is used limit the maximum number of requests that should be allowed to run concurrently
|
||||
when using the `run_async` method.
|
||||
"""
|
||||
self.prompt = prompt
|
||||
ast = SandboxedEnvironment().parse(prompt)
|
||||
template_variables = meta.find_undeclared_variables(ast)
|
||||
variables = list(template_variables)
|
||||
if variables != ["document"]:
|
||||
raise ValueError(
|
||||
f"Prompt must have exactly one variable called 'document'. "
|
||||
f"Found {','.join(variables) or 'no variables'} in the prompt."
|
||||
)
|
||||
self.builder = PromptBuilder(prompt, required_variables=variables)
|
||||
self.raise_on_failure = raise_on_failure
|
||||
self.expected_keys = expected_keys or []
|
||||
self.splitter = DocumentSplitter(split_by="page", split_length=1)
|
||||
self.expanded_range = expand_page_range(page_range) if page_range else None
|
||||
self.max_workers = max_workers
|
||||
self._chat_generator = chat_generator
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator and splitter.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator and splitter on the serving event loop.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's and splitter's resources.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's and splitter's async resources.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
|
||||
return default_to_dict(
|
||||
self,
|
||||
prompt=self.prompt,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
expected_keys=self.expected_keys,
|
||||
page_range=self.expanded_range,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
max_workers=self.max_workers,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "LLMMetadataExtractor":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary with serialized data.
|
||||
:returns:
|
||||
An instance of the component.
|
||||
"""
|
||||
|
||||
deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator")
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _extract_metadata(self, llm_answer: str) -> dict[str, Any]:
|
||||
try:
|
||||
parsed_metadata = _parse_dict_from_json(llm_answer, expected_keys=self.expected_keys, raise_on_failure=True)
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
"Response from the LLM is not valid JSON or missing expected keys. Received output: {response}",
|
||||
response=llm_answer,
|
||||
)
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
return {"error": "Response is not valid JSON or missing keys. Error: " + str(e)}
|
||||
|
||||
return parsed_metadata
|
||||
|
||||
def _prepare_prompts(
|
||||
self, documents: list[Document], expanded_range: list[int] | None = None
|
||||
) -> list[ChatMessage | None]:
|
||||
all_prompts: list[ChatMessage | None] = []
|
||||
for document in documents:
|
||||
if not document.content:
|
||||
logger.warning("Document {doc_id} has no content. Skipping metadata extraction.", doc_id=document.id)
|
||||
all_prompts.append(None)
|
||||
continue
|
||||
|
||||
if expanded_range:
|
||||
doc_copy = copy.deepcopy(document)
|
||||
pages = self.splitter.run(documents=[doc_copy])
|
||||
content = ""
|
||||
for idx, page in enumerate(pages["documents"]):
|
||||
if idx + 1 in expanded_range and page.content is not None:
|
||||
content += page.content
|
||||
doc_copy = replace(doc_copy, content=content)
|
||||
else:
|
||||
doc_copy = document
|
||||
|
||||
prompt_with_doc = self.builder.run(template=self.prompt, template_variables={"document": doc_copy})
|
||||
|
||||
# build a ChatMessage with the prompt
|
||||
message = ChatMessage.from_user(prompt_with_doc["prompt"])
|
||||
all_prompts.append(message)
|
||||
|
||||
return all_prompts
|
||||
|
||||
def _run_on_thread(self, prompt: ChatMessage | None) -> dict[str, Any]:
|
||||
# If prompt is None, return an error dictionary
|
||||
if prompt is None:
|
||||
return {"error": "Document has no content, skipping LLM call."}
|
||||
|
||||
try:
|
||||
result = self._chat_generator.run(messages=[prompt])
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
logger.exception(
|
||||
"LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.",
|
||||
class_name=self._chat_generator.__class__.__name__,
|
||||
error=e,
|
||||
)
|
||||
result = {"error": "LLM failed with exception: " + str(e)}
|
||||
return result
|
||||
|
||||
async def _run_async(self, prompt: ChatMessage | None) -> dict[str, Any]:
|
||||
# If prompt is None, return an error dictionary
|
||||
if prompt is None:
|
||||
return {"error": "Document has no content, skipping LLM call."}
|
||||
|
||||
try:
|
||||
result = await _execute_component_async(self._chat_generator, messages=[prompt])
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
logger.exception(
|
||||
"LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.",
|
||||
class_name=self._chat_generator.__class__.__name__,
|
||||
error=e,
|
||||
)
|
||||
result = {"error": "LLM failed with exception: " + str(e)}
|
||||
return result
|
||||
|
||||
def _process_results(
|
||||
self, documents: list[Document], results: Iterable[dict[str, Any]]
|
||||
) -> tuple[list[Document], list[Document]]:
|
||||
successful_documents = []
|
||||
failed_documents = []
|
||||
for document, result in zip(documents, results, strict=True):
|
||||
new_meta = {**document.meta}
|
||||
if "error" in result:
|
||||
new_meta["metadata_extraction_error"] = result["error"]
|
||||
new_meta["metadata_extraction_response"] = None
|
||||
failed_documents.append(replace(document, meta=new_meta))
|
||||
continue
|
||||
|
||||
parsed_metadata = self._extract_metadata(result["replies"][0].text)
|
||||
if "error" in parsed_metadata:
|
||||
new_meta["metadata_extraction_error"] = parsed_metadata["error"]
|
||||
new_meta["metadata_extraction_response"] = result["replies"][0]
|
||||
failed_documents.append(replace(document, meta=new_meta))
|
||||
continue
|
||||
|
||||
for key in parsed_metadata:
|
||||
new_meta[key] = parsed_metadata[key]
|
||||
# Remove metadata_extraction_error and metadata_extraction_response if present from previous runs
|
||||
new_meta.pop("metadata_extraction_error", None)
|
||||
new_meta.pop("metadata_extraction_response", None)
|
||||
successful_documents.append(replace(document, meta=new_meta))
|
||||
return successful_documents, failed_documents
|
||||
|
||||
@component.output_types(documents=list[Document], failed_documents=list[Document])
|
||||
def run(self, documents: list[Document], page_range: list[str | int] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Extract metadata from documents using a Large Language Model.
|
||||
|
||||
If `page_range` is provided, the metadata will be extracted from the specified range of pages. This component
|
||||
will split the documents into pages and extract metadata from the specified range of pages. The metadata will be
|
||||
extracted from the entire document if `page_range` is not provided.
|
||||
|
||||
The original documents will be returned updated with the extracted metadata.
|
||||
|
||||
:param documents: List of documents to extract metadata from.
|
||||
:param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract
|
||||
metadata from the first and third pages of each document. It also accepts printable range
|
||||
strings, e.g.: ['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10,
|
||||
11, 12.
|
||||
If None, metadata will be extracted from the entire document for each document in the
|
||||
documents list.
|
||||
:returns:
|
||||
A dictionary with the keys:
|
||||
- "documents": A list of documents that were successfully updated with the extracted metadata.
|
||||
- "failed_documents": A list of documents that failed to extract metadata. These documents will have
|
||||
"metadata_extraction_error" and "metadata_extraction_response" in their metadata. These documents can be
|
||||
re-run with the extractor to extract metadata.
|
||||
"""
|
||||
if len(documents) == 0:
|
||||
logger.warning("No documents provided. Skipping metadata extraction.")
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
self.warm_up()
|
||||
|
||||
expanded_range = self.expanded_range
|
||||
if page_range:
|
||||
expanded_range = expand_page_range(page_range)
|
||||
|
||||
# Create ChatMessage prompts for each document
|
||||
all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range)
|
||||
|
||||
# Run the LLM on each prompt
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
results = executor.map(self._run_on_thread, all_prompts)
|
||||
|
||||
successful_documents, failed_documents = self._process_results(documents, results)
|
||||
|
||||
return {"documents": successful_documents, "failed_documents": failed_documents}
|
||||
|
||||
@component.output_types(documents=list[Document], failed_documents=list[Document])
|
||||
async def run_async(self, documents: list[Document], page_range: list[str | int] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously extract metadata from documents using a Large Language Model.
|
||||
|
||||
If `page_range` is provided, the metadata will be extracted from the specified range of pages. This component
|
||||
will split the documents into pages and extract metadata from the specified range of pages. The metadata will be
|
||||
extracted from the entire document if `page_range` is not provided.
|
||||
|
||||
The original documents will be returned updated with the extracted metadata.
|
||||
|
||||
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.
|
||||
|
||||
:param documents: List of documents to extract metadata from.
|
||||
:param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract
|
||||
metadata from the first and third pages of each document. It also accepts printable range
|
||||
strings, e.g.: ['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10,
|
||||
11, 12.
|
||||
If None, metadata will be extracted from the entire document for each document in the
|
||||
documents list.
|
||||
:returns:
|
||||
A dictionary with the keys:
|
||||
- "documents": A list of documents that were successfully updated with the extracted metadata.
|
||||
- "failed_documents": A list of documents that failed to extract metadata. These documents will have
|
||||
"metadata_extraction_error" and "metadata_extraction_response" in their metadata. These documents can be
|
||||
re-run with the extractor to extract metadata.
|
||||
"""
|
||||
if len(documents) == 0:
|
||||
logger.warning("No documents provided. Skipping metadata extraction.")
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
expanded_range = self.expanded_range
|
||||
if page_range:
|
||||
expanded_range = expand_page_range(page_range)
|
||||
|
||||
# Create ChatMessage prompts for each document
|
||||
all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range)
|
||||
|
||||
# Run the LLM on each prompt, bounding concurrency per task so max_workers is enforced.
|
||||
sem = Semaphore(max(1, self.max_workers))
|
||||
|
||||
async def _bounded_run(prompt: ChatMessage | None) -> dict[str, Any]:
|
||||
async with sem:
|
||||
return await self._run_async(prompt)
|
||||
|
||||
results = await gather(*[_bounded_run(prompt) for prompt in all_prompts])
|
||||
|
||||
successful_documents, failed_documents = self._process_results(documents, results)
|
||||
|
||||
return {"documents": successful_documents, "failed_documents": failed_documents}
|
||||
@@ -0,0 +1,146 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class RegexTextExtractor:
|
||||
"""
|
||||
Extracts text from chat message or string input using a regex pattern.
|
||||
|
||||
RegexTextExtractor parses input text or ChatMessages using a provided regular expression pattern.
|
||||
It can be configured to search through all messages or only the last message in a list of ChatMessages.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.extractors import RegexTextExtractor
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
# Using with a string
|
||||
parser = RegexTextExtractor(regex_pattern='<issue url=\"(.+)\">')
|
||||
result = parser.run(text_or_messages='<issue url="github.com/hahahaha">hahahah</issue>')
|
||||
# result: {"captured_text": "github.com/hahahaha"}
|
||||
|
||||
# Using with ChatMessages
|
||||
messages = [ChatMessage.from_user('<issue url="github.com/hahahaha">hahahah</issue>')]
|
||||
result = parser.run(text_or_messages=messages)
|
||||
# result: {"captured_text": "github.com/hahahaha"}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, regex_pattern: str) -> None:
|
||||
"""
|
||||
Creates an instance of the RegexTextExtractor component.
|
||||
|
||||
:param regex_pattern:
|
||||
The regular expression pattern used to extract text.
|
||||
The pattern should include a capture group to extract the desired text.
|
||||
Example: `'<issue url="(.+)">'` captures `'github.com/hahahaha'` from `'<issue url="github.com/hahahaha">'`.
|
||||
"""
|
||||
self.regex_pattern = regex_pattern
|
||||
|
||||
# Check if the pattern has at least one capture group
|
||||
num_groups = re.compile(regex_pattern).groups
|
||||
if num_groups < 1:
|
||||
logger.warning(
|
||||
"The provided regex pattern {regex_pattern} doesn't contain any capture groups. "
|
||||
"The entire match will be returned instead.",
|
||||
regex_pattern=regex_pattern,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, regex_pattern=self.regex_pattern)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "RegexTextExtractor":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
# return_empty_on_no_match is an old parameter. We'd like to avoid that pipelines break if it's still present.
|
||||
if "return_empty_on_no_match" in data["init_parameters"]:
|
||||
logger.warning("The `return_empty_on_no_match` init parameter has been removed and will be ignored.")
|
||||
data["init_parameters"].pop("return_empty_on_no_match")
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(captured_text=str)
|
||||
def run(self, text_or_messages: str | list[ChatMessage]) -> dict[str, str]:
|
||||
"""
|
||||
Extracts text from input using the configured regex pattern.
|
||||
|
||||
:param text_or_messages:
|
||||
Either a string or a list of ChatMessage objects to search through.
|
||||
|
||||
:returns:
|
||||
- `{"captured_text": "matched text"}` if a match is found
|
||||
- `{"captured_text": ""}` if no match is found
|
||||
|
||||
:raises TypeError: if receiving a list the last element is not a ChatMessage instance.
|
||||
"""
|
||||
if isinstance(text_or_messages, str):
|
||||
return self._build_result(self._extract_from_text(text_or_messages))
|
||||
if not text_or_messages:
|
||||
logger.warning("Received empty list of messages")
|
||||
return {"captured_text": ""}
|
||||
return self._process_last_message(text_or_messages)
|
||||
|
||||
def _build_result(self, result: str | list[str]) -> dict:
|
||||
"""Helper method to build the return dictionary based on configuration."""
|
||||
if (isinstance(result, str) and result == "") or (isinstance(result, list) and not result):
|
||||
return {"captured_text": ""}
|
||||
return {"captured_text": result}
|
||||
|
||||
def _process_last_message(self, messages: list[ChatMessage]) -> dict:
|
||||
"""
|
||||
Process only the last message and build the result.
|
||||
|
||||
:raises TypeError: If the last element of the list is not a ChatMessage instance.
|
||||
"""
|
||||
last_message = messages[-1]
|
||||
if not isinstance(last_message, ChatMessage):
|
||||
raise TypeError(f"Expected ChatMessage object, got {type(last_message)}")
|
||||
if last_message.text is None:
|
||||
logger.warning("Last message has no text content")
|
||||
return {"captured_text": ""}
|
||||
result = self._extract_from_text(last_message.text)
|
||||
return self._build_result(result)
|
||||
|
||||
def _extract_from_text(self, text: str) -> str | list[str]:
|
||||
"""
|
||||
Extract text using the regex pattern.
|
||||
|
||||
:param text:
|
||||
The text to search through.
|
||||
|
||||
:returns:
|
||||
The text captured by the first capturing group in the regex pattern.
|
||||
If the pattern has no capture groups, returns the entire match.
|
||||
If no match is found, returns an empty string.
|
||||
"""
|
||||
match = re.search(self.regex_pattern, text)
|
||||
if not match:
|
||||
return ""
|
||||
if match.groups():
|
||||
return match.group(1)
|
||||
return match.group(0)
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"link_content": ["LinkContentFetcher"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .link_content import LinkContentFetcher as LinkContentFetcher
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,493 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.lazy_imports import LazyImport
|
||||
from haystack.version import __version__
|
||||
|
||||
# HTTP/2 support via lazy import
|
||||
with LazyImport("Run 'pip install httpx[http2]' to use HTTP/2 support") as h2_import:
|
||||
pass # nothing to import as we simply set the http2 attribute, library handles the rest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_USER_AGENT = f"haystack/LinkContentFetcher/{__version__}"
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"accept": "*/*",
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Accept-Language": "en-US,en;q=0.9,it;q=0.8,es;q=0.7",
|
||||
"referer": "https://www.google.com/",
|
||||
}
|
||||
|
||||
|
||||
def _merge_headers(*args: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
Merge a list of dict using case-insensitively
|
||||
|
||||
:param args: a list of dict to merge
|
||||
:returns: The merged dict
|
||||
"""
|
||||
merged = {}
|
||||
keymap = {}
|
||||
|
||||
for d in args:
|
||||
for k, v in d.items():
|
||||
kl = k.lower()
|
||||
keymap[kl] = k
|
||||
merged[kl] = v
|
||||
|
||||
return {keymap[kl]: v for kl, v in merged.items()}
|
||||
|
||||
|
||||
def _text_content_handler(response: httpx.Response) -> ByteStream:
|
||||
"""
|
||||
Handles text content.
|
||||
|
||||
:param response: Response object from the request.
|
||||
:returns: The extracted text.
|
||||
"""
|
||||
return ByteStream.from_string(response.text)
|
||||
|
||||
|
||||
def _binary_content_handler(response: httpx.Response) -> ByteStream:
|
||||
"""
|
||||
Handles binary content.
|
||||
|
||||
:param response: Response object from the request.
|
||||
:returns: The extracted binary file-like object.
|
||||
"""
|
||||
return ByteStream(data=response.content)
|
||||
|
||||
|
||||
@component
|
||||
class LinkContentFetcher:
|
||||
"""
|
||||
Fetches and extracts content from URLs.
|
||||
|
||||
It supports various content types, retries on failures, and automatic user-agent rotation for failed web
|
||||
requests. Use it as the data-fetching step in your pipelines.
|
||||
|
||||
You may need to convert LinkContentFetcher's output into a list of documents. Use HTMLToDocument
|
||||
converter to do this.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.fetchers.link_content import LinkContentFetcher
|
||||
|
||||
fetcher = LinkContentFetcher()
|
||||
streams = fetcher.run(urls=["https://www.google.com"])["streams"]
|
||||
|
||||
assert len(streams) == 1
|
||||
assert streams[0].meta == {'content_type': 'text/html', 'url': 'https://www.google.com'}
|
||||
assert streams[0].data
|
||||
```
|
||||
|
||||
For async usage:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from haystack.components.fetchers import LinkContentFetcher
|
||||
|
||||
async def fetch_async():
|
||||
fetcher = LinkContentFetcher()
|
||||
result = await fetcher.run_async(urls=["https://www.google.com"])
|
||||
return result["streams"]
|
||||
|
||||
streams = asyncio.run(fetch_async())
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
raise_on_failure: bool = True,
|
||||
user_agents: list[str] | None = None,
|
||||
retry_attempts: int = 2,
|
||||
timeout: int = 3,
|
||||
http2: bool = False,
|
||||
client_kwargs: dict | None = None,
|
||||
request_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the component.
|
||||
|
||||
:param raise_on_failure: If `True`, raises an exception if it fails to fetch a single URL.
|
||||
For multiple URLs, it logs errors and returns the content it successfully fetched.
|
||||
:param user_agents: [User agents](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent)
|
||||
for fetching content. If `None`, a default user agent is used.
|
||||
:param retry_attempts: The number of times to retry to fetch the URL's content.
|
||||
:param timeout: Timeout in seconds for the request.
|
||||
:param http2: Whether to enable HTTP/2 support for requests. Defaults to False.
|
||||
Requires the 'h2' package to be installed (via `pip install httpx[http2]`).
|
||||
:param client_kwargs: Additional keyword arguments to pass to the httpx client.
|
||||
If `None`, default values are used.
|
||||
"""
|
||||
self.raise_on_failure = raise_on_failure
|
||||
self.user_agents = user_agents or [DEFAULT_USER_AGENT]
|
||||
self.current_user_agent_idx: int = 0
|
||||
self.retry_attempts = retry_attempts
|
||||
self.timeout = timeout
|
||||
self.http2 = http2
|
||||
self.client_kwargs = client_kwargs or {}
|
||||
self.request_headers = request_headers or {}
|
||||
|
||||
# Configure default client settings
|
||||
self.client_kwargs.setdefault("timeout", timeout)
|
||||
self.client_kwargs.setdefault("follow_redirects", True)
|
||||
|
||||
# httpx clients are built lazily in warm_up / warm_up_async (resource lifecycle)
|
||||
self._client: httpx.Client | None = None
|
||||
self._async_client: httpx.AsyncClient | None = None
|
||||
|
||||
# register default content handlers that extract data from the response
|
||||
self.handlers: dict[str, Callable[[httpx.Response], ByteStream]] = defaultdict(lambda: _text_content_handler)
|
||||
self.handlers["text/*"] = _text_content_handler
|
||||
self.handlers["text/html"] = _binary_content_handler
|
||||
self.handlers["application/json"] = _text_content_handler
|
||||
self.handlers["application/*"] = _binary_content_handler
|
||||
self.handlers["image/*"] = _binary_content_handler
|
||||
self.handlers["audio/*"] = _binary_content_handler
|
||||
self.handlers["video/*"] = _binary_content_handler
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
stop=stop_after_attempt(self.retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=(retry_if_exception_type((httpx.HTTPStatusError, httpx.RequestError))),
|
||||
# This method is invoked only after failed requests (exception raised)
|
||||
after=self._switch_user_agent,
|
||||
)
|
||||
def get_response(url: str) -> httpx.Response:
|
||||
assert self._client is not None # mypy: client is built by warm_up before run
|
||||
response = self._client.get(url, headers=self._get_headers())
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
self._get_response: Callable = get_response
|
||||
|
||||
def _build_client_kwargs(self) -> dict[str, Any]:
|
||||
"""
|
||||
Build the keyword arguments used to construct the httpx clients.
|
||||
|
||||
Resolves optional HTTP/2 support, downgrading to HTTP/1.1 if the 'h2' package is not installed.
|
||||
"""
|
||||
client_kwargs = {**self.client_kwargs}
|
||||
|
||||
# Optional HTTP/2 support
|
||||
if self.http2:
|
||||
try:
|
||||
h2_import.check()
|
||||
client_kwargs["http2"] = True
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"HTTP/2 support requested but 'h2' package is not installed. "
|
||||
"Falling back to HTTP/1.1. Install with `pip install httpx[http2]` to enable HTTP/2 support."
|
||||
)
|
||||
self.http2 = False # Update the setting to match actual capability
|
||||
|
||||
return client_kwargs
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous httpx client.
|
||||
"""
|
||||
if self._client is None:
|
||||
self._client = httpx.Client(**self._build_client_kwargs())
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous httpx client on the serving event loop.
|
||||
"""
|
||||
if self._async_client is None:
|
||||
self._async_client = httpx.AsyncClient(**self._build_client_kwargs())
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous httpx client.
|
||||
"""
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous httpx client.
|
||||
"""
|
||||
if self._async_client is not None:
|
||||
await self._async_client.aclose()
|
||||
self._async_client = None
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""
|
||||
Build headers with precedence
|
||||
|
||||
client defaults -> component defaults -> user-provided -> rotating UA
|
||||
"""
|
||||
base = dict(self._client.headers) if self._client is not None else {}
|
||||
return _merge_headers(
|
||||
base, REQUEST_HEADERS, self.request_headers, {"User-Agent": self.user_agents[self.current_user_agent_idx]}
|
||||
)
|
||||
|
||||
@component.output_types(streams=list[ByteStream])
|
||||
def run(self, urls: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
Fetches content from a list of URLs and returns a list of extracted content streams.
|
||||
|
||||
Each content stream is a `ByteStream` object containing the extracted content as binary data.
|
||||
Each ByteStream object in the returned list corresponds to the contents of a single URL.
|
||||
The content type of each stream is stored in the metadata of the ByteStream object under
|
||||
the key "content_type". The URL of the fetched content is stored under the key "url".
|
||||
|
||||
:param urls: A list of URLs to fetch content from.
|
||||
:returns: `ByteStream` objects representing the extracted content.
|
||||
|
||||
:raises Exception: If the provided list of URLs contains only a single URL, and `raise_on_failure` is set to
|
||||
`True`, an exception will be raised in case of an error during content retrieval.
|
||||
In all other scenarios, any retrieval errors are logged, and a list of successfully retrieved `ByteStream`
|
||||
objects is returned.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
streams: list[ByteStream] = []
|
||||
if not urls:
|
||||
return {"streams": streams}
|
||||
|
||||
# don't use multithreading if there's only one URL
|
||||
if len(urls) == 1:
|
||||
stream_metadata, stream = self._fetch(urls[0])
|
||||
stream.meta.update(stream_metadata)
|
||||
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
||||
streams.append(stream)
|
||||
else:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
results = executor.map(self._fetch_with_exception_suppression, urls)
|
||||
|
||||
for stream_metadata, stream in results: # type: ignore
|
||||
if stream_metadata is not None and stream is not None:
|
||||
stream.meta.update(stream_metadata)
|
||||
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
||||
streams.append(stream)
|
||||
|
||||
return {"streams": streams}
|
||||
|
||||
@component.output_types(streams=list[ByteStream])
|
||||
async def run_async(self, urls: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously fetches content from a list of URLs and returns a list of extracted content streams.
|
||||
|
||||
This is the asynchronous version of the `run` method with the same parameters and return values.
|
||||
|
||||
:param urls: A list of URLs to fetch content from.
|
||||
:returns: `ByteStream` objects representing the extracted content.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
streams: list[ByteStream] = []
|
||||
if not urls:
|
||||
return {"streams": streams}
|
||||
|
||||
assert self._async_client is not None # mypy: async_client is built by warm_up_async above
|
||||
# Create tasks for all URLs using _fetch_async directly
|
||||
tasks = [self._fetch_async(url, self._async_client) for url in urls]
|
||||
|
||||
# Only capture exceptions when we have multiple URLs or raise_on_failure=False
|
||||
# This ensures errors propagate appropriately for single URLs with raise_on_failure=True
|
||||
return_exceptions = not (len(urls) == 1 and self.raise_on_failure)
|
||||
results = await asyncio.gather(*tasks, return_exceptions=return_exceptions)
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(results):
|
||||
# Handle exception results (only happens when return_exceptions=True)
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("Error fetching {url}: {error}", url=urls[i], error=str(result))
|
||||
# Add an empty result for failed URLs when raise_on_failure=False
|
||||
if not self.raise_on_failure:
|
||||
streams.append(ByteStream(data=b"", meta={"content_type": "Unknown", "url": urls[i]}))
|
||||
continue
|
||||
|
||||
# Process successful results
|
||||
# At this point, result is not an exception, so we need to cast it to the correct type for mypy
|
||||
if not isinstance(result, Exception): # Runtime check
|
||||
# Use cast to tell mypy that result is the tuple type returned by _fetch_async
|
||||
result_tuple = cast(tuple[dict[str, str] | None, ByteStream | None], result)
|
||||
stream_metadata, stream = result_tuple
|
||||
if stream_metadata is not None and stream is not None:
|
||||
stream.meta.update(stream_metadata)
|
||||
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
||||
streams.append(stream)
|
||||
|
||||
return {"streams": streams}
|
||||
|
||||
def _fetch(self, url: str) -> tuple[dict[str, str], ByteStream]:
|
||||
"""
|
||||
Fetches content from a URL and returns it as a ByteStream.
|
||||
|
||||
:param url: The URL to fetch content from.
|
||||
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
||||
ByteStream metadata contains the URL and the content type of the fetched content.
|
||||
The content type is a string indicating the type of content fetched (for example, "text/html",
|
||||
"application/pdf"). The ByteStream object contains the fetched content as binary data.
|
||||
|
||||
:raises: If an error occurs during content retrieval and `raise_on_failure` is set to True, this method will
|
||||
raise an exception. Otherwise, all fetching errors are logged, and an empty ByteStream is returned.
|
||||
|
||||
"""
|
||||
content_type: str = "text/html"
|
||||
stream: ByteStream = ByteStream(data=b"")
|
||||
try:
|
||||
response = self._get_response(url)
|
||||
content_type = self._get_content_type(response)
|
||||
handler: Callable = self._resolve_handler(content_type)
|
||||
stream = handler(response)
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
# less verbose log as this is expected to happen often (requests failing, blocked, etc.)
|
||||
logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e))
|
||||
|
||||
finally:
|
||||
self.current_user_agent_idx = 0
|
||||
|
||||
return {"content_type": content_type, "url": url}, stream
|
||||
|
||||
async def _fetch_async(
|
||||
self, url: str, client: httpx.AsyncClient
|
||||
) -> tuple[dict[str, str] | None, ByteStream | None]:
|
||||
"""
|
||||
Asynchronously fetches content from a URL and returns it as a ByteStream.
|
||||
|
||||
:param url: The URL to fetch content from.
|
||||
:param client: The async httpx client to use for making requests.
|
||||
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
||||
"""
|
||||
content_type: str = "text/html"
|
||||
stream: ByteStream | None = None
|
||||
metadata: dict[str, str] | None = None
|
||||
|
||||
try:
|
||||
response = await self._get_response_async(url, client)
|
||||
content_type = self._get_content_type(response)
|
||||
handler: Callable = self._resolve_handler(content_type)
|
||||
stream = handler(response)
|
||||
metadata = {"content_type": content_type, "url": url}
|
||||
except Exception as e:
|
||||
if self.raise_on_failure:
|
||||
raise e
|
||||
logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e))
|
||||
# Create an empty ByteStream for failed requests when raise_on_failure is False
|
||||
stream = ByteStream(data=b"")
|
||||
metadata = {"content_type": content_type, "url": url}
|
||||
finally:
|
||||
self.current_user_agent_idx = 0
|
||||
|
||||
return metadata, stream
|
||||
|
||||
def _fetch_with_exception_suppression(self, url: str) -> tuple[dict[str, str] | None, ByteStream | None]:
|
||||
"""
|
||||
Fetches content from a URL and returns it as a ByteStream.
|
||||
|
||||
If `raise_on_failure` is set to True, this method will wrap the fetch() method and catch any exceptions.
|
||||
Otherwise, it will simply call the fetch() method.
|
||||
:param url: The URL to fetch content from.
|
||||
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
||||
|
||||
"""
|
||||
if self.raise_on_failure:
|
||||
try:
|
||||
return self._fetch(url)
|
||||
except Exception as e:
|
||||
logger.warning("Error fetching {url}: {error}", url=url, error=str(e))
|
||||
return {"content_type": "Unknown", "url": url}, None
|
||||
else:
|
||||
return self._fetch(url)
|
||||
|
||||
async def _get_response_async(self, url: str, client: httpx.AsyncClient) -> httpx.Response:
|
||||
"""
|
||||
Asynchronously gets a response from a URL with retry logic.
|
||||
|
||||
:param url: The URL to fetch.
|
||||
:param client: The async httpx client to use for making requests.
|
||||
:returns: The httpx Response object.
|
||||
"""
|
||||
attempt = 0
|
||||
last_exception = None
|
||||
|
||||
while attempt <= self.retry_attempts:
|
||||
try:
|
||||
response = await client.get(url, headers=self._get_headers())
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except (httpx.HTTPStatusError, httpx.RequestError) as e:
|
||||
last_exception = e
|
||||
attempt += 1
|
||||
if attempt <= self.retry_attempts:
|
||||
self._switch_user_agent(None) # Switch user agent for next retry
|
||||
# Wait before retry using exponential backoff
|
||||
await asyncio.sleep(min(2 * 2 ** (attempt - 1), 10))
|
||||
else:
|
||||
break
|
||||
|
||||
# If we've exhausted all retries, raise the last exception
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
|
||||
# This should never happen, but just in case
|
||||
raise httpx.RequestError("Failed to get response after retries", request=None)
|
||||
|
||||
def _get_content_type(self, response: httpx.Response) -> str:
|
||||
"""
|
||||
Get the content type of the response.
|
||||
|
||||
:param response: The response object.
|
||||
:returns: The content type of the response.
|
||||
"""
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
return content_type.split(";")[0]
|
||||
|
||||
def _resolve_handler(self, content_type: str) -> Callable[[httpx.Response], ByteStream]:
|
||||
"""
|
||||
Resolves the handler for the given content type.
|
||||
|
||||
First, it tries to find a direct match for the content type in the handlers dictionary.
|
||||
If no direct match is found, it tries to find a pattern match using the fnmatch function.
|
||||
If no pattern match is found, it returns the default handler for text/plain.
|
||||
|
||||
:param content_type: The content type to resolve the handler for.
|
||||
:returns: The handler for the given content type, if found. Otherwise, the default handler for text/plain.
|
||||
"""
|
||||
# direct match
|
||||
if content_type in self.handlers:
|
||||
return self.handlers[content_type]
|
||||
|
||||
# pattern matches
|
||||
for pattern, handler in self.handlers.items():
|
||||
if fnmatch(content_type, pattern):
|
||||
return handler
|
||||
|
||||
# default handler
|
||||
return self.handlers["text/plain"]
|
||||
|
||||
def _switch_user_agent(self, retry_state: RetryCallState | None = None) -> None: # noqa: ARG002
|
||||
"""
|
||||
Switches the User-Agent for this LinkContentRetriever to the next one in the list of user agents.
|
||||
|
||||
Used by tenacity to retry the requests with a different user agent.
|
||||
|
||||
:param retry_state: The retry state (unused, required by tenacity).
|
||||
"""
|
||||
self.current_user_agent_idx = (self.current_user_agent_idx + 1) % len(self.user_agents)
|
||||
logger.debug("Switched user agent to {user_agent}", user_agent=self.user_agents[self.current_user_agent_idx])
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"openai_image_generator": ["OpenAIImageGenerator"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .openai_image_generator import OpenAIImageGenerator as OpenAIImageGenerator
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"openai": ["OpenAIChatGenerator"],
|
||||
"openai_responses": ["OpenAIResponsesChatGenerator"],
|
||||
"azure": ["AzureOpenAIChatGenerator"],
|
||||
"azure_responses": ["AzureOpenAIResponsesChatGenerator"],
|
||||
"fallback": ["FallbackChatGenerator"],
|
||||
"llm": ["LLM"],
|
||||
"mock": ["MockChatGenerator"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .azure import AzureOpenAIChatGenerator as AzureOpenAIChatGenerator
|
||||
from .azure_responses import AzureOpenAIResponsesChatGenerator as AzureOpenAIResponsesChatGenerator
|
||||
from .fallback import FallbackChatGenerator as FallbackChatGenerator
|
||||
from .llm import LLM as LLM
|
||||
from .mock import MockChatGenerator as MockChatGenerator
|
||||
from .openai import OpenAIChatGenerator as OpenAIChatGenerator
|
||||
from .openai_responses import OpenAIResponsesChatGenerator as OpenAIResponsesChatGenerator
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,369 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from openai.lib._pydantic import to_strict_json_schema
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI, AzureADTokenProvider, AzureOpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses.streaming_chunk import StreamingCallbackT
|
||||
from haystack.tools import (
|
||||
ToolsType,
|
||||
_check_duplicate_tool_names,
|
||||
deserialize_tools_or_toolset_inplace,
|
||||
flatten_tools_or_toolsets,
|
||||
serialize_tools_or_toolset,
|
||||
warm_up_tools,
|
||||
)
|
||||
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
|
||||
@component
|
||||
class AzureOpenAIChatGenerator(OpenAIChatGenerator):
|
||||
"""
|
||||
Generates text using OpenAI's models on Azure.
|
||||
|
||||
It works with the gpt-4 - type models and supports streaming responses
|
||||
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
|
||||
format in input and output.
|
||||
|
||||
You can customize how the text is generated by passing parameters to the
|
||||
OpenAI API. Use the `**generation_kwargs` argument when you initialize
|
||||
the component or when you run it. Any parameter that works with
|
||||
`openai.ChatCompletion.create` will work here too.
|
||||
|
||||
For details on OpenAI API parameters, see
|
||||
[OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.generators.chat import AzureOpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.utils import Secret
|
||||
|
||||
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
|
||||
|
||||
client = AzureOpenAIChatGenerator(
|
||||
azure_endpoint="<Your Azure endpoint e.g. `https://your-company.azure.openai.com/>",
|
||||
api_key=Secret.from_token("<your-api-key>"),
|
||||
azure_deployment="<this is a model name, e.g. gpt-4.1-mini>")
|
||||
response = client.run(messages)
|
||||
print(response)
|
||||
```
|
||||
|
||||
```
|
||||
{'replies':
|
||||
[ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
|
||||
"Natural Language Processing (NLP) is a branch of artificial intelligence that focuses on
|
||||
enabling computers to understand, interpret, and generate human language in a way that is useful.")],
|
||||
_name=None,
|
||||
_meta={'model': 'gpt-4.1-mini', 'index': 0, 'finish_reason': 'stop',
|
||||
'usage': {'prompt_tokens': 15, 'completion_tokens': 36, 'total_tokens': 51}})]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
SUPPORTED_MODELS: ClassVar[list[str]] = [
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2-chat",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-chat",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-chat",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime-1.5",
|
||||
"gpt-audio-1.5",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
"codex-mini",
|
||||
"gpt-4",
|
||||
"gpt-35-turbo",
|
||||
"gpt-oss-120b",
|
||||
"computer-use-preview",
|
||||
]
|
||||
"""A non-exhaustive list of chat models supported by this component.
|
||||
See https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure
|
||||
for the full list."""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
azure_endpoint: str | Secret | None = None,
|
||||
api_version: str | Secret | None = "2024-12-01-preview",
|
||||
azure_deployment: str | None = "gpt-4.1-mini",
|
||||
api_key: Secret | None = Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False),
|
||||
azure_ad_token: Secret | None = Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False),
|
||||
organization: str | None = None,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool = False,
|
||||
*,
|
||||
azure_ad_token_provider: AzureADTokenProvider | AsyncAzureADTokenProvider | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Azure OpenAI Chat Generator component.
|
||||
|
||||
:param azure_endpoint: The endpoint of the deployed model, for example `"https://example-resource.azure.openai.com/"`.
|
||||
Can also be a [Secret](https://docs.haystack.deepset.ai/docs/secret-management), for example
|
||||
`Secret.from_env_var("AZURE_OPENAI_ENDPOINT")`, to resolve the value from an environment variable at
|
||||
runtime. This is useful to switch endpoints between environments (e.g. dev and prod) without changing the
|
||||
serialized pipeline.
|
||||
:param api_version: The version of the API to use. Defaults to 2024-12-01-preview.
|
||||
Can also be a [Secret](https://docs.haystack.deepset.ai/docs/secret-management), for example
|
||||
`Secret.from_env_var("AZURE_OPENAI_API_VERSION")`, to resolve the value from an environment variable at
|
||||
runtime.
|
||||
:param azure_deployment: The deployment of the model, usually the model name.
|
||||
:param api_key: The API key to use for authentication.
|
||||
:param azure_ad_token: [Azure Active Directory token](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id).
|
||||
:param organization: Your organization ID, defaults to `None`. For help, see
|
||||
[Setting up your organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
|
||||
:param streaming_callback: A callback function called when a new token is received from the stream.
|
||||
It accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
|
||||
as an argument.
|
||||
:param timeout: Timeout for OpenAI client calls. If not set, it defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries: Maximum number of retries to contact OpenAI after an internal error.
|
||||
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
|
||||
:param generation_kwargs: Other parameters to use for the model. These parameters are sent directly to
|
||||
the OpenAI endpoint. For details, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
|
||||
Some of the supported parameters:
|
||||
- `max_completion_tokens`: An upper bound for the number of tokens that can be generated for a completion,
|
||||
including visible output tokens and reasoning tokens.
|
||||
- `temperature`: The sampling temperature to use. Higher values mean the model takes more risks.
|
||||
Try 0.9 for more creative applications and 0 (argmax sampling) for ones with a well-defined answer.
|
||||
- `top_p`: Nucleus sampling is an alternative to sampling with temperature, where the model considers
|
||||
tokens with a top_p probability mass. For example, 0.1 means only the tokens comprising
|
||||
the top 10% probability mass are considered.
|
||||
- `n`: The number of completions to generate for each prompt. For example, with 3 prompts and n=2,
|
||||
the LLM will generate two completions per prompt, resulting in 6 completions total.
|
||||
- `stop`: One or more sequences after which the LLM should stop generating tokens.
|
||||
- `presence_penalty`: The penalty applied if a token is already present.
|
||||
Higher values make the model less likely to repeat the token.
|
||||
- `frequency_penalty`: Penalty applied if a token has already been generated.
|
||||
Higher values make the model less likely to repeat the token.
|
||||
- `logit_bias`: Adds a logit bias to specific tokens. The keys of the dictionary are tokens, and the
|
||||
values are the bias to add to that token.
|
||||
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
|
||||
If provided, the output will always be validated against this
|
||||
format (unless the model returns a tool call).
|
||||
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
|
||||
Notes:
|
||||
- This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o.
|
||||
Older models only support basic version of structured outputs through `{"type": "json_object"}`.
|
||||
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
|
||||
- For structured outputs with streaming,
|
||||
the `response_format` must be a JSON schema and not a Pydantic model.
|
||||
:param default_headers: Default headers to use for the AzureOpenAI client.
|
||||
:param tools:
|
||||
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
|
||||
:param tools_strict:
|
||||
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
|
||||
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
|
||||
:param azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on
|
||||
every request.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
"""
|
||||
# We intentionally do not call super().__init__ here because we only need to instantiate the client to interact
|
||||
# with the API.
|
||||
|
||||
# Why is this here?
|
||||
# AzureOpenAI init is forcing us to use an init method that takes either base_url or azure_endpoint as not
|
||||
# None init parameters. This way we accommodate the use case where env var AZURE_OPENAI_ENDPOINT is set instead
|
||||
# of passing it as a parameter.
|
||||
azure_endpoint = azure_endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
# `azure_endpoint` accepts either a plain string or a `Secret`. We keep the original value on the instance for
|
||||
# serialization and resolve it to a string only to validate that an endpoint was provided.
|
||||
resolved_azure_endpoint = (
|
||||
azure_endpoint.resolve_value() if isinstance(azure_endpoint, Secret) else azure_endpoint
|
||||
)
|
||||
if not resolved_azure_endpoint:
|
||||
raise ValueError("Please provide an Azure endpoint or set the environment variable AZURE_OPENAI_ENDPOINT.")
|
||||
|
||||
if api_key is None and azure_ad_token is None:
|
||||
raise ValueError("Please provide an API key or an Azure Active Directory token.")
|
||||
|
||||
# The check above makes mypy incorrectly infer that api_key is never None,
|
||||
# which propagates the incorrect type.
|
||||
self.api_key = api_key # type: ignore
|
||||
self.azure_ad_token = azure_ad_token
|
||||
self.generation_kwargs = generation_kwargs or {}
|
||||
self.streaming_callback = streaming_callback
|
||||
self.api_version = api_version
|
||||
self.azure_endpoint = azure_endpoint
|
||||
self.azure_deployment = azure_deployment
|
||||
self.organization = organization
|
||||
self.model = azure_deployment or "gpt-4.1-mini"
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.default_headers = default_headers or {}
|
||||
self.azure_ad_token_provider = azure_ad_token_provider
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
_check_duplicate_tool_names(flatten_tools_or_toolsets(tools))
|
||||
self.tools = tools
|
||||
self.tools_strict = tools_strict
|
||||
|
||||
self.client: AzureOpenAI | None = None
|
||||
self.async_client: AsyncAzureOpenAI | None = None
|
||||
self._tools_warmed_up = False
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
resolved_azure_endpoint = (
|
||||
self.azure_endpoint.resolve_value() if isinstance(self.azure_endpoint, Secret) else self.azure_endpoint
|
||||
)
|
||||
resolved_api_version = (
|
||||
self.api_version.resolve_value() if isinstance(self.api_version, Secret) else self.api_version
|
||||
)
|
||||
return {
|
||||
"api_version": resolved_api_version,
|
||||
"azure_endpoint": resolved_azure_endpoint,
|
||||
"azure_deployment": self.azure_deployment,
|
||||
"api_key": self.api_key.resolve_value() if self.api_key is not None else None,
|
||||
"azure_ad_token": self.azure_ad_token.resolve_value() if self.azure_ad_token is not None else None,
|
||||
"organization": self.organization,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
"default_headers": self.default_headers,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
}
|
||||
|
||||
def _warm_up_tools(self) -> None:
|
||||
if not self._tools_warmed_up:
|
||||
warm_up_tools(self.tools)
|
||||
self._tools_warmed_up = True
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the tools and initialize the synchronous Azure OpenAI client.
|
||||
"""
|
||||
self._warm_up_tools()
|
||||
if self.client is None:
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Warm up the tools and initialize the asynchronous Azure OpenAI client on the serving event loop.
|
||||
"""
|
||||
self._warm_up_tools()
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous Azure OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous Azure OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
|
||||
azure_ad_token_provider_name = None
|
||||
if self.azure_ad_token_provider:
|
||||
azure_ad_token_provider_name = serialize_callable(self.azure_ad_token_provider)
|
||||
# If the response format is a Pydantic model, it's converted to openai's json schema format
|
||||
# If it's already a json schema, it's left as is
|
||||
generation_kwargs = self.generation_kwargs.copy()
|
||||
response_format = generation_kwargs.get("response_format")
|
||||
if response_format and issubclass(response_format, BaseModel):
|
||||
json_schema = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"strict": True,
|
||||
"schema": to_strict_json_schema(response_format),
|
||||
},
|
||||
}
|
||||
generation_kwargs["response_format"] = json_schema
|
||||
return default_to_dict(
|
||||
self,
|
||||
azure_endpoint=self.azure_endpoint.to_dict()
|
||||
if isinstance(self.azure_endpoint, Secret)
|
||||
else self.azure_endpoint,
|
||||
azure_deployment=self.azure_deployment,
|
||||
organization=self.organization,
|
||||
api_version=self.api_version.to_dict() if isinstance(self.api_version, Secret) else self.api_version,
|
||||
streaming_callback=callback_name,
|
||||
generation_kwargs=generation_kwargs,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
api_key=self.api_key,
|
||||
azure_ad_token=self.azure_ad_token,
|
||||
default_headers=self.default_headers,
|
||||
tools=serialize_tools_or_toolset(self.tools),
|
||||
tools_strict=self.tools_strict,
|
||||
azure_ad_token_provider=azure_ad_token_provider_name,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIChatGenerator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data: The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
|
||||
init_params = data.get("init_parameters", {})
|
||||
serialized_callback_handler = init_params.get("streaming_callback")
|
||||
if serialized_callback_handler:
|
||||
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
|
||||
serialized_azure_ad_token_provider = init_params.get("azure_ad_token_provider")
|
||||
if serialized_azure_ad_token_provider:
|
||||
data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable(
|
||||
serialized_azure_ad_token_provider
|
||||
)
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,272 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from openai.lib._pydantic import to_strict_json_schema
|
||||
from pydantic import BaseModel
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
|
||||
from haystack.dataclasses.streaming_chunk import StreamingCallbackT
|
||||
from haystack.tools import ToolsType, deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset
|
||||
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
||||
|
||||
|
||||
@component
|
||||
class AzureOpenAIResponsesChatGenerator(OpenAIResponsesChatGenerator):
|
||||
"""
|
||||
Completes chats using OpenAI's Responses API on Azure.
|
||||
|
||||
It works with the gpt-5 and o-series models and supports streaming responses
|
||||
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
|
||||
format in input and output.
|
||||
|
||||
You can customize how the text is generated by passing parameters to the
|
||||
OpenAI API. Use the `**generation_kwargs` argument when you initialize
|
||||
the component or when you run it. Any parameter that works with
|
||||
`openai.Responses.create` will work here too.
|
||||
|
||||
For details on OpenAI API parameters, see
|
||||
[OpenAI documentation](https://platform.openai.com/docs/api-reference/responses).
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.generators.chat import AzureOpenAIResponsesChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
|
||||
|
||||
client = AzureOpenAIResponsesChatGenerator(
|
||||
azure_endpoint="https://example-resource.azure.openai.com/",
|
||||
generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}}
|
||||
)
|
||||
response = client.run(messages)
|
||||
print(response)
|
||||
```
|
||||
"""
|
||||
|
||||
SUPPORTED_MODELS: ClassVar[list[str]] = [
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.3-chat",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-chat",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-chat",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5-pro",
|
||||
"gpt-5-codex",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-chat",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"computer-use-preview",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-image-1",
|
||||
"gpt-image-1-mini",
|
||||
"gpt-image-1.5",
|
||||
"o1",
|
||||
"o3-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
]
|
||||
"""A non-exhaustive list of chat models supported by this component.
|
||||
See https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#model-support for the full list."""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Secret | Callable[[], str] | Callable[[], Awaitable[str]] = Secret.from_env_var(
|
||||
"AZURE_OPENAI_API_KEY", strict=False
|
||||
),
|
||||
azure_endpoint: str | None = None,
|
||||
azure_deployment: str = "gpt-5-mini",
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
organization: str | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool = False,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the AzureOpenAIResponsesChatGenerator component.
|
||||
|
||||
:param api_key: The API key to use for authentication. Can be:
|
||||
- A `Secret` object containing the API key.
|
||||
- A `Secret` object containing the [Azure Active Directory token](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id).
|
||||
- A function that returns an Azure Active Directory token.
|
||||
:param azure_endpoint: The endpoint of the deployed model, for example `"https://example-resource.azure.openai.com/"`.
|
||||
:param azure_deployment: The deployment of the model, usually the model name.
|
||||
:param organization: Your organization ID, defaults to `None`. For help, see
|
||||
[Setting up your organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
|
||||
:param streaming_callback: A callback function called when a new token is received from the stream.
|
||||
It accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
|
||||
as an argument.
|
||||
:param timeout: Timeout for OpenAI client calls. If not set, it defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries: Maximum number of retries to contact OpenAI after an internal error.
|
||||
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
|
||||
:param generation_kwargs: Other parameters to use for the model. These parameters are sent
|
||||
directly to the OpenAI endpoint.
|
||||
See OpenAI [documentation](https://platform.openai.com/docs/api-reference/responses) for
|
||||
more details.
|
||||
Some of the supported parameters:
|
||||
- `temperature`: What sampling temperature to use. Higher values like 0.8 will make the output more random,
|
||||
while lower values like 0.2 will make it more focused and deterministic.
|
||||
- `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
|
||||
considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
|
||||
comprising the top 10% probability mass are considered.
|
||||
- `previous_response_id`: The ID of the previous response.
|
||||
Use this to create multi-turn conversations.
|
||||
- `text_format`: A Pydantic model that enforces the structure of the model's response.
|
||||
If provided, the output will always be validated against this
|
||||
format (unless the model returns a tool call).
|
||||
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
|
||||
- `text`: A JSON schema that enforces the structure of the model's response.
|
||||
If provided, the output will always be validated against this
|
||||
format (unless the model returns a tool call).
|
||||
Notes:
|
||||
- Both JSON Schema and Pydantic models are supported for latest models starting from GPT-4o.
|
||||
- If both are provided, `text_format` takes precedence and json schema passed to `text` is ignored.
|
||||
- Currently, this component doesn't support streaming for structured outputs.
|
||||
- Older models only support basic version of structured outputs through `{"type": "json_object"}`.
|
||||
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
|
||||
- `reasoning`: A dictionary of parameters for reasoning. For example:
|
||||
- `summary`: The summary of the reasoning.
|
||||
- `effort`: The level of effort to put into the reasoning. Can be `low`, `medium` or `high`.
|
||||
- `generate_summary`: Whether to generate a summary of the reasoning.
|
||||
Note: OpenAI does not return the reasoning tokens, but we can view summary if its enabled.
|
||||
For details, see the [OpenAI Reasoning documentation](https://platform.openai.com/docs/guides/reasoning).
|
||||
:param tools:
|
||||
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
|
||||
:param tools_strict:
|
||||
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
|
||||
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
"""
|
||||
azure_endpoint = azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
if azure_endpoint is None:
|
||||
raise ValueError(
|
||||
"You must provide `azure_endpoint` or set the `AZURE_OPENAI_ENDPOINT` environment variable."
|
||||
)
|
||||
self._azure_endpoint = azure_endpoint
|
||||
self._azure_deployment = azure_deployment
|
||||
super(AzureOpenAIResponsesChatGenerator, self).__init__( # noqa: UP008
|
||||
api_key=api_key, # type: ignore[arg-type]
|
||||
model=self._azure_deployment,
|
||||
streaming_callback=streaming_callback,
|
||||
api_base_url=f"{self._azure_endpoint.rstrip('/')}/openai/v1",
|
||||
organization=organization,
|
||||
generation_kwargs=generation_kwargs,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
tools=tools,
|
||||
tools_strict=tools_strict,
|
||||
http_client_kwargs=http_client_kwargs,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
|
||||
|
||||
# API key can be a secret or a callable
|
||||
serialized_api_key = (
|
||||
serialize_callable(self.api_key)
|
||||
if callable(self.api_key)
|
||||
else self.api_key.to_dict()
|
||||
if isinstance(self.api_key, Secret)
|
||||
else None
|
||||
)
|
||||
|
||||
# If the text format is a Pydantic model, it's converted to openai's json schema format
|
||||
# If it's already a json schema, it's left as is
|
||||
generation_kwargs = self.generation_kwargs.copy()
|
||||
text_format = generation_kwargs.pop("text_format", None)
|
||||
if text_format and isinstance(text_format, type) and issubclass(text_format, BaseModel):
|
||||
json_schema = {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": text_format.__name__,
|
||||
"strict": True,
|
||||
"schema": to_strict_json_schema(text_format),
|
||||
}
|
||||
}
|
||||
# json schema needs to be passed to text parameter instead of text_format
|
||||
generation_kwargs["text"] = json_schema
|
||||
|
||||
# OpenAI/MCP tools are passed as list of dictionaries
|
||||
serialized_tools: dict[str, Any] | list[dict[str, Any]] | None
|
||||
if self.tools and isinstance(self.tools, list) and isinstance(self.tools[0], dict):
|
||||
# mypy can't infer that self.tools is list[dict] here
|
||||
serialized_tools = self.tools
|
||||
else:
|
||||
serialized_tools = serialize_tools_or_toolset(self.tools) # type: ignore[arg-type]
|
||||
|
||||
return default_to_dict(
|
||||
self,
|
||||
azure_endpoint=self._azure_endpoint,
|
||||
api_key=serialized_api_key,
|
||||
azure_deployment=self._azure_deployment,
|
||||
streaming_callback=callback_name,
|
||||
organization=self.organization,
|
||||
generation_kwargs=generation_kwargs,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
tools=serialized_tools,
|
||||
tools_strict=self.tools_strict,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIResponsesChatGenerator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data: The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
# If api_key is a str, it's a callable (Secrets are handled automatically by default_from_dict)
|
||||
serialized_api_key = data["init_parameters"].get("api_key")
|
||||
if isinstance(serialized_api_key, str):
|
||||
data["init_parameters"]["api_key"] = deserialize_callable(serialized_api_key)
|
||||
|
||||
# we only deserialize the tools if they are haystack tools
|
||||
# because openai tools are not serialized in the same way
|
||||
tools = data["init_parameters"].get("tools")
|
||||
if tools and (
|
||||
isinstance(tools, dict)
|
||||
and tools.get("type") == "haystack.tools.toolset.Toolset"
|
||||
or isinstance(tools, list)
|
||||
and tools[0].get("type") == "haystack.tools.tool.Tool"
|
||||
):
|
||||
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
|
||||
|
||||
init_params = data.get("init_parameters", {})
|
||||
serialized_callback_handler = init_params.get("streaming_callback")
|
||||
if serialized_callback_handler:
|
||||
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,257 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.components.generators.utils import _normalize_messages
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses import ChatMessage, StreamingCallbackT
|
||||
from haystack.tools import ToolsType
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.deserialization import deserialize_component_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class FallbackChatGenerator:
|
||||
"""
|
||||
A chat generator wrapper that tries multiple chat generators sequentially.
|
||||
|
||||
It forwards all parameters transparently to the underlying chat generators and returns the first successful result.
|
||||
Calls chat generators sequentially until one succeeds. Falls back on any exception raised by a generator.
|
||||
If all chat generators fail, it raises a RuntimeError with details.
|
||||
|
||||
Timeout enforcement is fully delegated to the underlying chat generators. The fallback mechanism will only
|
||||
work correctly if the underlying chat generators implement proper timeout handling and raise exceptions
|
||||
when timeouts occur. For predictable latency guarantees, ensure your chat generators:
|
||||
- Support a `timeout` parameter in their initialization
|
||||
- Implement timeout as total wall-clock time (shared deadline for both streaming and non-streaming)
|
||||
- Raise timeout exceptions (e.g., TimeoutError, asyncio.TimeoutError, httpx.TimeoutException) when exceeded
|
||||
|
||||
Note: Most well-implemented chat generators (OpenAI, Anthropic, Cohere, etc.) support timeout parameters
|
||||
with consistent semantics. For HTTP-based LLM providers, a single timeout value (e.g., `timeout=30`)
|
||||
typically applies to all connection phases: connection setup, read, write, and pool. For streaming
|
||||
responses, read timeout is the maximum gap between chunks. For non-streaming, it's the time limit for
|
||||
receiving the complete response.
|
||||
|
||||
Fail over is automatically triggered when a generator raises any exception, including:
|
||||
- Timeout errors (if the generator implements and raises them)
|
||||
- Rate limit errors (429)
|
||||
- Authentication errors (401)
|
||||
- Context length errors (400)
|
||||
- Server errors (500+)
|
||||
- Any other exception
|
||||
"""
|
||||
|
||||
def __init__(self, chat_generators: list[ChatGenerator]) -> None:
|
||||
"""
|
||||
Creates an instance of FallbackChatGenerator.
|
||||
|
||||
:param chat_generators: A non-empty list of chat generator components to try in order.
|
||||
"""
|
||||
if not chat_generators:
|
||||
msg = "'chat_generators' must be a non-empty list"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.chat_generators = list(chat_generators)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the component, including nested chat generators."""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generators=[
|
||||
component_to_dict(gen, name=f"chat_generator_{idx}") for idx, gen in enumerate(self.chat_generators)
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> FallbackChatGenerator:
|
||||
"""Rebuild the component from a serialized representation, restoring nested chat generators."""
|
||||
# Reconstruct nested chat generators from their serialized dicts
|
||||
init_params = data.get("init_parameters", {})
|
||||
serialized = init_params.get("chat_generators") or []
|
||||
deserialized: list[Any] = []
|
||||
for g in serialized:
|
||||
# Use the generic component deserializer available in Haystack
|
||||
holder = {"component": g}
|
||||
deserialize_component_inplace(holder, key="component")
|
||||
deserialized.append(holder["component"])
|
||||
init_params["chat_generators"] = deserialized
|
||||
data["init_parameters"] = init_params
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""Warm up all underlying chat generators."""
|
||||
for gen in self.chat_generators:
|
||||
if hasattr(gen, "warm_up"):
|
||||
gen.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""Warm up all underlying chat generators on the serving event loop."""
|
||||
for gen in self.chat_generators:
|
||||
if hasattr(gen, "warm_up_async"):
|
||||
await gen.warm_up_async()
|
||||
elif hasattr(gen, "warm_up"):
|
||||
gen.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying chat generators' resources."""
|
||||
for gen in self.chat_generators:
|
||||
if hasattr(gen, "close"):
|
||||
gen.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""Release the underlying chat generators' async resources."""
|
||||
for gen in self.chat_generators:
|
||||
if hasattr(gen, "close_async"):
|
||||
await gen.close_async()
|
||||
elif hasattr(gen, "close"):
|
||||
gen.close()
|
||||
|
||||
def _run_single_sync(
|
||||
self,
|
||||
gen: Any,
|
||||
messages: list[ChatMessage],
|
||||
generation_kwargs: dict[str, Any] | None,
|
||||
tools: ToolsType | None,
|
||||
streaming_callback: StreamingCallbackT | None,
|
||||
) -> dict[str, Any]:
|
||||
return gen.run(
|
||||
messages=messages, generation_kwargs=generation_kwargs, tools=tools, streaming_callback=streaming_callback
|
||||
)
|
||||
|
||||
async def _run_single_async(
|
||||
self,
|
||||
gen: Any,
|
||||
messages: list[ChatMessage],
|
||||
generation_kwargs: dict[str, Any] | None,
|
||||
tools: ToolsType | None,
|
||||
streaming_callback: StreamingCallbackT | None,
|
||||
) -> dict[str, Any]:
|
||||
return await _execute_component_async(
|
||||
gen,
|
||||
messages=messages,
|
||||
generation_kwargs=generation_kwargs,
|
||||
tools=tools,
|
||||
streaming_callback=streaming_callback,
|
||||
)
|
||||
|
||||
@component.output_types(replies=list[ChatMessage], meta=dict[str, Any])
|
||||
def run(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
) -> dict[str, list[ChatMessage] | dict[str, Any]]:
|
||||
"""
|
||||
Execute chat generators sequentially until one succeeds.
|
||||
|
||||
:param messages: The conversation history as a list of ChatMessage instances.
|
||||
:param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens).
|
||||
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for function calling capabilities.
|
||||
:param streaming_callback: Optional callable for handling streaming responses.
|
||||
:returns: A dictionary with:
|
||||
- "replies": Generated ChatMessage instances from the first successful generator.
|
||||
- "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class,
|
||||
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
|
||||
:raises RuntimeError: If all chat generators fail.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
failed: list[str] = []
|
||||
last_error: BaseException | None = None
|
||||
|
||||
for idx, gen in enumerate(self.chat_generators):
|
||||
gen_name = gen.__class__.__name__
|
||||
try:
|
||||
result = self._run_single_sync(gen, messages, generation_kwargs, tools, streaming_callback)
|
||||
replies = result.get("replies", [])
|
||||
meta = dict(result.get("meta", {}))
|
||||
meta.update(
|
||||
{
|
||||
"successful_chat_generator_index": idx,
|
||||
"successful_chat_generator_class": gen_name,
|
||||
"total_attempts": idx + 1,
|
||||
"failed_chat_generators": failed,
|
||||
}
|
||||
)
|
||||
return {"replies": replies, "meta": meta}
|
||||
except Exception as e: # noqa: BLE001 - fallback logic should handle any exception
|
||||
logger.warning(
|
||||
"ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e
|
||||
)
|
||||
failed.append(gen_name)
|
||||
last_error = e
|
||||
|
||||
failed_names = ", ".join(failed)
|
||||
msg = (
|
||||
f"All {len(self.chat_generators)} chat generators failed. "
|
||||
f"Last error: {last_error}. Failed chat generators: [{failed_names}]"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@component.output_types(replies=list[ChatMessage], meta=dict[str, Any])
|
||||
async def run_async(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
) -> dict[str, list[ChatMessage] | dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously execute chat generators sequentially until one succeeds.
|
||||
|
||||
:param messages: The conversation history as a list of ChatMessage instances.
|
||||
:param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens).
|
||||
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for function calling capabilities.
|
||||
:param streaming_callback: Optional callable for handling streaming responses.
|
||||
:returns: A dictionary with:
|
||||
- "replies": Generated ChatMessage instances from the first successful generator.
|
||||
- "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class,
|
||||
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
|
||||
:raises RuntimeError: If all chat generators fail.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
failed: list[str] = []
|
||||
last_error: BaseException | None = None
|
||||
|
||||
for idx, gen in enumerate(self.chat_generators):
|
||||
gen_name = gen.__class__.__name__
|
||||
try:
|
||||
result = await self._run_single_async(gen, messages, generation_kwargs, tools, streaming_callback)
|
||||
replies = result.get("replies", [])
|
||||
meta = dict(result.get("meta", {}))
|
||||
meta.update(
|
||||
{
|
||||
"successful_chat_generator_index": idx,
|
||||
"successful_chat_generator_class": gen_name,
|
||||
"total_attempts": idx + 1,
|
||||
"failed_chat_generators": failed,
|
||||
}
|
||||
)
|
||||
return {"replies": replies, "meta": meta}
|
||||
except Exception as e: # noqa: BLE001 - fallback logic should handle any exception
|
||||
logger.warning(
|
||||
"ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e
|
||||
)
|
||||
failed.append(gen_name)
|
||||
last_error = e
|
||||
|
||||
failed_names = ", ".join(failed)
|
||||
msg = (
|
||||
f"All {len(self.chat_generators)} chat generators failed. "
|
||||
f"Last error: {last_error}. Failed chat generators: [{failed_names}]"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
@@ -0,0 +1,205 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.agents.agent import Agent
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
|
||||
from haystack.dataclasses import ChatMessage, StreamingCallbackT
|
||||
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
|
||||
from haystack.utils.deserialization import deserialize_component_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class LLM(Agent):
|
||||
"""
|
||||
A text generation component powered by a large language model.
|
||||
|
||||
The LLM component is a simplified version of the Agent that focuses solely on text generation
|
||||
without tool usage. It processes messages and returns a single response from the language model.
|
||||
|
||||
### Usage examples
|
||||
```python
|
||||
from haystack.components.generators.chat import LLM
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
|
||||
llm = LLM(
|
||||
chat_generator=OpenAIChatGenerator(),
|
||||
system_prompt="You are a helpful translation assistant.",
|
||||
user_prompt="Summarize the following document: {{ document }}",
|
||||
required_variables=["document"],
|
||||
)
|
||||
|
||||
result = llm.run(document="The weather is lovely today and the sun is shining. ")
|
||||
print(result["last_message"].text)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chat_generator: ChatGenerator,
|
||||
system_prompt: str | None = None,
|
||||
user_prompt: str | None = None,
|
||||
required_variables: list[str] | Literal["*"] = "*",
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the LLM component.
|
||||
|
||||
:param chat_generator: An instance of the chat generator that the LLM should use.
|
||||
:param system_prompt: System prompt for the LLM. Can be a plain string template or a Jinja2 message template.
|
||||
:param user_prompt: User prompt for the LLM. This prompt is appended to the messages provided at
|
||||
runtime. Can be a plain string template or a Jinja2 message template. If it contains template variables
|
||||
(e.g., `{{ variable_name }}`), they become inputs to the component. If omitted or if there are no
|
||||
template variables, `messages` must be provided at runtime instead.
|
||||
:param required_variables:
|
||||
Variables that must be provided as input to `user_prompt` or `system_prompt`.
|
||||
If a variable listed as required is not provided, an exception is raised.
|
||||
If set to `"*"`, all variables found in the prompt are required. Defaults to `"*"`.
|
||||
Only relevant when `user_prompt` or `system_prompt` contains template variables.
|
||||
:param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
|
||||
:raises ValueError: If user_prompt contains template variables but required_variables is an empty list.
|
||||
"""
|
||||
super(LLM, self).__init__( # noqa: UP008
|
||||
chat_generator=chat_generator,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
required_variables=required_variables,
|
||||
streaming_callback=streaming_callback,
|
||||
)
|
||||
if self._user_chat_prompt_builder is None or len(self._user_chat_prompt_builder.variables) == 0:
|
||||
# This means user_prompt is empty or has no template variables.
|
||||
# To ensure properly scheduling we then require messages to be passed at runtime.
|
||||
component.set_input_type(self, "messages", list[ChatMessage])
|
||||
else:
|
||||
# user prompt was provided with variables
|
||||
if isinstance(required_variables, list) and len(required_variables) == 0:
|
||||
raise ValueError(
|
||||
"required_variables must not be empty. Set it to '*' to require all variables, "
|
||||
"or provide a non-empty list of variable names."
|
||||
)
|
||||
component.set_input_type(self, "messages", list[ChatMessage], None)
|
||||
|
||||
# The Agent base class declares `step_count` and `tool_call_counts` as outputs, but an LLM never has tools
|
||||
# and always runs exactly one step — those values are uninformative, so drop them from the public surface.
|
||||
# `token_usage` is still meaningful and stays exposed.
|
||||
component.set_output_types(
|
||||
self, messages=list[ChatMessage], last_message=ChatMessage, token_usage=dict[str, Any]
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the LLM component to a dictionary.
|
||||
|
||||
:return: Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generator=component_to_dict(obj=self.chat_generator, name="chat_generator"),
|
||||
system_prompt=self.system_prompt,
|
||||
user_prompt=self.user_prompt,
|
||||
required_variables=self.required_variables,
|
||||
streaming_callback=serialize_callable(self.streaming_callback) if self.streaming_callback else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "LLM":
|
||||
"""
|
||||
Deserialize the LLM from a dictionary.
|
||||
|
||||
:param data: Dictionary to deserialize from.
|
||||
:return: Deserialized LLM instance.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
|
||||
deserialize_component_inplace(init_params, key="chat_generator")
|
||||
|
||||
if init_params.get("streaming_callback") is not None:
|
||||
init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def run( # type: ignore[override] # `messages` is in **kwargs to allow dynamic required/optional status
|
||||
self,
|
||||
*,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process messages and generate a response from the language model.
|
||||
|
||||
:param messages: Optional list of ChatMessage objects to prepend to the conversation. Whether this is
|
||||
required or optional depends on the `user_prompt` configuration: if `user_prompt` has no template
|
||||
variables, `messages` must be provided. Passed via `**kwargs`.
|
||||
:param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
|
||||
:param generation_kwargs: Additional keyword arguments for the underlying chat generator. These parameters
|
||||
will override the parameters passed during component initialization.
|
||||
:param kwargs: Additional keyword arguments. These are used to fill template variables in `user_prompt` or
|
||||
`system_prompt` (the keys must match template variable names).
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- "messages": List of all messages exchanged during the LLM's run.
|
||||
- "last_message": The last message exchanged during the LLM's run.
|
||||
- "token_usage": Token usage from the LLM call (e.g. prompt_tokens, completion_tokens). Empty if the
|
||||
chat generator did not return usage data.
|
||||
"""
|
||||
# `messages` is intentionally omitted from the signature so the framework can treat it as required
|
||||
# or optional depending on init configuration. See __init__ for details.
|
||||
messages = kwargs.pop("messages", None)
|
||||
result = super(LLM, self).run( # noqa: UP008
|
||||
messages=messages or [],
|
||||
streaming_callback=streaming_callback,
|
||||
generation_kwargs=generation_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
# Inherited Agent-internal bookkeeping that isn't useful at the LLM surface.
|
||||
result.pop("step_count", None)
|
||||
result.pop("tool_call_counts", None)
|
||||
return result
|
||||
|
||||
async def run_async( # type: ignore[override] # `messages` is in **kwargs to allow dynamic required/optional status
|
||||
self,
|
||||
*,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously process messages and generate a response from the language model.
|
||||
|
||||
:param messages: Optional list of ChatMessage objects to prepend to the conversation. Whether this is
|
||||
required or optional depends on the `user_prompt` configuration: if `user_prompt` has no template
|
||||
variables, `messages` must be provided. Passed via `**kwargs`.
|
||||
:param streaming_callback: An asynchronous callback that will be invoked when a response is streamed
|
||||
from the LLM.
|
||||
:param generation_kwargs: Additional keyword arguments for the underlying chat generator. These parameters
|
||||
will override the parameters passed during component initialization.
|
||||
:param kwargs: Additional keyword arguments. These are used to fill template variables in `user_prompt` or
|
||||
`system_prompt` (the keys must match template variable names).
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- "messages": List of all messages exchanged during the LLM's run.
|
||||
- "last_message": The last message exchanged during the LLM's run.
|
||||
- "token_usage": Token usage from the LLM call (e.g. prompt_tokens, completion_tokens). Empty if the
|
||||
chat generator did not return usage data.
|
||||
"""
|
||||
# `messages` is intentionally omitted from the signature so the framework can treat it as required
|
||||
# or optional depending on init configuration. See __init__ for details.
|
||||
messages = kwargs.pop("messages", None)
|
||||
result = await super(LLM, self).run_async( # noqa: UP008
|
||||
messages=messages or [],
|
||||
streaming_callback=streaming_callback,
|
||||
generation_kwargs=generation_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
# Inherited Agent-internal bookkeeping that isn't useful at the LLM surface.
|
||||
result.pop("step_count", None)
|
||||
result.pop("tool_call_counts", None)
|
||||
return result
|
||||
@@ -0,0 +1,374 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.generators.utils import _normalize_messages
|
||||
from haystack.dataclasses import (
|
||||
ChatMessage,
|
||||
ChatRole,
|
||||
ComponentInfo,
|
||||
FinishReason,
|
||||
StreamingCallbackT,
|
||||
StreamingChunk,
|
||||
select_streaming_callback,
|
||||
)
|
||||
from haystack.dataclasses.streaming_chunk import ToolCallDelta, _invoke_streaming_callback
|
||||
from haystack.tools import ToolsType
|
||||
from haystack.utils import deserialize_callable, serialize_callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A callable that derives a response from the input messages. It receives the (normalized) list of input
|
||||
# `ChatMessage` objects and returns either the text of the assistant reply or a full `ChatMessage`.
|
||||
ResponseFn = Callable[[list[ChatMessage]], str | ChatMessage]
|
||||
|
||||
|
||||
@component
|
||||
class MockChatGenerator:
|
||||
"""
|
||||
A Chat Generator that returns predefined responses without calling any API.
|
||||
|
||||
It is a drop-in replacement for real Chat Generators (such as `OpenAIChatGenerator`) in tests, smoke tests, and
|
||||
quick prototypes. It implements the same interface (`run`, `run_async`, streaming, serialization) but never
|
||||
contacts an external service, so it is fully deterministic and free to run.
|
||||
|
||||
The response is selected based on how the component is configured:
|
||||
|
||||
- **Fixed response**: pass a single string or `ChatMessage`. The same reply is returned on every call.
|
||||
Any `ChatMessage` passed as a response must have the `assistant` role.
|
||||
- **Cycling responses**: pass a list of strings and/or `ChatMessage` objects. Each call returns the next item,
|
||||
wrapping around to the start once the list is exhausted. This is useful to drive multi-step flows such as
|
||||
Agents, where the first call returns a tool call and a later call returns the final answer.
|
||||
- **Dynamic response**: pass a `response_fn` callable that receives the input messages and returns the reply.
|
||||
This is useful when the reply should depend on the input, for example to echo back part of the prompt.
|
||||
- **Echo (default)**: with no configuration, the component echoes back the text of the last message that has
|
||||
text content. This makes it usable out of the box for quick prototyping.
|
||||
|
||||
Pass `ChatMessage` objects (rather than plain strings) to return tool calls or reasoning content, which is handy
|
||||
for exercising tool-calling pipelines without a real model.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.generators.chat import MockChatGenerator
|
||||
from haystack.dataclasses import ChatMessage, ToolCall
|
||||
|
||||
# Fixed response
|
||||
generator = MockChatGenerator(responses="Hello, this is a mock response.")
|
||||
result = generator.run([ChatMessage.from_user("Hi!")])
|
||||
print(result["replies"][0].text) # "Hello, this is a mock response."
|
||||
|
||||
# Cycling responses to drive an Agent-like loop
|
||||
generator = MockChatGenerator(
|
||||
responses=[
|
||||
ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="search", arguments={"query": "Haystack"})]),
|
||||
"Here is the final answer.",
|
||||
]
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
responses: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
response_fn: ResponseFn | None = None,
|
||||
model: str = "mock-model",
|
||||
meta: dict[str, Any] | None = None,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of MockChatGenerator.
|
||||
|
||||
:param responses: The predefined response(s) to return. Accepts a single string or `ChatMessage` (returned on
|
||||
every call), or a non-empty list of strings and/or `ChatMessage` objects that are returned in order,
|
||||
cycling back to the start once exhausted. Strings are wrapped into assistant `ChatMessage` objects, and any
|
||||
`ChatMessage` passed must have the `assistant` role. Mutually exclusive with `response_fn`. If neither is
|
||||
provided, the component echoes the last message with text content.
|
||||
:param response_fn: An optional callable that receives the input messages and returns the reply as a string or
|
||||
an assistant `ChatMessage`. Use this for input-dependent responses. Mutually exclusive with `responses`. To
|
||||
support serialization, pass a named function (lambdas and nested functions cannot be serialized).
|
||||
:param model: The model name reported in the response metadata. Purely cosmetic; no model is loaded.
|
||||
:param meta: Additional metadata merged into the `meta` of every returned `ChatMessage`. A per-response
|
||||
`ChatMessage`'s own metadata takes precedence over this value.
|
||||
:param streaming_callback: An optional callback invoked with `StreamingChunk` objects reconstructed from the
|
||||
predefined response. It lets the mock exercise streaming code paths without a real model.
|
||||
:raises ValueError: If both `responses` and `response_fn` are provided, if `responses` is an empty list, or if
|
||||
a `ChatMessage` response does not have the `assistant` role.
|
||||
"""
|
||||
if responses is not None and response_fn is not None:
|
||||
raise ValueError("Pass either 'responses' or 'response_fn', not both.")
|
||||
|
||||
self._responses = self._normalize_responses(responses)
|
||||
self.response_fn = response_fn
|
||||
self.model = model
|
||||
self.meta = meta or {}
|
||||
self.streaming_callback = streaming_callback
|
||||
self._call_count = 0
|
||||
self._is_warmed_up = False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_responses(
|
||||
responses: str | ChatMessage | Sequence[str | ChatMessage] | None,
|
||||
) -> list[ChatMessage] | None:
|
||||
"""Normalize the `responses` argument into a non-empty list of `ChatMessage`, or `None` for echo mode."""
|
||||
if responses is None:
|
||||
return None
|
||||
|
||||
items: list[str | ChatMessage]
|
||||
if isinstance(responses, (str, ChatMessage)):
|
||||
items = [responses]
|
||||
elif isinstance(responses, Sequence):
|
||||
items = list(responses)
|
||||
else:
|
||||
raise TypeError(f"'responses' must be a string, ChatMessage, or a sequence of them, got {type(responses)}.")
|
||||
|
||||
if len(items) == 0:
|
||||
raise ValueError("'responses' must not be an empty list.")
|
||||
|
||||
normalized: list[ChatMessage] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
normalized.append(ChatMessage.from_assistant(item))
|
||||
elif isinstance(item, ChatMessage):
|
||||
if item.role != ChatRole.ASSISTANT:
|
||||
raise ValueError(
|
||||
f"Each ChatMessage response must have the 'assistant' role, got '{item.role.value}'."
|
||||
)
|
||||
normalized.append(item)
|
||||
else:
|
||||
raise TypeError(f"Each response must be a string or ChatMessage, got {type(item)}.")
|
||||
return normalized
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the component to a dictionary."""
|
||||
responses = [msg.to_dict() for msg in self._responses] if self._responses is not None else None
|
||||
response_fn = serialize_callable(self.response_fn) if self.response_fn is not None else None
|
||||
streaming_callback = serialize_callable(self.streaming_callback) if self.streaming_callback else None
|
||||
return default_to_dict(
|
||||
self,
|
||||
responses=responses,
|
||||
response_fn=response_fn,
|
||||
model=self.model,
|
||||
meta=self.meta,
|
||||
streaming_callback=streaming_callback,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> MockChatGenerator:
|
||||
"""Deserialize the component from a dictionary."""
|
||||
init_params = data.get("init_parameters", {})
|
||||
responses = init_params.get("responses")
|
||||
if responses is not None:
|
||||
init_params["responses"] = [ChatMessage.from_dict(msg) for msg in responses]
|
||||
response_fn = init_params.get("response_fn")
|
||||
if response_fn:
|
||||
init_params["response_fn"] = deserialize_callable(response_fn)
|
||||
streaming_callback = init_params.get("streaming_callback")
|
||||
if streaming_callback:
|
||||
init_params["streaming_callback"] = deserialize_callable(streaming_callback)
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""No-op warm up, provided for interface compatibility with real Chat Generators."""
|
||||
self._is_warmed_up = True
|
||||
|
||||
@staticmethod
|
||||
def _echo_text(messages: list[ChatMessage]) -> str | None:
|
||||
"""Return the text of the last message that has text content, for echo mode."""
|
||||
for message in reversed(messages):
|
||||
if message.text:
|
||||
return message.text
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_message(result: str | ChatMessage) -> ChatMessage:
|
||||
"""Turn the output of `response_fn` into a `ChatMessage`, wrapping strings and requiring the assistant role."""
|
||||
if isinstance(result, str):
|
||||
return ChatMessage.from_assistant(result)
|
||||
if isinstance(result, ChatMessage):
|
||||
if result.role != ChatRole.ASSISTANT:
|
||||
raise ValueError(f"'response_fn' must return an assistant ChatMessage, got '{result.role.value}'.")
|
||||
return result
|
||||
raise TypeError(f"'response_fn' must return a string or ChatMessage, got {type(result)}.")
|
||||
|
||||
@staticmethod
|
||||
def _estimate_usage(messages: list[ChatMessage], reply: ChatMessage) -> dict[str, int]:
|
||||
"""
|
||||
Roughly estimate token usage as whitespace-separated word counts.
|
||||
|
||||
This is an approximation (not real tokenization) intended to give downstream code realistic-looking metadata.
|
||||
"""
|
||||
prompt_tokens = sum(len((message.text or "").split()) for message in messages)
|
||||
completion_tokens = len((reply.text or "").split())
|
||||
return {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
|
||||
def _build_meta(self, messages: list[ChatMessage], base: ChatMessage) -> dict[str, Any]:
|
||||
"""Build the metadata attached to the returned reply, merging defaults, init meta, and per-response meta."""
|
||||
meta: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls" if base.tool_calls else "stop",
|
||||
"usage": self._estimate_usage(messages, base),
|
||||
}
|
||||
meta.update(self.meta)
|
||||
meta.update(base.meta)
|
||||
return meta
|
||||
|
||||
def _build_reply(self, messages: list[ChatMessage]) -> ChatMessage | None:
|
||||
"""Select and finalize the reply for the given input messages. Returns `None` when there is nothing to echo."""
|
||||
if self.response_fn is not None:
|
||||
base = self._coerce_to_message(self.response_fn(messages))
|
||||
elif self._responses is not None:
|
||||
base = self._responses[self._call_count % len(self._responses)]
|
||||
self._call_count += 1
|
||||
else:
|
||||
text = self._echo_text(messages)
|
||||
if text is None:
|
||||
return None
|
||||
base = ChatMessage.from_assistant(text)
|
||||
|
||||
return replace(base, _meta=self._build_meta(messages, base))
|
||||
|
||||
def _make_chunks(self, reply: ChatMessage) -> list[StreamingChunk]:
|
||||
"""Reconstruct streaming chunks from a finalized reply so streaming callbacks can be exercised."""
|
||||
component_info = ComponentInfo.from_component(self)
|
||||
chunks: list[StreamingChunk] = []
|
||||
|
||||
# Stream the text content word by word in content block 0.
|
||||
parts = re.findall(r"\S+\s*", reply.text) if reply.text else []
|
||||
for idx, part in enumerate(parts):
|
||||
chunks.append(
|
||||
StreamingChunk(
|
||||
content=part, component_info=component_info, index=0, start=(idx == 0), meta={"model": self.model}
|
||||
)
|
||||
)
|
||||
|
||||
# Stream each tool call in its own content block.
|
||||
block_index = 1 if parts else 0
|
||||
for tool_call in reply.tool_calls:
|
||||
chunks.append(
|
||||
StreamingChunk(
|
||||
content="",
|
||||
component_info=component_info,
|
||||
index=block_index,
|
||||
start=True,
|
||||
tool_calls=[
|
||||
ToolCallDelta(
|
||||
index=block_index,
|
||||
tool_name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.arguments),
|
||||
id=tool_call.id,
|
||||
)
|
||||
],
|
||||
meta={"model": self.model},
|
||||
)
|
||||
)
|
||||
block_index += 1
|
||||
|
||||
if not chunks:
|
||||
chunks.append(
|
||||
StreamingChunk(content="", component_info=component_info, index=0, meta={"model": self.model})
|
||||
)
|
||||
|
||||
finish_reason: FinishReason = "tool_calls" if reply.tool_calls else "stop"
|
||||
last = chunks[-1]
|
||||
chunks[-1] = replace(last, finish_reason=finish_reason, meta={**last.meta, "finish_reason": finish_reason})
|
||||
return chunks
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
def run(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None, # noqa: ARG002
|
||||
*,
|
||||
tools: ToolsType | None = None, # noqa: ARG002
|
||||
tools_strict: bool | None = None, # noqa: ARG002
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
"""
|
||||
Return a predefined reply for the given messages without calling any API.
|
||||
|
||||
The signature mirrors `OpenAIChatGenerator.run` so the mock can be used as a positional drop-in replacement.
|
||||
|
||||
:param messages: The conversation history as a list of `ChatMessage` instances or a single string.
|
||||
:param streaming_callback: An optional callback invoked with reconstructed `StreamingChunk` objects. Overrides
|
||||
the callback set at initialization.
|
||||
:param generation_kwargs: Accepted for interface compatibility and ignored.
|
||||
:param tools: Accepted for interface compatibility and ignored.
|
||||
:param tools_strict: Accepted for interface compatibility and ignored.
|
||||
:returns: A dictionary with a single key `replies` containing the predefined reply as a list of one
|
||||
`ChatMessage` (empty in echo mode when there is no message to echo).
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
streaming_callback = select_streaming_callback(
|
||||
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
|
||||
)
|
||||
|
||||
reply = self._build_reply(messages)
|
||||
if reply is None:
|
||||
return {"replies": []}
|
||||
|
||||
if streaming_callback is not None:
|
||||
for chunk in self._make_chunks(reply):
|
||||
streaming_callback(chunk)
|
||||
|
||||
return {"replies": [reply]}
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
async def run_async(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None, # noqa: ARG002
|
||||
*,
|
||||
tools: ToolsType | None = None, # noqa: ARG002
|
||||
tools_strict: bool | None = None, # noqa: ARG002
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
"""
|
||||
Asynchronously return a predefined reply for the given messages without calling any API.
|
||||
|
||||
The signature mirrors `OpenAIChatGenerator.run_async` so the mock can be used as a positional drop-in
|
||||
replacement.
|
||||
|
||||
:param messages: The conversation history as a list of `ChatMessage` instances or a single string.
|
||||
:param streaming_callback: An optional callback invoked with reconstructed `StreamingChunk` objects. Overrides
|
||||
the callback set at initialization.
|
||||
:param generation_kwargs: Accepted for interface compatibility and ignored.
|
||||
:param tools: Accepted for interface compatibility and ignored.
|
||||
:param tools_strict: Accepted for interface compatibility and ignored.
|
||||
:returns: A dictionary with a single key `replies` containing the predefined reply as a list of one
|
||||
`ChatMessage` (empty in echo mode when there is no message to echo).
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
streaming_callback = select_streaming_callback(
|
||||
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
|
||||
)
|
||||
|
||||
reply = self._build_reply(messages)
|
||||
if reply is None:
|
||||
return {"replies": []}
|
||||
|
||||
if streaming_callback is not None:
|
||||
for chunk in self._make_chunks(reply):
|
||||
await _invoke_streaming_callback(streaming_callback, chunk)
|
||||
|
||||
return {"replies": [reply]}
|
||||
@@ -0,0 +1,795 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
|
||||
from openai.lib._pydantic import to_strict_json_schema
|
||||
from openai.types.chat import (
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageCustomToolCall,
|
||||
ParsedChatCompletion,
|
||||
ParsedChatCompletionMessage,
|
||||
)
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from pydantic import BaseModel
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.generators.utils import (
|
||||
_convert_streaming_chunks_to_chat_message,
|
||||
_normalize_messages,
|
||||
_serialize_object,
|
||||
)
|
||||
from haystack.dataclasses import (
|
||||
ChatMessage,
|
||||
ComponentInfo,
|
||||
FinishReason,
|
||||
StreamingCallbackT,
|
||||
StreamingChunk,
|
||||
SyncStreamingCallbackT,
|
||||
ToolCall,
|
||||
ToolCallDelta,
|
||||
select_streaming_callback,
|
||||
)
|
||||
from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback
|
||||
from haystack.tools import (
|
||||
ToolsType,
|
||||
_check_duplicate_tool_names,
|
||||
deserialize_tools_or_toolset_inplace,
|
||||
flatten_tools_or_toolsets,
|
||||
serialize_tools_or_toolset,
|
||||
warm_up_tools,
|
||||
)
|
||||
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class OpenAIChatGenerator:
|
||||
"""
|
||||
Completes chats using OpenAI's large language models (LLMs).
|
||||
|
||||
It works with the gpt-4 and gpt-5 series models and supports streaming responses
|
||||
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
|
||||
format in input and output.
|
||||
|
||||
You can customize how the text is generated by passing parameters to the
|
||||
OpenAI API. Use the `**generation_kwargs` argument when you initialize
|
||||
the component or when you run it. Any parameter that works with
|
||||
`openai.ChatCompletion.create` will work here too.
|
||||
|
||||
For details on OpenAI API parameters, see
|
||||
[OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
|
||||
|
||||
client = OpenAIChatGenerator()
|
||||
response = client.run(messages)
|
||||
print(response)
|
||||
```
|
||||
Output:
|
||||
```
|
||||
{'replies':
|
||||
[ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=
|
||||
[TextContent(text="Natural Language Processing (NLP) is a branch of artificial intelligence
|
||||
that focuses on enabling computers to understand, interpret, and generate human language in
|
||||
a way that is meaningful and useful.")],
|
||||
_name=None,
|
||||
_meta={'model': 'gpt-5-mini', 'index': 0, 'finish_reason': 'stop',
|
||||
'usage': {'prompt_tokens': 15, 'completion_tokens': 36, 'total_tokens': 51}})
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
SUPPORTED_MODELS: ClassVar[list[str]] = [
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5",
|
||||
"gpt-5.1",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5-pro",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
]
|
||||
"""A non-exhaustive list of chat models supported by this component.
|
||||
See https://developers.openai.com/api/docs/models for the full list and snapshot IDs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
|
||||
model: str = "gpt-5-mini",
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
api_base_url: str | None = None,
|
||||
organization: str | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool = False,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of OpenAIChatGenerator. Unless specified otherwise in `model`, uses OpenAI's gpt-5-mini
|
||||
|
||||
Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
|
||||
environment variables to override the `timeout` and `max_retries` parameters respectively
|
||||
in the OpenAI client.
|
||||
|
||||
:param api_key: The OpenAI API key.
|
||||
You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
|
||||
during initialization.
|
||||
:param model: The name of the model to use.
|
||||
:param streaming_callback: A callback function that is called when a new token is received from the stream.
|
||||
The callback function accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
|
||||
as an argument.
|
||||
:param api_base_url: An optional base URL.
|
||||
:param organization: Your organization ID, defaults to `None`. See
|
||||
[production best practices](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
|
||||
:param generation_kwargs: Other parameters to use for the model. These parameters are sent directly to
|
||||
the OpenAI endpoint. See OpenAI [documentation](https://platform.openai.com/docs/api-reference/chat) for
|
||||
more details.
|
||||
Some of the supported parameters:
|
||||
- `max_completion_tokens`: An upper bound for the number of tokens that can be generated for a completion,
|
||||
including visible output tokens and reasoning tokens.
|
||||
- `temperature`: What sampling temperature to use. Higher values mean the model will take more risks.
|
||||
Try 0.9 for more creative applications and 0 (argmax sampling) for ones with a well-defined answer.
|
||||
- `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
|
||||
considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
|
||||
comprising the top 10% probability mass are considered.
|
||||
- `n`: How many completions to generate for each prompt. For example, if the LLM gets 3 prompts and n is 2,
|
||||
it will generate two completions for each of the three prompts, ending up with 6 completions in total.
|
||||
- `stop`: One or more sequences after which the LLM should stop generating tokens.
|
||||
- `presence_penalty`: What penalty to apply if a token is already present at all. Bigger values mean
|
||||
the model will be less likely to repeat the same token in the text.
|
||||
- `frequency_penalty`: What penalty to apply if a token has already been generated in the text.
|
||||
Bigger values mean the model will be less likely to repeat the same token in the text.
|
||||
- `logit_bias`: Add a logit bias to specific tokens. The keys of the dictionary are tokens, and the
|
||||
values are the bias to add to that token.
|
||||
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
|
||||
If provided, the output will always be validated against this
|
||||
format (unless the model returns a tool call).
|
||||
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
|
||||
Notes:
|
||||
- This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o.
|
||||
Older models only support basic version of structured outputs through `{"type": "json_object"}`.
|
||||
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
|
||||
- For structured outputs with streaming,
|
||||
the `response_format` must be a JSON schema and not a Pydantic model.
|
||||
:param timeout:
|
||||
Timeout for OpenAI client calls. If not set, it defaults to either the
|
||||
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
|
||||
:param max_retries:
|
||||
Maximum number of retries to contact OpenAI after an internal error.
|
||||
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
|
||||
:param tools:
|
||||
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
|
||||
:param tools_strict:
|
||||
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
|
||||
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.generation_kwargs = generation_kwargs or {}
|
||||
self.streaming_callback = streaming_callback
|
||||
self.api_base_url = api_base_url
|
||||
self.organization = organization
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.tools = tools # Store tools as-is, whether it's a list or a Toolset
|
||||
self.tools_strict = tools_strict
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
# Check for duplicate tool names
|
||||
_check_duplicate_tool_names(flatten_tools_or_toolsets(self.tools))
|
||||
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
self._tools_warmed_up = False
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_key": self.api_key.resolve_value(),
|
||||
"organization": self.organization,
|
||||
"base_url": self.api_base_url,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
}
|
||||
|
||||
def _warm_up_tools(self) -> None:
|
||||
if not self._tools_warmed_up:
|
||||
warm_up_tools(self.tools)
|
||||
self._tools_warmed_up = True
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the tools and initialize the synchronous OpenAI client.
|
||||
"""
|
||||
self._warm_up_tools()
|
||||
if self.client is None:
|
||||
self.client = OpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Warm up the tools and initialize the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
self._warm_up_tools()
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"model": self.model}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
|
||||
generation_kwargs = self.generation_kwargs.copy()
|
||||
response_format = generation_kwargs.get("response_format")
|
||||
|
||||
# If the response format is a Pydantic model, it's converted to openai's json schema format
|
||||
# If it's already a json schema, it's left as is
|
||||
if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
json_schema = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__,
|
||||
"strict": True,
|
||||
"schema": to_strict_json_schema(response_format),
|
||||
},
|
||||
}
|
||||
generation_kwargs["response_format"] = json_schema
|
||||
|
||||
return default_to_dict(
|
||||
self,
|
||||
model=self.model,
|
||||
streaming_callback=callback_name,
|
||||
api_base_url=self.api_base_url,
|
||||
organization=self.organization,
|
||||
generation_kwargs=generation_kwargs,
|
||||
api_key=self.api_key,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
tools=serialize_tools_or_toolset(self.tools),
|
||||
tools_strict=self.tools_strict,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OpenAIChatGenerator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data: The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
|
||||
init_params = data.get("init_parameters", {})
|
||||
serialized_callback_handler = init_params.get("streaming_callback")
|
||||
|
||||
if serialized_callback_handler:
|
||||
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
def run(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool | None = None,
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
"""
|
||||
Invokes chat completion based on the provided messages and generation parameters.
|
||||
|
||||
:param messages:
|
||||
A list of ChatMessage instances representing the input messages. If a string is provided, it is converted
|
||||
to a list containing a ChatMessage with user role.
|
||||
:param streaming_callback:
|
||||
A callback function that is called when a new token is received from the stream.
|
||||
:param generation_kwargs:
|
||||
Additional keyword arguments for text generation. These parameters will
|
||||
override the parameters passed during component initialization.
|
||||
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
|
||||
:param tools:
|
||||
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
|
||||
If set, it will override the `tools` parameter provided during initialization.
|
||||
:param tools_strict:
|
||||
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
|
||||
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
|
||||
If set, it will override the `tools_strict` parameter set during component initialization.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
if len(messages) == 0:
|
||||
return {"replies": []}
|
||||
|
||||
streaming_callback = select_streaming_callback(
|
||||
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
|
||||
)
|
||||
chat_completion: Stream[ChatCompletionChunk] | ChatCompletion | ParsedChatCompletion
|
||||
|
||||
api_args = self._prepare_api_call(
|
||||
messages=messages,
|
||||
streaming_callback=streaming_callback,
|
||||
generation_kwargs=generation_kwargs,
|
||||
tools=tools,
|
||||
tools_strict=tools_strict,
|
||||
)
|
||||
openai_endpoint = api_args.pop("openai_endpoint")
|
||||
assert self.client is not None # mypy: client is built by warm_up above
|
||||
openai_endpoint_method = getattr(self.client.chat.completions, openai_endpoint)
|
||||
chat_completion = openai_endpoint_method(**api_args)
|
||||
|
||||
if streaming_callback is not None:
|
||||
completions = self._handle_stream_response(
|
||||
# we cannot check isinstance(chat_completion, Stream) because some observability tools wrap Stream
|
||||
# and return a different type. See https://github.com/deepset-ai/haystack/issues/9014.
|
||||
chat_completion, # type: ignore
|
||||
streaming_callback,
|
||||
)
|
||||
|
||||
else:
|
||||
assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."
|
||||
completions = [
|
||||
_convert_chat_completion_to_chat_message(chat_completion, choice) for choice in chat_completion.choices
|
||||
]
|
||||
|
||||
# before returning, do post-processing of the completions
|
||||
for message in completions:
|
||||
_check_finish_reason(message.meta)
|
||||
|
||||
return {"replies": completions}
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
async def run_async(
|
||||
self,
|
||||
messages: list[ChatMessage] | str,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool | None = None,
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
"""
|
||||
Asynchronously invokes chat completion based on the provided messages and generation parameters.
|
||||
|
||||
This is the asynchronous version of the `run` method. It has the same parameters and return values
|
||||
but can be used with `await` in async code.
|
||||
|
||||
:param messages:
|
||||
A list of ChatMessage instances representing the input messages. If a string is provided, it is converted
|
||||
to a list containing a ChatMessage with user role.
|
||||
:param streaming_callback:
|
||||
A callback function that is called when a new token is received from the stream. Async callbacks are
|
||||
preferred; a sync callback is accepted but will run synchronously on the event loop and may block it.
|
||||
:param generation_kwargs:
|
||||
Additional keyword arguments for text generation. These parameters will
|
||||
override the parameters passed during component initialization.
|
||||
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
|
||||
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
|
||||
If set, it will override the `tools` parameter provided during initialization.
|
||||
:param tools_strict:
|
||||
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
|
||||
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
|
||||
If set, it will override the `tools_strict` parameter set during component initialization.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
# validate and select the streaming callback
|
||||
streaming_callback = select_streaming_callback(
|
||||
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
|
||||
)
|
||||
chat_completion: AsyncStream[ChatCompletionChunk] | ChatCompletion | ParsedChatCompletion
|
||||
|
||||
if len(messages) == 0:
|
||||
return {"replies": []}
|
||||
|
||||
api_args = self._prepare_api_call(
|
||||
messages=messages,
|
||||
streaming_callback=streaming_callback,
|
||||
generation_kwargs=generation_kwargs,
|
||||
tools=tools,
|
||||
tools_strict=tools_strict,
|
||||
)
|
||||
|
||||
openai_endpoint = api_args.pop("openai_endpoint")
|
||||
assert self.async_client is not None # mypy: async_client is built by warm_up_async above
|
||||
openai_endpoint_method = getattr(self.async_client.chat.completions, openai_endpoint)
|
||||
chat_completion = await openai_endpoint_method(**api_args)
|
||||
|
||||
if streaming_callback is not None:
|
||||
completions = await self._handle_async_stream_response(
|
||||
# we cannot check isinstance(chat_completion, AsyncStream) because some observability tools wrap
|
||||
# AsyncStream and return a different type. See https://github.com/deepset-ai/haystack/issues/9014.
|
||||
chat_completion, # type: ignore
|
||||
streaming_callback,
|
||||
)
|
||||
|
||||
else:
|
||||
assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."
|
||||
completions = [
|
||||
_convert_chat_completion_to_chat_message(chat_completion, choice) for choice in chat_completion.choices
|
||||
]
|
||||
|
||||
# before returning, do post-processing of the completions
|
||||
for message in completions:
|
||||
_check_finish_reason(message.meta)
|
||||
|
||||
return {"replies": completions}
|
||||
|
||||
def _prepare_api_call( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
messages: list[ChatMessage],
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
tools_strict: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# update generation kwargs by merging with the generation kwargs passed to the run method
|
||||
generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
|
||||
|
||||
is_streaming = streaming_callback is not None
|
||||
num_responses = generation_kwargs.pop("n", 1)
|
||||
|
||||
if is_streaming and num_responses > 1:
|
||||
raise ValueError("Cannot stream multiple responses, please set n=1.")
|
||||
response_format = generation_kwargs.pop("response_format", None)
|
||||
|
||||
# adapt ChatMessage(s) to the format expected by the OpenAI API
|
||||
openai_formatted_messages = [message.to_openai_dict_format() for message in messages]
|
||||
|
||||
flattened_tools = flatten_tools_or_toolsets(tools or self.tools)
|
||||
tools_strict = tools_strict if tools_strict is not None else self.tools_strict
|
||||
_check_duplicate_tool_names(flattened_tools)
|
||||
|
||||
openai_tools = {}
|
||||
if flattened_tools:
|
||||
tool_definitions = []
|
||||
for t in flattened_tools:
|
||||
function_spec = {**t.tool_spec}
|
||||
if tools_strict:
|
||||
function_spec["strict"] = True
|
||||
function_spec["parameters"] = _make_schema_strict(function_spec["parameters"])
|
||||
tool_definitions.append({"type": "function", "function": function_spec})
|
||||
openai_tools = {"tools": tool_definitions}
|
||||
|
||||
base_args = {
|
||||
"model": self.model,
|
||||
"messages": openai_formatted_messages,
|
||||
"n": num_responses,
|
||||
**openai_tools,
|
||||
**generation_kwargs,
|
||||
}
|
||||
|
||||
if response_format and not is_streaming:
|
||||
# for structured outputs without streaming, we use openai's parse endpoint
|
||||
# Note: `stream` cannot be passed to chat.completions.parse
|
||||
# we pass a key `openai_endpoint` as a hint to the run method to use the parse endpoint
|
||||
# this key will be removed before the API call is made
|
||||
return {**base_args, "response_format": response_format, "openai_endpoint": "parse"}
|
||||
|
||||
# for structured outputs with streaming, we use openai's create endpoint
|
||||
# we pass a key `openai_endpoint` as a hint to the run method to use the create endpoint
|
||||
# this key will be removed before the API call is made
|
||||
final_args = {**base_args, "stream": is_streaming, "openai_endpoint": "create"}
|
||||
|
||||
# We only set the response_format parameter if it's not None since None is not a valid value in the API.
|
||||
if response_format:
|
||||
final_args["response_format"] = response_format
|
||||
return final_args
|
||||
|
||||
def _handle_stream_response(self, chat_completion: Stream, callback: SyncStreamingCallbackT) -> list[ChatMessage]:
|
||||
component_info = ComponentInfo.from_component(self)
|
||||
chunks: list[StreamingChunk] = []
|
||||
for chunk in chat_completion:
|
||||
assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
|
||||
chunk_delta = _convert_chat_completion_chunk_to_streaming_chunk(
|
||||
chunk=chunk, previous_chunks=chunks, component_info=component_info
|
||||
)
|
||||
chunks.append(chunk_delta)
|
||||
callback(chunk_delta)
|
||||
return [_convert_streaming_chunks_to_chat_message(chunks=chunks)]
|
||||
|
||||
async def _handle_async_stream_response(
|
||||
self, chat_completion: AsyncStream, callback: StreamingCallbackT
|
||||
) -> list[ChatMessage]:
|
||||
component_info = ComponentInfo.from_component(self)
|
||||
chunks: list[StreamingChunk] = []
|
||||
try:
|
||||
async for chunk in chat_completion:
|
||||
assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
|
||||
chunk_delta = _convert_chat_completion_chunk_to_streaming_chunk(
|
||||
chunk=chunk, previous_chunks=chunks, component_info=component_info
|
||||
)
|
||||
chunks.append(chunk_delta)
|
||||
await _invoke_streaming_callback(callback, chunk_delta)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.shield(chat_completion.close())
|
||||
# close the stream when task is cancelled
|
||||
# asyncio.shield ensures the close operation completes
|
||||
# https://docs.python.org/3/library/asyncio-task.html#shielding-from-cancellation
|
||||
raise # Re-raise to propagate cancellation
|
||||
|
||||
return [_convert_streaming_chunks_to_chat_message(chunks=chunks)]
|
||||
|
||||
|
||||
def _make_schema_strict(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Recursively transform a JSON schema to be OpenAI strict-mode compliant.
|
||||
|
||||
Sets `additionalProperties: false` on all objects and ensures every defined
|
||||
property is listed in `required`. Walks into nested properties, `$defs`,
|
||||
array `items`, and `anyOf`/`oneOf`/`allOf` combinators.
|
||||
|
||||
See https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
|
||||
"""
|
||||
schema = {**schema}
|
||||
|
||||
schema_type = schema.get("type")
|
||||
|
||||
if schema_type == "object" or "properties" in schema:
|
||||
schema["additionalProperties"] = False
|
||||
if "properties" in schema:
|
||||
schema["required"] = list(schema["properties"].keys())
|
||||
schema["properties"] = {k: _make_schema_strict(v) for k, v in schema["properties"].items()}
|
||||
|
||||
if "items" in schema:
|
||||
schema["items"] = _make_schema_strict(schema["items"])
|
||||
|
||||
if "$defs" in schema:
|
||||
schema["$defs"] = {k: _make_schema_strict(v) for k, v in schema["$defs"].items()}
|
||||
|
||||
for combinator in ("anyOf", "oneOf", "allOf"):
|
||||
if combinator in schema:
|
||||
schema[combinator] = [_make_schema_strict(s) for s in schema[combinator]]
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def _check_finish_reason(meta: dict[str, Any]) -> None:
|
||||
if meta["finish_reason"] == "length":
|
||||
logger.warning(
|
||||
"The completion for index {index} has been truncated before reaching a natural stopping point. "
|
||||
"Increase the max_completion_tokens parameter to allow for longer completions.",
|
||||
index=meta["index"],
|
||||
finish_reason=meta["finish_reason"],
|
||||
)
|
||||
if meta["finish_reason"] == "content_filter":
|
||||
logger.warning(
|
||||
"The completion for index {index} has been truncated due to the content filter.",
|
||||
index=meta["index"],
|
||||
finish_reason=meta["finish_reason"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_chat_completion_to_chat_message(
|
||||
completion: ChatCompletion | ParsedChatCompletion, choice: Choice
|
||||
) -> ChatMessage:
|
||||
"""
|
||||
Converts the non-streaming response from the OpenAI API to a ChatMessage.
|
||||
|
||||
:param completion: The completion returned by the OpenAI API.
|
||||
:param choice: The choice returned by the OpenAI API.
|
||||
:return: The ChatMessage.
|
||||
"""
|
||||
message: ChatCompletionMessage | ParsedChatCompletionMessage = choice.message
|
||||
text = message.content
|
||||
tool_calls = []
|
||||
if message.tool_calls:
|
||||
# we currently only support function tools (not custom tools)
|
||||
# https://platform.openai.com/docs/guides/function-calling#custom-tools
|
||||
openai_tool_calls = [tc for tc in message.tool_calls if not isinstance(tc, ChatCompletionMessageCustomToolCall)]
|
||||
for openai_tc in openai_tool_calls:
|
||||
arguments_str = openai_tc.function.arguments
|
||||
try:
|
||||
arguments = json.loads(arguments_str)
|
||||
tool_calls.append(ToolCall(id=openai_tc.id, tool_name=openai_tc.function.name, arguments=arguments))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"OpenAI returned a malformed JSON string for tool call arguments. This tool call "
|
||||
"will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
|
||||
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
|
||||
_id=openai_tc.id,
|
||||
_name=openai_tc.function.name,
|
||||
_arguments=arguments_str,
|
||||
)
|
||||
|
||||
logprobs = _serialize_object(choice.logprobs) if choice.logprobs else None
|
||||
meta = {
|
||||
"model": completion.model,
|
||||
"index": choice.index,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"usage": _serialize_object(completion.usage),
|
||||
}
|
||||
if logprobs:
|
||||
meta["logprobs"] = logprobs
|
||||
|
||||
return ChatMessage.from_assistant(text=text, tool_calls=tool_calls, meta=meta)
|
||||
|
||||
|
||||
def _convert_chat_completion_chunk_to_streaming_chunk(
|
||||
chunk: ChatCompletionChunk, previous_chunks: list[StreamingChunk], component_info: ComponentInfo | None = None
|
||||
) -> StreamingChunk:
|
||||
"""
|
||||
Converts the streaming response chunk from the OpenAI API to a StreamingChunk.
|
||||
|
||||
:param chunk: The chunk returned by the OpenAI API.
|
||||
:param previous_chunks: A list of previously received StreamingChunks.
|
||||
:param component_info: An optional `ComponentInfo` object containing information about the component that
|
||||
generated the chunk, such as the component name and type.
|
||||
|
||||
:returns:
|
||||
A StreamingChunk object representing the content of the chunk from the OpenAI API.
|
||||
"""
|
||||
finish_reason_mapping: dict[str, FinishReason] = {
|
||||
"stop": "stop",
|
||||
"length": "length",
|
||||
"content_filter": "content_filter",
|
||||
"tool_calls": "tool_calls",
|
||||
"function_call": "tool_calls",
|
||||
}
|
||||
# On very first chunk so len(previous_chunks) == 0, the Choices field only provides role info (e.g. "assistant")
|
||||
# Choices is empty if include_usage is set to True where the usage information is returned.
|
||||
if len(chunk.choices) == 0:
|
||||
return StreamingChunk(
|
||||
content="",
|
||||
component_info=component_info,
|
||||
# Index is None since it's only set to an int when a content block is present
|
||||
index=None,
|
||||
finish_reason=None,
|
||||
meta={
|
||||
"model": chunk.model,
|
||||
"received_at": datetime.now().isoformat(),
|
||||
"usage": _serialize_object(chunk.usage),
|
||||
},
|
||||
)
|
||||
|
||||
choice: ChunkChoice = chunk.choices[0]
|
||||
|
||||
# create a list of ToolCallDelta objects from the tool calls
|
||||
if choice.delta and choice.delta.tool_calls:
|
||||
tool_calls_deltas = []
|
||||
for tool_call in choice.delta.tool_calls:
|
||||
function = tool_call.function
|
||||
tool_calls_deltas.append(
|
||||
ToolCallDelta(
|
||||
index=tool_call.index,
|
||||
id=tool_call.id,
|
||||
tool_name=function.name if function else None,
|
||||
arguments=function.arguments if function and function.arguments else None,
|
||||
)
|
||||
)
|
||||
return StreamingChunk(
|
||||
content=choice.delta.content or "",
|
||||
component_info=component_info,
|
||||
# We adopt the first tool_calls_deltas.index as the overall index of the chunk.
|
||||
index=tool_calls_deltas[0].index,
|
||||
tool_calls=tool_calls_deltas,
|
||||
start=tool_calls_deltas[0].tool_name is not None,
|
||||
finish_reason=finish_reason_mapping.get(choice.finish_reason) if choice.finish_reason else None,
|
||||
meta={
|
||||
"model": chunk.model,
|
||||
"index": choice.index,
|
||||
"tool_calls": choice.delta.tool_calls,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"received_at": datetime.now().isoformat(),
|
||||
"usage": _serialize_object(chunk.usage),
|
||||
},
|
||||
)
|
||||
|
||||
# On very first chunk the choice field only provides role info (e.g. "assistant") so we set index to None
|
||||
# We set all chunks missing the content field to index of None. E.g. can happen if chunk only contains finish
|
||||
# reason.
|
||||
if choice.delta and (choice.delta.content is None or choice.delta.role is not None):
|
||||
resolved_index = None
|
||||
else:
|
||||
# We set the index to be 0 since if text content is being streamed then no tool calls are being streamed
|
||||
# NOTE: We may need to revisit this if OpenAI allows planning/thinking content before tool calls like
|
||||
# Anthropic Claude
|
||||
resolved_index = 0
|
||||
|
||||
# Initialize meta dictionary
|
||||
meta = {
|
||||
"model": chunk.model,
|
||||
"index": choice.index,
|
||||
"tool_calls": choice.delta.tool_calls if choice.delta and choice.delta.tool_calls else None,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"received_at": datetime.now().isoformat(),
|
||||
"usage": _serialize_object(chunk.usage),
|
||||
}
|
||||
|
||||
# check if logprobs are present
|
||||
# logprobs are returned only for text content
|
||||
logprobs = _serialize_object(choice.logprobs) if choice.logprobs else None
|
||||
if logprobs:
|
||||
meta["logprobs"] = logprobs
|
||||
|
||||
content = ""
|
||||
if choice.delta and choice.delta.content:
|
||||
content = choice.delta.content
|
||||
|
||||
return StreamingChunk(
|
||||
content=content,
|
||||
component_info=component_info,
|
||||
index=resolved_index,
|
||||
# The first chunk is always a start message chunk that only contains role information, so if we reach here
|
||||
# and previous_chunks is length 1 then this is the start of text content.
|
||||
start=len(previous_chunks) == 1,
|
||||
finish_reason=finish_reason_mapping.get(choice.finish_reason) if choice.finish_reason else None,
|
||||
meta=meta,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .protocol import ChatGenerator
|
||||
|
||||
__all__ = ["ChatGenerator"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
|
||||
class ChatGenerator(Protocol):
|
||||
"""
|
||||
Protocol for Chat Generators.
|
||||
|
||||
This protocol defines the minimal interface that Chat Generators must implement.
|
||||
Chat Generators are components that process a list of `ChatMessage` objects as input and generate
|
||||
responses using a Language Model. They return a dictionary.
|
||||
"""
|
||||
|
||||
def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
|
||||
"""
|
||||
Generate messages using the underlying Language Model.
|
||||
|
||||
Implementing classes may accept additional optional parameters in their run method.
|
||||
For example: `def run (self, messages: list[ChatMessage], param_a="default", param_b="another_default")`.
|
||||
|
||||
:param messages:
|
||||
A list of ChatMessage instances representing the input messages.
|
||||
:returns:
|
||||
A dictionary.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,246 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
from openai.types.image import Image
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.utils import Secret
|
||||
from haystack.utils.http_client import init_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class OpenAIImageGenerator:
|
||||
"""
|
||||
Generates images using OpenAI's image generation models such as `gpt-image-2`.
|
||||
|
||||
For details on OpenAI API parameters, see
|
||||
[OpenAI documentation](https://developers.openai.com/api/reference/resources/images/methods/generate).
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.generators import OpenAIImageGenerator
|
||||
image_generator = OpenAIImageGenerator()
|
||||
response = image_generator.run("Show me a picture of a black cat.")
|
||||
print(response)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "gpt-image-2",
|
||||
quality: Literal["auto", "high", "medium", "low"] = "auto",
|
||||
size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] = "1024x1024",
|
||||
response_format: Literal["b64_json"] = "b64_json",
|
||||
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
|
||||
api_base_url: str | None = None,
|
||||
organization: str | None = None,
|
||||
timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
http_client_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Creates an instance of OpenAIImageGenerator. Unless specified otherwise in `model`, uses OpenAI's gpt-image-2.
|
||||
|
||||
:param model: The model to use for image generation. Model names can be found in the
|
||||
[OpenAI documentation](https://developers.openai.com/api/docs/models/all).
|
||||
:param quality: The quality of the generated image. Can be "auto", "high", "medium", or "low".
|
||||
:param size: The size of the generated images. One of 1024x1024, 1024x1536, 1536x1024, or "auto".
|
||||
`gpt-image-2` also supports arbitrary sizes. You can find more information about supported sizes in
|
||||
the [OpenAI documentation](https://developers.openai.com/api/reference/resources/images/methods/generate).
|
||||
:param response_format: This parameter is ignored and only kept for backward compatibility.
|
||||
:param api_key: The OpenAI API key to connect to OpenAI.
|
||||
:param api_base_url: An optional base URL.
|
||||
:param organization: The Organization ID, defaults to `None`.
|
||||
:param timeout:
|
||||
Timeout for OpenAI Client calls. If not set, it is inferred from the `OPENAI_TIMEOUT` environment variable
|
||||
or set to 30.
|
||||
:param max_retries:
|
||||
Maximum retries to establish contact with OpenAI if it returns an internal error. If not set, it is inferred
|
||||
from the `OPENAI_MAX_RETRIES` environment variable or set to 5.
|
||||
:param http_client_kwargs:
|
||||
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
|
||||
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
|
||||
"""
|
||||
self.model = model
|
||||
if quality not in ["auto", "high", "medium", "low"]:
|
||||
logger.warning("Invalid quality: {quality}. Defaulting to 'auto'.", quality=quality)
|
||||
quality = "auto"
|
||||
self.quality = quality
|
||||
self.size = size
|
||||
if response_format != "b64_json":
|
||||
logger.warning("response_format is ignored. A base64-encoded image will be returned.")
|
||||
self.api_key = api_key
|
||||
self.api_base_url = api_base_url
|
||||
self.organization = organization
|
||||
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
def _client_kwargs(self) -> dict[str, Any]:
|
||||
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
max_retries = (
|
||||
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
)
|
||||
return {
|
||||
"api_key": self.api_key.resolve_value(),
|
||||
"organization": self.organization,
|
||||
"base_url": self.api_base_url,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = OpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous OpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
@component.output_types(images=list[str], revised_prompt=str)
|
||||
def run(
|
||||
self,
|
||||
prompt: str,
|
||||
size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None,
|
||||
quality: Literal["auto", "high", "medium", "low"] | None = None,
|
||||
response_format: Literal["b64_json"] | None = None, # noqa: ARG002
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Invokes the image generation inference based on the provided prompt and generation parameters.
|
||||
|
||||
:param prompt: The prompt to generate the image.
|
||||
:param size: If provided, overrides the size provided during initialization.
|
||||
:param quality: If provided, overrides the quality provided during initialization.
|
||||
:param response_format: This parameter is ignored and only kept for backward compatibility.
|
||||
|
||||
:returns:
|
||||
A dictionary containing the generated list of images as base64 encoded JSON strings and the revised prompt.
|
||||
The revised prompt is the prompt that was used to generate the image, if there was any revision
|
||||
to the prompt made by OpenAI.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
# at this point the client is initialized, but mypy doesn't know that
|
||||
assert self.client is not None
|
||||
|
||||
size = size or self.size
|
||||
quality = quality or self.quality
|
||||
response = self.client.images.generate(model=self.model, prompt=prompt, size=size, quality=quality, n=1)
|
||||
image_str = ""
|
||||
revised_prompt = ""
|
||||
if response.data is not None:
|
||||
image: Image = response.data[0]
|
||||
image_str = image.b64_json or ""
|
||||
revised_prompt = image.revised_prompt or ""
|
||||
|
||||
return {"images": [image_str], "revised_prompt": revised_prompt}
|
||||
|
||||
@component.output_types(images=list[str], revised_prompt=str)
|
||||
async def run_async(
|
||||
self,
|
||||
prompt: str,
|
||||
size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None,
|
||||
quality: Literal["auto", "high", "medium", "low"] | None = None,
|
||||
response_format: Literal["b64_json"] | None = None, # noqa: ARG002
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously invokes the image generation inference based on the provided prompt and generation parameters.
|
||||
|
||||
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.
|
||||
|
||||
:param prompt: The prompt to generate the image.
|
||||
:param size: If provided, overrides the size provided during initialization.
|
||||
:param quality: If provided, overrides the quality provided during initialization.
|
||||
:param response_format: This parameter is ignored and only kept for backward compatibility.
|
||||
|
||||
:returns:
|
||||
A dictionary containing the generated list of images as base64 encoded JSON strings and the revised prompt.
|
||||
The revised prompt is the prompt that was used to generate the image, if there was any revision
|
||||
to the prompt made by OpenAI.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
# at this point the client is initialized, but mypy doesn't know that
|
||||
assert self.async_client is not None
|
||||
|
||||
size = size or self.size
|
||||
quality = quality or self.quality
|
||||
response = await self.async_client.images.generate(
|
||||
model=self.model, prompt=prompt, size=size, quality=quality, n=1
|
||||
)
|
||||
image_str = ""
|
||||
revised_prompt = ""
|
||||
if response.data is not None:
|
||||
image: Image = response.data[0]
|
||||
image_str = image.b64_json or ""
|
||||
revised_prompt = image.revised_prompt or ""
|
||||
|
||||
return {"images": [image_str], "revised_prompt": revised_prompt}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
model=self.model,
|
||||
quality=self.quality,
|
||||
size=self.size,
|
||||
api_key=self.api_key,
|
||||
api_base_url=self.api_base_url,
|
||||
organization=self.organization,
|
||||
http_client_kwargs=self.http_client_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OpenAIImageGenerator":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of this component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,187 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from haystack import logging
|
||||
from haystack.dataclasses import ChatMessage, ReasoningContent, StreamingChunk, ToolCall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def print_streaming_chunk(chunk: StreamingChunk) -> None:
|
||||
"""
|
||||
Callback function to handle and display streaming output chunks.
|
||||
|
||||
This function processes a `StreamingChunk` object by:
|
||||
- Printing tool call metadata (if any), including function names and arguments, as they arrive.
|
||||
- Printing tool call results when available.
|
||||
- Printing the main content (e.g., text tokens) of the chunk as it is received.
|
||||
|
||||
The function outputs data directly to stdout and flushes output buffers to ensure immediate display during
|
||||
streaming.
|
||||
|
||||
:param chunk: A chunk of streaming data containing content and optional metadata, such as tool calls and
|
||||
tool results.
|
||||
"""
|
||||
if chunk.start and chunk.index and chunk.index > 0:
|
||||
# If this is the start of a new content block but not the first content block, print two new lines
|
||||
print("\n\n", flush=True, end="")
|
||||
|
||||
## Tool Call streaming
|
||||
if chunk.tool_calls:
|
||||
# Typically, if there are multiple tool calls in the chunk this means that the tool calls are fully formed and
|
||||
# not just a delta.
|
||||
for tool_call in chunk.tool_calls:
|
||||
# If chunk.start is True indicates beginning of a tool call
|
||||
# Also presence of tool_call.tool_name indicates the start of a tool call too
|
||||
if chunk.start:
|
||||
# If there is more than one tool call in the chunk, we print two new lines to separate them
|
||||
# We know there is more than one tool call if the index of the tool call is greater than the index of
|
||||
# the chunk.
|
||||
if chunk.index and tool_call.index > chunk.index:
|
||||
print("\n\n", flush=True, end="")
|
||||
|
||||
print(f"[TOOL CALL]\nTool: {tool_call.tool_name} \nArguments: ", flush=True, end="")
|
||||
|
||||
# print the tool arguments
|
||||
if tool_call.arguments:
|
||||
print(tool_call.arguments, flush=True, end="")
|
||||
|
||||
## Tool Call Result streaming
|
||||
# Print tool call results if available.
|
||||
if chunk.tool_call_result:
|
||||
# Tool Call Result is fully formed so delta accumulation is not needed
|
||||
print(f"[TOOL RESULT]\n{chunk.tool_call_result.result}", flush=True, end="")
|
||||
|
||||
## Normal content streaming
|
||||
# Print the main content of the chunk (from ChatGenerator)
|
||||
if chunk.content:
|
||||
if chunk.start:
|
||||
print("[ASSISTANT]\n", flush=True, end="")
|
||||
print(chunk.content, flush=True, end="")
|
||||
|
||||
## Reasoning content streaming
|
||||
# Print the reasoning content of the chunk (from ChatGenerator)
|
||||
if chunk.reasoning:
|
||||
if chunk.start:
|
||||
print("[REASONING]\n", flush=True, end="")
|
||||
print(chunk.reasoning.reasoning_text, flush=True, end="")
|
||||
|
||||
# End of LLM assistant message so we add two new lines
|
||||
# This ensures spacing between multiple LLM messages (e.g. Agent) or multiple Tool Call Results
|
||||
if chunk.finish_reason is not None:
|
||||
print("\n\n", flush=True, end="")
|
||||
|
||||
|
||||
def _convert_streaming_chunks_to_chat_message(chunks: list[StreamingChunk]) -> ChatMessage:
|
||||
"""
|
||||
Connects the streaming chunks into a single ChatMessage.
|
||||
|
||||
:param chunks: The list of all `StreamingChunk` objects.
|
||||
|
||||
:returns: The ChatMessage.
|
||||
"""
|
||||
text = "".join([chunk.content for chunk in chunks])
|
||||
logprobs = []
|
||||
for chunk in chunks:
|
||||
if chunk.meta.get("logprobs"):
|
||||
logprobs.append(chunk.meta.get("logprobs"))
|
||||
tool_calls = []
|
||||
|
||||
# Accumulate reasoning content from chunks
|
||||
reasoning_parts = [chunk.reasoning.reasoning_text for chunk in chunks if chunk.reasoning]
|
||||
reasoning = ReasoningContent(reasoning_text="".join(reasoning_parts)) if reasoning_parts else None
|
||||
|
||||
# Process tool calls if present in any chunk
|
||||
tool_call_data: dict[int, dict[str, str]] = {} # Track tool calls by index
|
||||
for chunk in chunks:
|
||||
if chunk.tool_calls:
|
||||
for tool_call in chunk.tool_calls:
|
||||
# We use the index of the tool_call to track the tool call across chunks since the ID is not always
|
||||
# provided
|
||||
if tool_call.index not in tool_call_data:
|
||||
tool_call_data[tool_call.index] = {"id": "", "name": "", "arguments": ""}
|
||||
|
||||
# Save the ID if present
|
||||
if tool_call.id is not None:
|
||||
tool_call_data[tool_call.index]["id"] = tool_call.id
|
||||
|
||||
if tool_call.tool_name is not None:
|
||||
tool_call_data[tool_call.index]["name"] += tool_call.tool_name
|
||||
if tool_call.arguments is not None:
|
||||
tool_call_data[tool_call.index]["arguments"] += tool_call.arguments
|
||||
|
||||
# Convert accumulated tool call data into ToolCall objects
|
||||
sorted_keys = sorted(tool_call_data.keys())
|
||||
for key in sorted_keys:
|
||||
tool_call_dict = tool_call_data[key]
|
||||
try:
|
||||
arguments = json.loads(tool_call_dict.get("arguments", "{}")) if tool_call_dict.get("arguments") else {}
|
||||
tool_calls.append(ToolCall(id=tool_call_dict["id"], tool_name=tool_call_dict["name"], arguments=arguments))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"The LLM provider returned a malformed JSON string for tool call arguments. This tool call "
|
||||
"will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
|
||||
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
|
||||
_id=tool_call_dict["id"],
|
||||
_name=tool_call_dict["name"],
|
||||
_arguments=tool_call_dict["arguments"],
|
||||
)
|
||||
|
||||
# finish_reason can appear in different places so we look for the last one
|
||||
finish_reasons = [chunk.finish_reason for chunk in chunks if chunk.finish_reason]
|
||||
finish_reason = finish_reasons[-1] if finish_reasons else None
|
||||
|
||||
# usage info can appear in different chunks depending on the API provider
|
||||
# (e.g., OpenAI returns it in the last chunk with empty choices, but Qwen3 may return it differently)
|
||||
# so we look for the last non-None usage value across all chunks
|
||||
usage = None
|
||||
for chunk in reversed(chunks):
|
||||
chunk_usage = chunk.meta.get("usage")
|
||||
if chunk_usage is not None:
|
||||
usage = chunk_usage
|
||||
break
|
||||
|
||||
meta = {
|
||||
"model": chunks[-1].meta.get("model"),
|
||||
"index": 0,
|
||||
"finish_reason": finish_reason,
|
||||
"completion_start_time": chunks[0].meta.get("received_at"), # first chunk received
|
||||
"usage": usage,
|
||||
}
|
||||
|
||||
if logprobs:
|
||||
meta["logprobs"] = logprobs
|
||||
|
||||
return ChatMessage.from_assistant(text=text or None, tool_calls=tool_calls, reasoning=reasoning, meta=meta)
|
||||
|
||||
|
||||
def _serialize_object(obj: Any) -> Any:
|
||||
"""
|
||||
Convert an object to a serializable dict recursively.
|
||||
|
||||
Used to serialize `logprobs` and `usage` from OpenAI SDK response objects, so it skips any
|
||||
attribute starting with "_" (SDK-internal fields). `base_serialization._serialize_value_with_schema`
|
||||
doesn't skip those, so don't swap this out for it.
|
||||
"""
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump()
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {k: _serialize_object(v) for k, v in obj.__dict__.items() if not k.startswith("_")}
|
||||
if isinstance(obj, dict):
|
||||
return {k: _serialize_object(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_serialize_object(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _normalize_messages(messages: list[ChatMessage] | str) -> list[ChatMessage]:
|
||||
"""Normalize messages to a list of ChatMessage objects."""
|
||||
if isinstance(messages, str):
|
||||
return [ChatMessage.from_user(messages)]
|
||||
if isinstance(messages, list) and all(isinstance(msg, ChatMessage) for msg in messages):
|
||||
return messages
|
||||
raise TypeError("Invalid messages type. Expected list[ChatMessage] or str.")
|
||||
@@ -0,0 +1,26 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"answer_joiner": ["AnswerJoiner"],
|
||||
"branch": ["BranchJoiner"],
|
||||
"document_joiner": ["DocumentJoiner"],
|
||||
"list_joiner": ["ListJoiner"],
|
||||
"string_joiner": ["StringJoiner"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .answer_joiner import AnswerJoiner as AnswerJoiner
|
||||
from .branch import BranchJoiner as BranchJoiner
|
||||
from .document_joiner import DocumentJoiner as DocumentJoiner
|
||||
from .list_joiner import ListJoiner as ListJoiner
|
||||
from .string_joiner import StringJoiner as StringJoiner
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from math import inf
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.dataclasses.answer import ExtractedAnswer, GeneratedAnswer
|
||||
|
||||
AnswerType = GeneratedAnswer | ExtractedAnswer
|
||||
|
||||
|
||||
class JoinMode(Enum):
|
||||
"""
|
||||
Enum for AnswerJoiner join modes.
|
||||
"""
|
||||
|
||||
CONCATENATE = "concatenate"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "JoinMode":
|
||||
"""
|
||||
Convert a string to a JoinMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in JoinMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown join mode '{string}'. Supported modes in AnswerJoiner are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class AnswerJoiner:
|
||||
"""
|
||||
Merges multiple lists of `Answer` objects into a single list.
|
||||
|
||||
Use this component to combine answers from different Generators into a single list.
|
||||
Currently, the component supports only one join mode: `CONCATENATE`.
|
||||
This mode concatenates multiple lists of answers into a single list.
|
||||
|
||||
### Usage example
|
||||
|
||||
In this example, AnswerJoiner merges answers from two different Generators:
|
||||
|
||||
```python
|
||||
from haystack.components.builders import AnswerBuilder
|
||||
from haystack.components.joiners import AnswerJoiner
|
||||
|
||||
from haystack.core.pipeline import Pipeline
|
||||
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
|
||||
query = "What's Natural Language Processing?"
|
||||
messages = [ChatMessage.from_system("You are a helpful, respectful and honest assistant. Be super concise."),
|
||||
ChatMessage.from_user(query)]
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("llm_1", OpenAIChatGenerator())
|
||||
pipe.add_component("llm_2", OpenAIChatGenerator())
|
||||
pipe.add_component("aba", AnswerBuilder())
|
||||
pipe.add_component("abb", AnswerBuilder())
|
||||
pipe.add_component("joiner", AnswerJoiner())
|
||||
|
||||
pipe.connect("llm_1.replies", "aba")
|
||||
pipe.connect("llm_2.replies", "abb")
|
||||
pipe.connect("aba.answers", "joiner")
|
||||
pipe.connect("abb.answers", "joiner")
|
||||
|
||||
results = pipe.run(data={"llm_1": {"messages": messages},
|
||||
"llm_2": {"messages": messages},
|
||||
"aba": {"query": query},
|
||||
"abb": {"query": query}})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, join_mode: str | JoinMode = JoinMode.CONCATENATE, top_k: int | None = None, sort_by_score: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Creates an AnswerJoiner component.
|
||||
|
||||
:param join_mode:
|
||||
Specifies the join mode to use. Available modes:
|
||||
- `concatenate`: Concatenates multiple lists of Answers into a single list.
|
||||
:param top_k:
|
||||
The maximum number of Answers to return.
|
||||
:param sort_by_score:
|
||||
If `True`, sorts the documents by score in descending order.
|
||||
If a document has no score, it is handled as if its score is -infinity.
|
||||
"""
|
||||
if isinstance(join_mode, str):
|
||||
join_mode = JoinMode.from_str(join_mode)
|
||||
join_mode_functions: dict[JoinMode, Callable[[list[list[AnswerType]]], list[AnswerType]]] = {
|
||||
JoinMode.CONCATENATE: self._concatenate
|
||||
}
|
||||
self.join_mode_function: Callable[[list[list[AnswerType]]], list[AnswerType]] = join_mode_functions[join_mode]
|
||||
self.join_mode = join_mode
|
||||
self.top_k = top_k
|
||||
self.sort_by_score = sort_by_score
|
||||
|
||||
@component.output_types(answers=list[AnswerType])
|
||||
def run(self, answers: Variadic[list[AnswerType]], top_k: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Joins multiple lists of Answers into a single list depending on the `join_mode` parameter.
|
||||
|
||||
:param answers:
|
||||
Nested list of Answers to be merged.
|
||||
|
||||
:param top_k:
|
||||
The maximum number of Answers to return. Overrides the instance's `top_k` if provided.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `answers`: Merged list of Answers
|
||||
"""
|
||||
answers_list = list(answers)
|
||||
join_function = self.join_mode_function
|
||||
output_answers: list[AnswerType] = join_function(answers_list)
|
||||
|
||||
if self.sort_by_score:
|
||||
output_answers = sorted(
|
||||
output_answers,
|
||||
key=lambda answer: score if (score := getattr(answer, "score", None)) is not None else -inf,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
top_k = top_k or self.top_k
|
||||
if top_k:
|
||||
output_answers = output_answers[:top_k]
|
||||
return {"answers": output_answers}
|
||||
|
||||
def _concatenate(self, answer_lists: list[list[AnswerType]]) -> list[AnswerType]:
|
||||
"""
|
||||
Concatenate multiple lists of Answers, flattening them into a single list and sorting by score.
|
||||
|
||||
:param answer_lists: List of lists of Answers to be flattened.
|
||||
"""
|
||||
return list(itertools.chain.from_iterable(answer_lists))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, join_mode=str(self.join_mode), top_k=self.top_k, sort_by_score=self.sort_by_score)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AnswerJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,129 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import GreedyVariadic
|
||||
from haystack.utils import deserialize_type, serialize_type
|
||||
|
||||
|
||||
@component
|
||||
class BranchJoiner:
|
||||
"""
|
||||
A component that merges multiple input branches of a pipeline into a single output stream.
|
||||
|
||||
`BranchJoiner` receives multiple inputs of the same data type and forwards the first received value
|
||||
to its output. This is useful for scenarios where multiple branches need to converge before proceeding.
|
||||
|
||||
### Common Use Cases:
|
||||
- **Loop Handling:** `BranchJoiner` helps close loops in pipelines. For example, if a pipeline component validates
|
||||
or modifies incoming data and produces an error-handling branch, `BranchJoiner` can merge both branches and send
|
||||
(or resend in the case of a loop) the data to the component that evaluates errors. See "Usage example" below.
|
||||
|
||||
- **Decision-Based Merging:** `BranchJoiner` reconciles branches coming from Router components (such as
|
||||
`ConditionalRouter`, `TextLanguageRouter`). Suppose a `TextLanguageRouter` directs user queries to different
|
||||
Retrievers based on the detected language. Each Retriever processes its assigned query and passes the results
|
||||
to `BranchJoiner`, which consolidates them into a single output before passing them to the next component, such
|
||||
as a `PromptBuilder`.
|
||||
|
||||
### Example Usage:
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack import Pipeline
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.joiners import BranchJoiner
|
||||
from haystack.components.validators import JsonSchemaValidator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
# Define a schema for validation
|
||||
person_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": {"type": "string", "pattern": "^[A-Z][a-z]+$"},
|
||||
"last_name": {"type": "string", "pattern": "^[A-Z][a-z]+$"},
|
||||
"nationality": {"type": "string", "enum": ["Italian", "Portuguese", "American"]},
|
||||
},
|
||||
"required": ["first_name", "last_name", "nationality"]
|
||||
}
|
||||
|
||||
# Initialize a pipeline
|
||||
pipe = Pipeline()
|
||||
|
||||
# Add components to the pipeline
|
||||
pipe.add_component("joiner", BranchJoiner(list[ChatMessage]))
|
||||
pipe.add_component("generator", OpenAIChatGenerator(model="gpt-4.1-mini"))
|
||||
pipe.add_component("validator", JsonSchemaValidator(json_schema=person_schema))
|
||||
|
||||
# And connect them
|
||||
pipe.connect("joiner", "generator")
|
||||
pipe.connect("generator.replies", "validator.messages")
|
||||
pipe.connect("validator.validation_error", "joiner")
|
||||
|
||||
result = pipe.run(
|
||||
data={
|
||||
"generator": {"generation_kwargs": {"response_format": {"type": "json_object"}}},
|
||||
"joiner": {"value": [ChatMessage.from_user("Create json from Peter Parker")]}}
|
||||
)
|
||||
|
||||
print(json.loads(result["validator"]["validated"][0].text))
|
||||
|
||||
|
||||
# >> {'first_name': 'Peter', 'last_name': 'Parker', 'nationality': 'American', 'name': 'Spider-Man', 'occupation':
|
||||
# >> 'Superhero', 'age': 23, 'location': 'New York City'}
|
||||
```
|
||||
|
||||
Note that `BranchJoiner` can manage only one data type at a time. In this case, `BranchJoiner` is created for
|
||||
passing `list[ChatMessage]`. This determines the type of data that `BranchJoiner` will receive from the upstream
|
||||
connected components and also the type of data that `BranchJoiner` will send through its output.
|
||||
|
||||
In the code example, `BranchJoiner` receives a looped back `list[ChatMessage]` from the `JsonSchemaValidator` and
|
||||
sends it down to the `OpenAIChatGenerator` for re-generation. We can have multiple loopback connections in the
|
||||
pipeline. In this instance, the downstream component is only one (the `OpenAIChatGenerator`), but the pipeline could
|
||||
have more than one downstream component.
|
||||
"""
|
||||
|
||||
def __init__(self, type_: type) -> None:
|
||||
"""
|
||||
Creates a `BranchJoiner` component.
|
||||
|
||||
:param type_: The expected data type of inputs and outputs.
|
||||
"""
|
||||
self.type_ = type_
|
||||
component.set_input_types(self, value=GreedyVariadic[type_]) # type: ignore
|
||||
component.set_output_types(self, value=type_)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component into a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(self, type_=serialize_type(self.type_))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "BranchJoiner":
|
||||
"""
|
||||
Deserializes a `BranchJoiner` instance from a dictionary.
|
||||
|
||||
:param data: The dictionary containing serialized component data.
|
||||
:returns:
|
||||
A deserialized `BranchJoiner` instance.
|
||||
"""
|
||||
data["init_parameters"]["type_"] = deserialize_type(data["init_parameters"]["type_"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Executes the `BranchJoiner`, selecting the first available input value and passing it downstream.
|
||||
|
||||
:param **kwargs: The input data. Must be of the type declared by `type_` during initialization.
|
||||
:returns:
|
||||
A dictionary with a single key `value`, containing the first input received.
|
||||
"""
|
||||
if (inputs_count := len(kwargs["value"])) != 1:
|
||||
raise ValueError(f"BranchJoiner expects only one input, but {inputs_count} were received.")
|
||||
return {"value": kwargs["value"][0]}
|
||||
@@ -0,0 +1,271 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import replace
|
||||
from enum import Enum
|
||||
from math import inf
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.utils.misc import _reciprocal_rank_fusion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JoinMode(Enum):
|
||||
"""
|
||||
Enum for join mode.
|
||||
"""
|
||||
|
||||
CONCATENATE = "concatenate"
|
||||
MERGE = "merge"
|
||||
RECIPROCAL_RANK_FUSION = "reciprocal_rank_fusion"
|
||||
DISTRIBUTION_BASED_RANK_FUSION = "distribution_based_rank_fusion"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "JoinMode":
|
||||
"""
|
||||
Convert a string to a JoinMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in JoinMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown join mode '{string}'. Supported modes in DocumentJoiner are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class DocumentJoiner:
|
||||
"""
|
||||
Joins multiple lists of documents into a single list.
|
||||
|
||||
It supports different join modes:
|
||||
- concatenate: Keeps the highest-scored document in case of duplicates.
|
||||
- merge: Calculates a weighted sum of scores for duplicates and merges them.
|
||||
- reciprocal_rank_fusion: Merges and assigns scores based on reciprocal rank fusion.
|
||||
- distribution_based_rank_fusion: Merges and assigns scores based on scores distribution in each Retriever.
|
||||
|
||||
### Usage example:
|
||||
|
||||
```python
|
||||
from haystack import Pipeline, Document
|
||||
from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder
|
||||
from haystack.components.joiners import DocumentJoiner
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever
|
||||
from haystack.components.retrievers import InMemoryEmbeddingRetriever
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
|
||||
document_store = InMemoryDocumentStore()
|
||||
docs = [Document(content="Paris"), Document(content="Berlin"), Document(content="London")]
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
docs_embeddings = embedder.run(docs)
|
||||
document_store.write_documents(docs_embeddings['documents'])
|
||||
|
||||
p = Pipeline()
|
||||
p.add_component(instance=InMemoryBM25Retriever(document_store=document_store), name="bm25_retriever")
|
||||
p.add_component(
|
||||
instance=OpenAITextEmbedder(),
|
||||
name="text_embedder",
|
||||
)
|
||||
p.add_component(instance=InMemoryEmbeddingRetriever(document_store=document_store), name="embedding_retriever")
|
||||
p.add_component(instance=DocumentJoiner(), name="joiner")
|
||||
p.connect("bm25_retriever", "joiner")
|
||||
p.connect("embedding_retriever", "joiner")
|
||||
p.connect("text_embedder", "embedding_retriever")
|
||||
query = "What is the capital of France?"
|
||||
p.run(data={"query": query, "text": query, "top_k": 1})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
join_mode: str | JoinMode = JoinMode.CONCATENATE,
|
||||
weights: list[float] | None = None,
|
||||
top_k: int | None = None,
|
||||
sort_by_score: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a DocumentJoiner component.
|
||||
|
||||
:param join_mode:
|
||||
Specifies the join mode to use. Available modes:
|
||||
- `concatenate`: Keeps the highest-scored document in case of duplicates.
|
||||
- `merge`: Calculates a weighted sum of scores for duplicates and merges them.
|
||||
- `reciprocal_rank_fusion`: Merges and assigns scores based on reciprocal rank fusion.
|
||||
- `distribution_based_rank_fusion`: Merges and assigns scores based on scores
|
||||
distribution in each Retriever.
|
||||
:param weights:
|
||||
Assign importance to each list of documents to influence how they're joined.
|
||||
This parameter is ignored for
|
||||
`concatenate` or `distribution_based_rank_fusion` join modes.
|
||||
Weight for each list of documents must match the number of inputs.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param sort_by_score:
|
||||
If `True`, sorts the documents by score in descending order.
|
||||
If a document has no score, it is handled as if its score is -infinity.
|
||||
"""
|
||||
if isinstance(join_mode, str):
|
||||
join_mode = JoinMode.from_str(join_mode)
|
||||
join_mode_functions = {
|
||||
JoinMode.CONCATENATE: DocumentJoiner._concatenate,
|
||||
JoinMode.MERGE: self._merge,
|
||||
JoinMode.RECIPROCAL_RANK_FUSION: self._rrf,
|
||||
JoinMode.DISTRIBUTION_BASED_RANK_FUSION: DocumentJoiner._distribution_based_rank_fusion,
|
||||
}
|
||||
self.join_mode_function = join_mode_functions[join_mode]
|
||||
self.join_mode = join_mode
|
||||
if weights:
|
||||
weight_sum = sum(weights)
|
||||
if weight_sum == 0:
|
||||
raise ValueError("The provided `weights` must not sum to zero.")
|
||||
self.weights: list[float] | None = [float(i) / weight_sum for i in weights]
|
||||
else:
|
||||
self.weights = None
|
||||
self.top_k = top_k
|
||||
self.sort_by_score = sort_by_score
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: Variadic[list[Document]], top_k: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Joins multiple lists of Documents into a single list depending on the `join_mode` parameter.
|
||||
|
||||
:param documents:
|
||||
List of list of documents to be merged.
|
||||
:param top_k:
|
||||
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Merged list of Documents
|
||||
"""
|
||||
documents = list(documents)
|
||||
output_documents = self.join_mode_function(documents)
|
||||
|
||||
if self.sort_by_score:
|
||||
output_documents = sorted(
|
||||
output_documents, key=lambda doc: doc.score if doc.score is not None else -inf, reverse=True
|
||||
)
|
||||
if any(doc.score is None for doc in output_documents):
|
||||
logger.info(
|
||||
"Some of the Documents DocumentJoiner got have score=None. It was configured to sort Documents by "
|
||||
"score, so those with score=None were sorted as if they had a score of -infinity."
|
||||
)
|
||||
|
||||
if top_k:
|
||||
output_documents = output_documents[:top_k]
|
||||
elif self.top_k:
|
||||
output_documents = output_documents[: self.top_k]
|
||||
|
||||
return {"documents": output_documents}
|
||||
|
||||
@staticmethod
|
||||
def _concatenate(document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Concatenate multiple lists of Documents and return only the Document with the highest score for duplicates.
|
||||
"""
|
||||
output = []
|
||||
docs_per_id = defaultdict(list)
|
||||
for doc in itertools.chain.from_iterable(document_lists):
|
||||
docs_per_id[doc.id].append(doc)
|
||||
for docs in docs_per_id.values():
|
||||
doc_with_best_score = max(docs, key=lambda doc: doc.score if doc.score is not None else -inf)
|
||||
output.append(doc_with_best_score)
|
||||
return output
|
||||
|
||||
def _merge(self, document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and calculate a weighted sum of the scores of duplicate Documents.
|
||||
"""
|
||||
# This check prevents a division by zero when no documents are passed
|
||||
if not document_lists:
|
||||
return []
|
||||
|
||||
scores_map: dict = defaultdict(int)
|
||||
documents_map = {}
|
||||
weights = self.weights if self.weights else [1 / len(document_lists)] * len(document_lists)
|
||||
|
||||
for documents, weight in zip(document_lists, weights, strict=True):
|
||||
for doc in documents:
|
||||
scores_map[doc.id] += (doc.score if doc.score is not None else 0) * weight
|
||||
documents_map[doc.id] = doc
|
||||
|
||||
return [replace(doc, score=scores_map[doc.id]) for doc in documents_map.values()]
|
||||
|
||||
def _rrf(self, document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and assign scores based on reciprocal rank fusion.
|
||||
"""
|
||||
return _reciprocal_rank_fusion(document_lists, weights=self.weights)
|
||||
|
||||
@staticmethod
|
||||
def _distribution_based_rank_fusion(document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
Merge multiple lists of Documents and assign scores based on Distribution-Based Score Fusion.
|
||||
|
||||
(https://medium.com/plain-simple-software/distribution-based-score-fusion-dbsf-a-new-approach-to-vector-search-ranking-f87c37488b18)
|
||||
If a Document is in more than one retriever, the one with the highest score is used.
|
||||
"""
|
||||
rescaled_lists: list[list[Document]] = []
|
||||
for documents in document_lists:
|
||||
if len(documents) == 0:
|
||||
rescaled_lists.append(documents)
|
||||
continue
|
||||
|
||||
scores_list = [doc.score if doc.score is not None else 0 for doc in documents]
|
||||
|
||||
mean_score = sum(scores_list) / len(scores_list)
|
||||
std_dev = (sum((x - mean_score) ** 2 for x in scores_list) / len(scores_list)) ** 0.5
|
||||
min_score = mean_score - 3 * std_dev
|
||||
max_score = mean_score + 3 * std_dev
|
||||
delta_score = max_score - min_score
|
||||
|
||||
# if all docs have the same score delta_score is 0, the docs are uninformative for the query
|
||||
rescaled_lists.append(
|
||||
[
|
||||
replace(
|
||||
doc,
|
||||
score=((doc.score if doc.score is not None else 0) - min_score) / delta_score
|
||||
if delta_score != 0.0
|
||||
else 0.0,
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
)
|
||||
|
||||
return DocumentJoiner._concatenate(document_lists=rescaled_lists)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
join_mode=str(self.join_mode),
|
||||
weights=self.weights,
|
||||
top_k=self.top_k,
|
||||
sort_by_score=self.sort_by_score,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "DocumentJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.core.component.types import Variadic
|
||||
from haystack.utils import deserialize_type, serialize_type
|
||||
|
||||
|
||||
@component
|
||||
class ListJoiner:
|
||||
"""
|
||||
A component that joins multiple lists into a single flat list.
|
||||
|
||||
The ListJoiner receives multiple lists of the same type and concatenates them into a single flat list.
|
||||
The output order respects the pipeline's execution sequence, with earlier inputs being added first.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.builders import ChatPromptBuilder
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack import Pipeline
|
||||
from haystack.components.joiners import ListJoiner
|
||||
|
||||
|
||||
user_message = [ChatMessage.from_user("Give a brief answer the following question: {{query}}")]
|
||||
|
||||
feedback_prompt = \"""
|
||||
You are given a question and an answer.
|
||||
Your task is to provide a score and a brief feedback on the answer.
|
||||
Question: {{query}}
|
||||
Answer: {{response}}
|
||||
\"""
|
||||
feedback_message = [ChatMessage.from_system(feedback_prompt)]
|
||||
|
||||
prompt_builder = ChatPromptBuilder(template=user_message)
|
||||
feedback_prompt_builder = ChatPromptBuilder(template=feedback_message)
|
||||
llm = OpenAIChatGenerator()
|
||||
feedback_llm = OpenAIChatGenerator()
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("prompt_builder", prompt_builder)
|
||||
pipe.add_component("llm", llm)
|
||||
pipe.add_component("feedback_prompt_builder", feedback_prompt_builder)
|
||||
pipe.add_component("feedback_llm", feedback_llm)
|
||||
pipe.add_component("list_joiner", ListJoiner(list[ChatMessage]))
|
||||
|
||||
pipe.connect("prompt_builder.prompt", "llm.messages")
|
||||
pipe.connect("prompt_builder.prompt", "list_joiner")
|
||||
pipe.connect("llm.replies", "list_joiner")
|
||||
pipe.connect("llm.replies", "feedback_prompt_builder.response")
|
||||
pipe.connect("feedback_prompt_builder.prompt", "feedback_llm.messages")
|
||||
pipe.connect("feedback_llm.replies", "list_joiner")
|
||||
|
||||
query = "What is nuclear physics?"
|
||||
ans = pipe.run(data={"prompt_builder": {"template_variables":{"query": query}},
|
||||
"feedback_prompt_builder": {"template_variables":{"query": query}}})
|
||||
|
||||
print(ans["list_joiner"]["values"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, list_type_: type | None = None) -> None:
|
||||
"""
|
||||
Creates a ListJoiner component.
|
||||
|
||||
:param list_type_: The expected type of the lists this component will join (e.g., list[ChatMessage]).
|
||||
If specified, all input lists must conform to this type. If None, the component defaults to handling
|
||||
lists of any type including mixed types.
|
||||
"""
|
||||
self.list_type_ = list_type_
|
||||
if list_type_ is not None:
|
||||
component.set_output_types(self, values=list_type_)
|
||||
else:
|
||||
component.set_output_types(self, values=list[Any])
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns: Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, list_type_=serialize_type(self.list_type_) if self.list_type_ is not None else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ListJoiner":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data: Dictionary to deserialize from.
|
||||
:returns: Deserialized component.
|
||||
"""
|
||||
init_parameters = data.get("init_parameters")
|
||||
if init_parameters is not None and init_parameters.get("list_type_") is not None:
|
||||
data["init_parameters"]["list_type_"] = deserialize_type(data["init_parameters"]["list_type_"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def run(self, values: Variadic[list[Any]]) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Joins multiple lists into a single flat list.
|
||||
|
||||
:param values: The list to be joined.
|
||||
:returns: Dictionary with 'values' key containing the joined list.
|
||||
"""
|
||||
result = list(chain(*values))
|
||||
return {"values": result}
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
from haystack import component
|
||||
from haystack.core.component.types import Variadic
|
||||
|
||||
|
||||
@component
|
||||
class StringJoiner:
|
||||
"""
|
||||
Component to join strings from different components to a list of strings.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.joiners import StringJoiner
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.core.pipeline import Pipeline
|
||||
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.dataclasses import ChatMessage
|
||||
|
||||
string_1 = "What's Natural Language Processing?"
|
||||
string_2 = "What is life?"
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("prompt_builder_1", PromptBuilder("Builder 1: {{query}}"))
|
||||
pipeline.add_component("prompt_builder_2", PromptBuilder("Builder 2: {{query}}"))
|
||||
pipeline.add_component("string_joiner", StringJoiner())
|
||||
|
||||
pipeline.connect("prompt_builder_1.prompt", "string_joiner.strings")
|
||||
pipeline.connect("prompt_builder_2.prompt", "string_joiner.strings")
|
||||
|
||||
print(pipeline.run(data={"prompt_builder_1": {"query": string_1}, "prompt_builder_2": {"query": string_2}}))
|
||||
|
||||
# >> {"string_joiner": {"strings": ["Builder 1: What's Natural Language Processing?", "Builder 2: What is life?"]}}
|
||||
```
|
||||
"""
|
||||
|
||||
@component.output_types(strings=list[str])
|
||||
def run(self, strings: Variadic[str]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Joins strings into a list of strings
|
||||
|
||||
:param strings:
|
||||
strings from different components
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `strings`: Merged list of strings
|
||||
"""
|
||||
|
||||
out_strings = list(strings)
|
||||
return {"strings": out_strings}
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"csv_document_cleaner": ["CSVDocumentCleaner"],
|
||||
"csv_document_splitter": ["CSVDocumentSplitter"],
|
||||
"document_cleaner": ["DocumentCleaner"],
|
||||
"document_preprocessor": ["DocumentPreprocessor"],
|
||||
"document_splitter": ["DocumentSplitter"],
|
||||
"embedding_based_document_splitter": ["EmbeddingBasedDocumentSplitter"],
|
||||
"hierarchical_document_splitter": ["HierarchicalDocumentSplitter"],
|
||||
"markdown_header_splitter": ["MarkdownHeaderSplitter"],
|
||||
"python_code_splitter": ["PythonCodeSplitter"],
|
||||
"recursive_splitter": ["RecursiveDocumentSplitter"],
|
||||
"text_cleaner": ["TextCleaner"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .csv_document_cleaner import CSVDocumentCleaner as CSVDocumentCleaner
|
||||
from .csv_document_splitter import CSVDocumentSplitter as CSVDocumentSplitter
|
||||
from .document_cleaner import DocumentCleaner as DocumentCleaner
|
||||
from .document_preprocessor import DocumentPreprocessor as DocumentPreprocessor
|
||||
from .document_splitter import DocumentSplitter as DocumentSplitter
|
||||
from .embedding_based_document_splitter import EmbeddingBasedDocumentSplitter as EmbeddingBasedDocumentSplitter
|
||||
from .hierarchical_document_splitter import HierarchicalDocumentSplitter as HierarchicalDocumentSplitter
|
||||
from .markdown_header_splitter import MarkdownHeaderSplitter as MarkdownHeaderSplitter
|
||||
from .python_code_splitter import PythonCodeSplitter as PythonCodeSplitter
|
||||
from .recursive_splitter import RecursiveDocumentSplitter as RecursiveDocumentSplitter
|
||||
from .text_cleaner import TextCleaner as TextCleaner
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,178 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from copy import deepcopy
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pandas'") as pandas_import:
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class CSVDocumentCleaner:
|
||||
"""
|
||||
A component for cleaning CSV documents by removing empty rows and columns.
|
||||
|
||||
This component processes CSV content stored in Documents, allowing
|
||||
for the optional ignoring of a specified number of rows and columns before performing
|
||||
the cleaning operation. Additionally, it provides options to keep document IDs and
|
||||
control whether empty rows and columns should be removed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ignore_rows: int = 0,
|
||||
ignore_columns: int = 0,
|
||||
remove_empty_rows: bool = True,
|
||||
remove_empty_columns: bool = True,
|
||||
keep_id: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the CSVDocumentCleaner component.
|
||||
|
||||
:param ignore_rows: Number of rows to ignore from the top of the CSV table before processing.
|
||||
:param ignore_columns: Number of columns to ignore from the left of the CSV table before processing.
|
||||
:param remove_empty_rows: Whether to remove rows that are entirely empty.
|
||||
:param remove_empty_columns: Whether to remove columns that are entirely empty.
|
||||
:param keep_id: Whether to retain the original document ID in the output document.
|
||||
|
||||
Rows and columns ignored using these parameters are preserved in the final output, meaning
|
||||
they are not considered when removing empty rows and columns.
|
||||
"""
|
||||
self.ignore_rows = ignore_rows
|
||||
self.ignore_columns = ignore_columns
|
||||
self.remove_empty_rows = remove_empty_rows
|
||||
self.remove_empty_columns = remove_empty_columns
|
||||
self.keep_id = keep_id
|
||||
pandas_import.check()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Cleans CSV documents by removing empty rows and columns while preserving specified ignored rows and columns.
|
||||
|
||||
:param documents: List of Documents containing CSV-formatted content.
|
||||
:return: A dictionary with a list of cleaned Documents under the key "documents".
|
||||
|
||||
Processing steps:
|
||||
1. Reads each document's content as a CSV table.
|
||||
2. Retains the specified number of `ignore_rows` from the top and `ignore_columns` from the left.
|
||||
3. Drops any rows and columns that are entirely empty (if enabled by `remove_empty_rows` and
|
||||
`remove_empty_columns`).
|
||||
4. Reattaches the ignored rows and columns to maintain their original positions.
|
||||
5. Returns the cleaned CSV content as a new `Document` object, with an option to retain the original
|
||||
document ID.
|
||||
"""
|
||||
if len(documents) == 0:
|
||||
return {"documents": []}
|
||||
|
||||
ignore_rows = self.ignore_rows
|
||||
ignore_columns = self.ignore_columns
|
||||
|
||||
cleaned_documents = []
|
||||
for document in documents:
|
||||
try:
|
||||
df = pd.read_csv(StringIO(document.content), header=None, dtype=object)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error processing document {id}. Keeping it, but skipping cleaning. Error: {error}",
|
||||
id=document.id,
|
||||
error=e,
|
||||
)
|
||||
cleaned_documents.append(document)
|
||||
continue
|
||||
|
||||
if ignore_rows > df.shape[0] or ignore_columns > df.shape[1]:
|
||||
logger.warning(
|
||||
"Document {id} has fewer rows {df_rows} or columns {df_cols} "
|
||||
"than the number of rows {rows} or columns {cols} to ignore. "
|
||||
"Keeping the entire document.",
|
||||
id=document.id,
|
||||
df_rows=df.shape[0],
|
||||
df_cols=df.shape[1],
|
||||
rows=ignore_rows,
|
||||
cols=ignore_columns,
|
||||
)
|
||||
cleaned_documents.append(document)
|
||||
continue
|
||||
|
||||
final_df = self._clean_df(df=df, ignore_rows=ignore_rows, ignore_columns=ignore_columns)
|
||||
|
||||
clean_doc = Document(
|
||||
id=document.id if self.keep_id else "",
|
||||
content=final_df.to_csv(index=False, header=False, lineterminator="\n"),
|
||||
blob=document.blob,
|
||||
meta=deepcopy(document.meta),
|
||||
score=document.score,
|
||||
embedding=document.embedding,
|
||||
sparse_embedding=document.sparse_embedding,
|
||||
)
|
||||
cleaned_documents.append(clean_doc)
|
||||
return {"documents": cleaned_documents}
|
||||
|
||||
def _clean_df(self, df: "pd.DataFrame", ignore_rows: int, ignore_columns: int) -> "pd.DataFrame":
|
||||
"""
|
||||
Cleans a DataFrame by removing empty rows and columns while preserving ignored sections.
|
||||
|
||||
:param df: The input DataFrame representing the CSV data.
|
||||
:param ignore_rows: Number of top rows to ignore.
|
||||
:param ignore_columns: Number of left columns to ignore.
|
||||
"""
|
||||
# Get ignored rows and columns
|
||||
ignored_rows = self._get_ignored_rows(df=df, ignore_rows=ignore_rows)
|
||||
ignored_columns = self._get_ignored_columns(df=df, ignore_columns=ignore_columns)
|
||||
final_df = df.iloc[ignore_rows:, ignore_columns:]
|
||||
|
||||
# Drop rows that are entirely empty
|
||||
if self.remove_empty_rows:
|
||||
final_df = final_df.dropna(axis=0, how="all")
|
||||
|
||||
# Drop columns that are entirely empty
|
||||
if self.remove_empty_columns:
|
||||
final_df = final_df.dropna(axis=1, how="all")
|
||||
|
||||
# Reattach ignored rows
|
||||
if ignore_rows > 0 and ignored_rows is not None:
|
||||
# Keep only relevant columns
|
||||
ignored_rows = ignored_rows.loc[:, final_df.columns]
|
||||
final_df = pd.concat([ignored_rows, final_df], axis=0)
|
||||
|
||||
# Reattach ignored columns
|
||||
if ignore_columns > 0 and ignored_columns is not None:
|
||||
# Keep only relevant rows
|
||||
ignored_columns = ignored_columns.loc[final_df.index, :]
|
||||
final_df = pd.concat([ignored_columns, final_df], axis=1)
|
||||
|
||||
return final_df
|
||||
|
||||
@staticmethod
|
||||
def _get_ignored_rows(df: "pd.DataFrame", ignore_rows: int) -> Optional["pd.DataFrame"]:
|
||||
"""
|
||||
Extracts the rows to be ignored from the DataFrame.
|
||||
|
||||
:param df: The input DataFrame.
|
||||
:param ignore_rows: Number of rows to extract from the top.
|
||||
"""
|
||||
if ignore_rows > 0:
|
||||
return df.iloc[:ignore_rows, :]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_ignored_columns(df: "pd.DataFrame", ignore_columns: int) -> Optional["pd.DataFrame"]:
|
||||
"""
|
||||
Extracts the columns to be ignored from the DataFrame.
|
||||
|
||||
:param df: The input DataFrame.
|
||||
:param ignore_columns: Number of columns to extract from the left.
|
||||
"""
|
||||
if ignore_columns > 0:
|
||||
return df.iloc[:, :ignore_columns]
|
||||
return None
|
||||
@@ -0,0 +1,286 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pandas'") as pandas_import:
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SplitMode = Literal["threshold", "row-wise"]
|
||||
|
||||
|
||||
@component
|
||||
class CSVDocumentSplitter:
|
||||
"""
|
||||
A component for splitting CSV documents into sub-tables based on split arguments.
|
||||
|
||||
The splitter supports two modes of operation:
|
||||
- identify consecutive empty rows or columns that exceed a given threshold
|
||||
and uses them as delimiters to segment the document into smaller tables.
|
||||
- split each row into a separate sub-table, represented as a Document.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
row_split_threshold: int | None = 2,
|
||||
column_split_threshold: int | None = 2,
|
||||
read_csv_kwargs: dict[str, Any] | None = None,
|
||||
split_mode: SplitMode = "threshold",
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the CSVDocumentSplitter component.
|
||||
|
||||
:param row_split_threshold: The minimum number of consecutive empty rows required to trigger a split.
|
||||
:param column_split_threshold: The minimum number of consecutive empty columns required to trigger a split.
|
||||
:param read_csv_kwargs: Additional keyword arguments to pass to `pandas.read_csv`.
|
||||
By default, the component with options:
|
||||
- `header=None`
|
||||
- `skip_blank_lines=False` to preserve blank lines
|
||||
- `dtype=object` to prevent type inference (e.g., converting numbers to floats).
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html for more information.
|
||||
:param split_mode:
|
||||
If `threshold`, the component will split the document based on the number of
|
||||
consecutive empty rows or columns that exceed the `row_split_threshold` or `column_split_threshold`.
|
||||
If `row-wise`, the component will split each row into a separate sub-table.
|
||||
"""
|
||||
pandas_import.check()
|
||||
if split_mode not in get_args(SplitMode):
|
||||
raise ValueError(
|
||||
f"Split mode '{split_mode}' not recognized. Choose one among: {', '.join(get_args(SplitMode))}."
|
||||
)
|
||||
if row_split_threshold is not None and row_split_threshold < 1:
|
||||
raise ValueError("row_split_threshold must be greater than 0")
|
||||
|
||||
if column_split_threshold is not None and column_split_threshold < 1:
|
||||
raise ValueError("column_split_threshold must be greater than 0")
|
||||
|
||||
if row_split_threshold is None and column_split_threshold is None:
|
||||
raise ValueError("At least one of row_split_threshold or column_split_threshold must be specified.")
|
||||
|
||||
self.row_split_threshold = row_split_threshold
|
||||
self.column_split_threshold = column_split_threshold
|
||||
self.read_csv_kwargs = read_csv_kwargs or {}
|
||||
self.split_mode = split_mode
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Processes and splits a list of CSV documents into multiple sub-tables.
|
||||
|
||||
**Splitting Process:**
|
||||
1. Applies a row-based split if `row_split_threshold` is provided.
|
||||
2. Applies a column-based split if `column_split_threshold` is provided.
|
||||
3. If both thresholds are specified, performs a recursive split by rows first, then columns, ensuring
|
||||
further fragmentation of any sub-tables that still contain empty sections.
|
||||
4. Sorts the resulting sub-tables based on their original positions within the document.
|
||||
|
||||
:param documents: A list of Documents containing CSV-formatted content.
|
||||
Each document is assumed to contain one or more tables separated by empty rows or columns.
|
||||
|
||||
:return:
|
||||
A dictionary with a key `"documents"`, mapping to a list of new `Document` objects,
|
||||
each representing an extracted sub-table from the original CSV.
|
||||
The metadata of each document includes:
|
||||
- A field `source_id` to track the original document.
|
||||
- A field `row_idx_start` to indicate the starting row index of the sub-table in the original table.
|
||||
- A field `col_idx_start` to indicate the starting column index of the sub-table in the original table.
|
||||
- A field `split_id` to indicate the order of the split in the original document.
|
||||
- All other metadata copied from the original document.
|
||||
|
||||
- If a document cannot be processed, it is returned unchanged.
|
||||
- The `meta` field from the original document is preserved in the split documents.
|
||||
"""
|
||||
if len(documents) == 0:
|
||||
return {"documents": documents}
|
||||
|
||||
resolved_read_csv_kwargs = {"header": None, "skip_blank_lines": False, "dtype": object, **self.read_csv_kwargs}
|
||||
|
||||
split_documents = []
|
||||
split_dfs = []
|
||||
for document in documents:
|
||||
try:
|
||||
df = pd.read_csv(StringIO(document.content), **resolved_read_csv_kwargs)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error processing document {document_id}. Keeping it, but skipping splitting. Error: {error}",
|
||||
document_id=document.id,
|
||||
error=e,
|
||||
)
|
||||
split_documents.append(document)
|
||||
continue
|
||||
|
||||
if self.split_mode == "row-wise":
|
||||
# each row is a separate sub-table
|
||||
split_dfs = self._split_by_row(df=df)
|
||||
|
||||
elif self.split_mode == "threshold":
|
||||
if self.row_split_threshold is not None and self.column_split_threshold is None:
|
||||
# split by rows
|
||||
split_dfs = self._split_dataframe(df=df, split_threshold=self.row_split_threshold, axis="row")
|
||||
elif self.column_split_threshold is not None and self.row_split_threshold is None:
|
||||
# split by columns
|
||||
split_dfs = self._split_dataframe(df=df, split_threshold=self.column_split_threshold, axis="column")
|
||||
else:
|
||||
# recursive split
|
||||
split_dfs = self._recursive_split(
|
||||
df=df,
|
||||
row_split_threshold=self.row_split_threshold, # type: ignore
|
||||
column_split_threshold=self.column_split_threshold, # type: ignore
|
||||
)
|
||||
|
||||
# check if no sub-tables were found
|
||||
if len(split_dfs) == 0:
|
||||
logger.warning(
|
||||
"No sub-tables found while splitting CSV Document with id {doc_id}. Skipping document.",
|
||||
doc_id=document.id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Sort split_dfs first by row index, then by column index
|
||||
split_dfs.sort(key=lambda dataframe: (dataframe.index[0], dataframe.columns[0]))
|
||||
|
||||
for split_id, split_df in enumerate(split_dfs):
|
||||
split_documents.append(
|
||||
Document(
|
||||
content=split_df.to_csv(index=False, header=False, lineterminator="\n"),
|
||||
meta={
|
||||
**document.meta.copy(),
|
||||
"source_id": document.id,
|
||||
"row_idx_start": int(split_df.index[0]),
|
||||
"col_idx_start": int(split_df.columns[0]),
|
||||
"split_id": split_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return {"documents": split_documents}
|
||||
|
||||
@staticmethod
|
||||
def _find_split_indices(
|
||||
df: "pd.DataFrame", split_threshold: int, axis: Literal["row", "column"]
|
||||
) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Finds the indices of consecutive empty rows or columns in a DataFrame.
|
||||
|
||||
:param df: DataFrame to split.
|
||||
:param split_threshold: Minimum number of consecutive empty rows or columns to trigger a split.
|
||||
:param axis: Axis along which to find empty elements. Either "row" or "column".
|
||||
:return: List of indices where consecutive empty rows or columns start.
|
||||
"""
|
||||
if axis == "row":
|
||||
empty_elements = df[df.isnull().all(axis=1)].index.tolist()
|
||||
else:
|
||||
empty_elements = df.columns[df.isnull().all(axis=0)].tolist()
|
||||
|
||||
# If no empty elements found, return empty list
|
||||
if len(empty_elements) == 0:
|
||||
return []
|
||||
|
||||
# Identify groups of consecutive empty elements
|
||||
split_indices = []
|
||||
consecutive_count = 1
|
||||
start_index = empty_elements[0]
|
||||
|
||||
for i in range(1, len(empty_elements)):
|
||||
if empty_elements[i] == empty_elements[i - 1] + 1:
|
||||
consecutive_count += 1
|
||||
else:
|
||||
if consecutive_count >= split_threshold:
|
||||
split_indices.append((start_index, empty_elements[i - 1]))
|
||||
consecutive_count = 1
|
||||
start_index = empty_elements[i]
|
||||
|
||||
# Handle the last group of consecutive elements
|
||||
if consecutive_count >= split_threshold:
|
||||
split_indices.append((start_index, empty_elements[-1]))
|
||||
|
||||
return split_indices
|
||||
|
||||
def _split_dataframe(
|
||||
self, df: "pd.DataFrame", split_threshold: int, axis: Literal["row", "column"]
|
||||
) -> list["pd.DataFrame"]:
|
||||
"""
|
||||
Splits a DataFrame into sub-tables based on consecutive empty rows or columns exceeding `split_threshold`.
|
||||
|
||||
:param df: DataFrame to split.
|
||||
:param split_threshold: Minimum number of consecutive empty rows or columns to trigger a split.
|
||||
:param axis: Axis along which to split. Either "row" or "column".
|
||||
:return: List of split DataFrames.
|
||||
"""
|
||||
# Find indices of consecutive empty rows or columns
|
||||
split_indices = self._find_split_indices(df=df, split_threshold=split_threshold, axis=axis)
|
||||
|
||||
# If no split_indices are found, return the original DataFrame
|
||||
if len(split_indices) == 0:
|
||||
return [df]
|
||||
|
||||
# Split the DataFrame at identified indices
|
||||
sub_tables = []
|
||||
table_start_idx = 0
|
||||
df_length = df.shape[0] if axis == "row" else df.shape[1]
|
||||
for empty_start_idx, empty_end_idx in split_indices + [(df_length, df_length)]:
|
||||
# Avoid empty splits
|
||||
if empty_start_idx - table_start_idx >= 1:
|
||||
if axis == "row":
|
||||
sub_table = df.iloc[table_start_idx:empty_start_idx]
|
||||
else:
|
||||
sub_table = df.iloc[:, table_start_idx:empty_start_idx]
|
||||
if not sub_table.empty:
|
||||
sub_tables.append(sub_table)
|
||||
table_start_idx = empty_end_idx + 1
|
||||
|
||||
return sub_tables
|
||||
|
||||
def _recursive_split(
|
||||
self, df: "pd.DataFrame", row_split_threshold: int, column_split_threshold: int
|
||||
) -> list["pd.DataFrame"]:
|
||||
"""
|
||||
Recursively splits a DataFrame.
|
||||
|
||||
Recursively splits a DataFrame first by empty rows, then by empty columns, and repeats the process
|
||||
until no more splits are possible. Returns a list of DataFrames, each representing a fully separated sub-table.
|
||||
|
||||
:param df: A Pandas DataFrame representing a table (or multiple tables) extracted from a CSV.
|
||||
:param row_split_threshold: The minimum number of consecutive empty rows required to trigger a split.
|
||||
:param column_split_threshold: The minimum number of consecutive empty columns to trigger a split.
|
||||
"""
|
||||
|
||||
# Step 1: Split by rows
|
||||
new_sub_tables = self._split_dataframe(df=df, split_threshold=row_split_threshold, axis="row")
|
||||
|
||||
# Step 2: Split by columns
|
||||
final_tables = []
|
||||
for table in new_sub_tables:
|
||||
final_tables.extend(self._split_dataframe(df=table, split_threshold=column_split_threshold, axis="column"))
|
||||
|
||||
# Step 3: Recursively reapply splitting checked by whether any new empty rows appear after column split
|
||||
result = []
|
||||
for table in final_tables:
|
||||
# Check if there are consecutive rows >= row_split_threshold now present
|
||||
if len(self._find_split_indices(df=table, split_threshold=row_split_threshold, axis="row")) > 0:
|
||||
result.extend(
|
||||
self._recursive_split(
|
||||
df=table, row_split_threshold=row_split_threshold, column_split_threshold=column_split_threshold
|
||||
)
|
||||
)
|
||||
else:
|
||||
result.append(table)
|
||||
|
||||
return result
|
||||
|
||||
def _split_by_row(self, df: "pd.DataFrame") -> list["pd.DataFrame"]:
|
||||
"""Split each CSV row into a separate subtable"""
|
||||
split_dfs = []
|
||||
for idx, row in enumerate(df.itertuples(index=False)):
|
||||
split_df = pd.DataFrame(row).T
|
||||
split_df.index = [idx] # Set the index of the new DataFrame to idx
|
||||
split_dfs.append(split_df)
|
||||
return split_dfs
|
||||
@@ -0,0 +1,352 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
from functools import partial, reduce
|
||||
from itertools import chain
|
||||
from typing import Literal
|
||||
from unicodedata import normalize
|
||||
|
||||
from haystack import Document, component, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class DocumentCleaner:
|
||||
"""
|
||||
Cleans the text in the documents.
|
||||
|
||||
It removes extra whitespaces,
|
||||
empty lines, specified substrings, regexes,
|
||||
page headers and footers (in this order).
|
||||
|
||||
### Usage example:
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import DocumentCleaner
|
||||
|
||||
doc = Document(content="This is a document to clean\\n\\n\\nsubstring to remove")
|
||||
|
||||
cleaner = DocumentCleaner(remove_substrings = ["substring to remove"])
|
||||
result = cleaner.run(documents=[doc])
|
||||
|
||||
assert result["documents"][0].content == "This is a document to clean "
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remove_empty_lines: bool = True,
|
||||
remove_extra_whitespaces: bool = True,
|
||||
remove_repeated_substrings: bool = False,
|
||||
keep_id: bool = False,
|
||||
remove_substrings: list[str] | None = None,
|
||||
remove_regex: str | None = None,
|
||||
unicode_normalization: Literal["NFC", "NFKC", "NFD", "NFKD"] | None = None,
|
||||
ascii_only: bool = False,
|
||||
strip_whitespaces: bool = False,
|
||||
replace_regexes: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize DocumentCleaner.
|
||||
|
||||
:param remove_empty_lines: If `True`, removes empty lines.
|
||||
:param remove_extra_whitespaces: If `True`, removes extra whitespaces.
|
||||
:param remove_repeated_substrings: If `True`, removes repeated substrings (headers and footers) from pages.
|
||||
Pages must be separated by a form feed character "\\f",
|
||||
which is supported by `TextFileToDocument` and `AzureOCRDocumentConverter`.
|
||||
:param remove_substrings: List of substrings to remove from the text.
|
||||
:param remove_regex: Regex to match and replace substrings by "".
|
||||
:param keep_id: If `True`, keeps the IDs of the original documents.
|
||||
:param unicode_normalization: Unicode normalization form to apply to the text.
|
||||
Note: This will run before any other steps.
|
||||
:param ascii_only: Whether to convert the text to ASCII only.
|
||||
Will remove accents from characters and replace them with ASCII characters.
|
||||
Other non-ASCII characters will be removed.
|
||||
Note: This will run before any pattern matching or removal.
|
||||
:param strip_whitespaces: If `True`, removes leading and trailing whitespace from the document content
|
||||
using Python's `str.strip()`. Unlike `remove_extra_whitespaces`, this only affects the beginning
|
||||
and end of the text, preserving internal whitespace (useful for markdown formatting).
|
||||
:param replace_regexes: A dictionary mapping regex patterns to their replacement strings.
|
||||
For example, `{r'\\n\\n+': '\\n'}` replaces multiple consecutive newlines with a single newline.
|
||||
This is applied after `remove_regex` and allows custom replacements instead of just removal.
|
||||
"""
|
||||
|
||||
self._validate_params(unicode_normalization=unicode_normalization)
|
||||
|
||||
self.remove_empty_lines = remove_empty_lines
|
||||
self.remove_extra_whitespaces = remove_extra_whitespaces
|
||||
self.remove_repeated_substrings = remove_repeated_substrings
|
||||
self.remove_substrings = remove_substrings
|
||||
self.remove_regex = remove_regex
|
||||
self.keep_id = keep_id
|
||||
self.unicode_normalization = unicode_normalization
|
||||
self.ascii_only = ascii_only
|
||||
self.strip_whitespaces = strip_whitespaces
|
||||
self.replace_regexes = replace_regexes
|
||||
|
||||
def _validate_params(self, unicode_normalization: str | None) -> None:
|
||||
"""
|
||||
Validate the parameters of the DocumentCleaner.
|
||||
|
||||
:param unicode_normalization: Unicode normalization form to apply to the text.
|
||||
:raises ValueError: if the parameters are not valid.
|
||||
"""
|
||||
if unicode_normalization and unicode_normalization not in ["NFC", "NFKC", "NFD", "NFKD"]:
|
||||
raise ValueError("unicode_normalization must be one of 'NFC', 'NFKC', 'NFD', 'NFKD'.")
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Cleans up the documents.
|
||||
|
||||
:param documents: List of Documents to clean.
|
||||
|
||||
:returns: A dictionary with the following key:
|
||||
- `documents`: List of cleaned Documents.
|
||||
|
||||
:raises TypeError: if documents is not a list of Documents.
|
||||
"""
|
||||
if not isinstance(documents, list) or documents and not isinstance(documents[0], Document):
|
||||
raise TypeError("DocumentCleaner expects a List of Documents as input.")
|
||||
|
||||
cleaned_docs = []
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
logger.warning(
|
||||
"DocumentCleaner only cleans text documents but document.content for document ID"
|
||||
" {document_id} is None.",
|
||||
document_id=doc.id,
|
||||
)
|
||||
cleaned_docs.append(doc)
|
||||
continue
|
||||
text = doc.content
|
||||
|
||||
if self.unicode_normalization:
|
||||
text = self._normalize_unicode(text, self.unicode_normalization)
|
||||
if self.ascii_only:
|
||||
text = self._ascii_only(text)
|
||||
if self.remove_extra_whitespaces:
|
||||
text = self._remove_extra_whitespaces(text)
|
||||
if self.remove_empty_lines:
|
||||
text = self._remove_empty_lines(text)
|
||||
if self.remove_substrings:
|
||||
text = self._remove_substrings(text, self.remove_substrings)
|
||||
if self.remove_regex:
|
||||
text = self._remove_regex(text, self.remove_regex)
|
||||
if self.replace_regexes:
|
||||
text = self._replace_regexes(text, self.replace_regexes)
|
||||
if self.remove_repeated_substrings:
|
||||
text = self._remove_repeated_substrings(text)
|
||||
if self.strip_whitespaces:
|
||||
text = text.strip()
|
||||
|
||||
clean_doc = Document(
|
||||
id=doc.id if self.keep_id else "",
|
||||
content=text,
|
||||
blob=doc.blob,
|
||||
meta=deepcopy(doc.meta),
|
||||
score=doc.score,
|
||||
embedding=doc.embedding,
|
||||
sparse_embedding=doc.sparse_embedding,
|
||||
)
|
||||
cleaned_docs.append(clean_doc)
|
||||
|
||||
return {"documents": cleaned_docs}
|
||||
|
||||
def _normalize_unicode(self, text: str, form: Literal["NFC", "NFKC", "NFD", "NFKD"]) -> str:
|
||||
"""
|
||||
Normalize the unicode of the text.
|
||||
|
||||
:param text: Text to normalize.
|
||||
:param form: Unicode normalization form to apply to the text.
|
||||
Options: "NFC", "NFKC", "NFD", "NFKD".
|
||||
:returns: The normalized text.
|
||||
"""
|
||||
return normalize(form, text)
|
||||
|
||||
def _ascii_only(self, text: str) -> str:
|
||||
"""
|
||||
Convert the text to ASCII only.
|
||||
|
||||
Will remove accents from characters and replace them with ASCII characters.
|
||||
Other non-ASCII characters will be removed.
|
||||
|
||||
:param text: Text to convert to ASCII only.
|
||||
:returns: The text in ASCII only.
|
||||
"""
|
||||
|
||||
# First normalize the text to NFKD to separate the characters and their diacritics
|
||||
# Then encode it to ASCII and ignore any characters that can't be encoded
|
||||
return self._normalize_unicode(text, "NFKD").encode("ascii", "ignore").decode("utf-8")
|
||||
|
||||
def _remove_empty_lines(self, text: str) -> str:
|
||||
"""
|
||||
Remove empty lines and lines that contain nothing but whitespaces from text.
|
||||
|
||||
:param text: Text to clean.
|
||||
:returns: The text without empty lines.
|
||||
"""
|
||||
pages = text.split("\f")
|
||||
cleaned_pages = ["\n".join(line for line in page.split("\n") if line.strip()) for page in pages]
|
||||
return "\f".join(cleaned_pages)
|
||||
|
||||
def _remove_extra_whitespaces(self, text: str) -> str:
|
||||
"""
|
||||
Remove extra whitespaces from text.
|
||||
|
||||
:param text: Text to clean.
|
||||
:returns: The text without extra whitespaces.
|
||||
"""
|
||||
texts = text.split("\f")
|
||||
cleaned_text = [re.sub(r"\s\s+", " ", text).strip() for text in texts]
|
||||
return "\f".join(cleaned_text)
|
||||
|
||||
def _remove_regex(self, text: str, regex: str) -> str:
|
||||
"""
|
||||
Remove substrings that match the specified regex from the text.
|
||||
|
||||
:param text: Text to clean.
|
||||
:param regex: Regex to match and replace substrings by "".
|
||||
:returns: The text without the substrings that match the regex.
|
||||
"""
|
||||
texts = text.split("\f")
|
||||
cleaned_text = [re.sub(regex, "", text).strip() for text in texts]
|
||||
return "\f".join(cleaned_text)
|
||||
|
||||
def _replace_regexes(self, text: str, replace_regexes: dict[str, str]) -> str:
|
||||
"""
|
||||
Replace substrings that match the specified regex patterns with custom replacement strings.
|
||||
|
||||
:param text: Text to clean.
|
||||
:param replace_regexes: A dictionary mapping regex patterns to their replacement strings.
|
||||
:returns: The text with the regex matches replaced by the specified strings.
|
||||
"""
|
||||
pages = text.split("\f")
|
||||
cleaned_pages = []
|
||||
for page in pages:
|
||||
for pattern, replacement in replace_regexes.items():
|
||||
page = re.sub(pattern, replacement, page)
|
||||
cleaned_pages.append(page)
|
||||
return "\f".join(cleaned_pages)
|
||||
|
||||
def _remove_substrings(self, text: str, substrings: list[str]) -> str:
|
||||
"""
|
||||
Remove all specified substrings from the text.
|
||||
|
||||
:param text: Text to clean.
|
||||
:param substrings: Substrings to remove.
|
||||
:returns: The text without the specified substrings.
|
||||
"""
|
||||
for substring in substrings:
|
||||
text = text.replace(substring, "")
|
||||
return text
|
||||
|
||||
def _remove_repeated_substrings(self, text: str) -> str:
|
||||
"""
|
||||
Remove any substrings from the text that occur repeatedly on every page. For example headers or footers.
|
||||
|
||||
Pages in the text need to be separated by form feed character "\f".
|
||||
:param text: Text to clean.
|
||||
:returns: The text without the repeated substrings.
|
||||
"""
|
||||
return self._find_and_remove_header_footer(
|
||||
text, n_chars=300, n_first_pages_to_ignore=1, n_last_pages_to_ignore=1
|
||||
)
|
||||
|
||||
def _find_and_remove_header_footer(
|
||||
self, text: str, n_chars: int, n_first_pages_to_ignore: int, n_last_pages_to_ignore: int
|
||||
) -> str:
|
||||
"""
|
||||
Heuristic to find footers and headers across different pages by searching for the longest common string.
|
||||
|
||||
Pages in the text need to be separated by form feed character "\f".
|
||||
For headers, we only search in the first n_chars characters (for footer: last n_chars).
|
||||
Note: This heuristic uses exact matches and therefore works well for footers like "Copyright 2019 by XXX",
|
||||
but won't detect "Page 3 of 4" or similar.
|
||||
|
||||
:param n_chars: The number of first/last characters where the header/footer shall be searched in.
|
||||
:param n_first_pages_to_ignore: The number of first pages to ignore
|
||||
(e.g. TOCs often don't contain footer/header).
|
||||
:param n_last_pages_to_ignore: The number of last pages to ignore.
|
||||
:returns: The text without the found headers and footers.
|
||||
"""
|
||||
|
||||
pages = text.split("\f")
|
||||
|
||||
# header
|
||||
start_of_pages = [p[:n_chars] for p in pages[n_first_pages_to_ignore:-n_last_pages_to_ignore]]
|
||||
found_header = self._find_longest_common_ngram(start_of_pages)
|
||||
if found_header:
|
||||
pages = [page.replace(found_header, "") for page in pages]
|
||||
|
||||
# footer
|
||||
end_of_pages = [p[-n_chars:] for p in pages[n_first_pages_to_ignore:-n_last_pages_to_ignore]]
|
||||
found_footer = self._find_longest_common_ngram(end_of_pages)
|
||||
if found_footer:
|
||||
pages = [page.replace(found_footer, "") for page in pages]
|
||||
|
||||
logger.debug(
|
||||
"Removed header '{header}' and footer '{footer}' in document", header=found_header, footer=found_footer
|
||||
)
|
||||
return "\f".join(pages)
|
||||
|
||||
def _ngram(self, seq: str, n: int) -> Generator[str, None, None]:
|
||||
"""
|
||||
Return all ngrams of length n from a text sequence. Each ngram consists of n words split by whitespace.
|
||||
|
||||
:param seq: The sequence to generate ngrams from.
|
||||
:param n: The length of the ngrams to generate.
|
||||
:returns: A Generator generating all ngrams of length n from the given sequence.
|
||||
"""
|
||||
|
||||
# In order to maintain the original whitespace, but still consider \n and \t for n-gram tokenization,
|
||||
# we add a space here and remove it after creation of the ngrams again (see below)
|
||||
seq = seq.replace("\n", " \n")
|
||||
seq = seq.replace("\t", " \t")
|
||||
|
||||
words = seq.split(" ")
|
||||
return (" ".join(words[i : i + n]).replace(" \n", "\n").replace(" \t", "\t") for i in range(len(words) - n + 1))
|
||||
|
||||
def _allngram(self, seq: str, min_ngram: int, max_ngram: int) -> set[str]:
|
||||
"""
|
||||
Generates all possible ngrams from a given sequence of text.
|
||||
|
||||
Considering all ngram lengths between the minimum and maximum length.
|
||||
|
||||
:param seq: The sequence to generate ngrams from.
|
||||
:param min_ngram: The minimum length of ngram to consider.
|
||||
:param max_ngram: The maximum length of ngram to consider.
|
||||
:returns: A set of all ngrams from the given sequence.
|
||||
"""
|
||||
lengths = range(min_ngram, max_ngram) if max_ngram else range(min_ngram, len(seq))
|
||||
ngrams = map(partial(self._ngram, seq), lengths)
|
||||
return set(chain.from_iterable(ngrams))
|
||||
|
||||
def _find_longest_common_ngram(self, sequences: list[str], min_ngram: int = 3, max_ngram: int = 30) -> str:
|
||||
"""
|
||||
Find the longest common ngram across a list of text sequences (e.g. start of pages).
|
||||
|
||||
Considering all ngram lengths between the minimum and maximum length. Helpful for finding footers, headers etc.
|
||||
Empty sequences are ignored.
|
||||
|
||||
:param sequences: The list of strings that shall be searched for common n_grams.
|
||||
:param max_ngram: The maximum length of ngram to consider.
|
||||
:param min_ngram: The minimum length of ngram to consider.
|
||||
:returns: The longest ngram that all sequences have in common.
|
||||
"""
|
||||
sequences = [s for s in sequences if s] # filter empty sequences
|
||||
if len(sequences) < 2:
|
||||
# a single sequence has no ngram "in common" with any other; treating
|
||||
# its own longest ngram as a repeated header/footer would wipe it
|
||||
return ""
|
||||
seqs_ngrams = map(partial(self._allngram, min_ngram=min_ngram, max_ngram=max_ngram), sequences)
|
||||
intersection = reduce(set.intersection, seqs_ngrams)
|
||||
|
||||
longest = max(intersection, key=len, default="")
|
||||
return longest if longest.strip() else ""
|
||||
@@ -0,0 +1,198 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from haystack import Document, Pipeline, default_from_dict, default_to_dict, super_component
|
||||
from haystack.components.preprocessors.document_cleaner import DocumentCleaner
|
||||
from haystack.components.preprocessors.document_splitter import DocumentSplitter, Language
|
||||
from haystack.utils import deserialize_callable, serialize_callable
|
||||
|
||||
|
||||
@super_component
|
||||
class DocumentPreprocessor:
|
||||
"""
|
||||
A SuperComponent that first splits and then cleans documents.
|
||||
|
||||
This component consists of a DocumentSplitter followed by a DocumentCleaner in a single pipeline.
|
||||
It takes a list of documents as input and returns a processed list of documents.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import DocumentPreprocessor
|
||||
|
||||
doc = Document(content="I love pizza!")
|
||||
preprocessor = DocumentPreprocessor()
|
||||
result = preprocessor.run(documents=[doc])
|
||||
print(result["documents"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913 (too-many-arguments)
|
||||
self,
|
||||
*,
|
||||
# --- DocumentSplitter arguments ---
|
||||
split_by: Literal["function", "page", "passage", "period", "word", "line", "sentence"] = "word",
|
||||
split_length: int = 250,
|
||||
split_overlap: int = 0,
|
||||
split_threshold: int = 0,
|
||||
splitting_function: Callable[[str], list[str]] | None = None,
|
||||
respect_sentence_boundary: bool = False,
|
||||
language: Language = "en",
|
||||
use_split_rules: bool = True,
|
||||
extend_abbreviations: bool = True,
|
||||
# --- DocumentCleaner arguments ---
|
||||
remove_empty_lines: bool = True,
|
||||
remove_extra_whitespaces: bool = True,
|
||||
remove_repeated_substrings: bool = False,
|
||||
keep_id: bool = False,
|
||||
remove_substrings: list[str] | None = None,
|
||||
remove_regex: str | None = None,
|
||||
unicode_normalization: Literal["NFC", "NFKC", "NFD", "NFKD"] | None = None,
|
||||
ascii_only: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a DocumentPreProcessor that first splits and then cleans documents.
|
||||
|
||||
**Splitter Parameters**:
|
||||
:param split_by: The unit of splitting: "function", "page", "passage", "period", "word", "line", or "sentence".
|
||||
:param split_length: The maximum number of units (words, lines, pages, and so on) in each split.
|
||||
:param split_overlap: The number of overlapping units between consecutive splits.
|
||||
:param split_threshold: The minimum number of units per split. If a split is smaller than this, it's merged
|
||||
with the previous split.
|
||||
:param splitting_function: A custom function for splitting if `split_by="function"`.
|
||||
:param respect_sentence_boundary: If `True`, splits by words but tries not to break inside a sentence.
|
||||
:param language: Language used by the sentence tokenizer if `split_by="sentence"` or
|
||||
`respect_sentence_boundary=True`.
|
||||
:param use_split_rules: Whether to apply additional splitting heuristics for the sentence splitter.
|
||||
:param extend_abbreviations: Whether to extend the sentence splitter with curated abbreviations for certain
|
||||
languages.
|
||||
|
||||
**Cleaner Parameters**:
|
||||
:param remove_empty_lines: If `True`, removes empty lines.
|
||||
:param remove_extra_whitespaces: If `True`, removes extra whitespaces.
|
||||
:param remove_repeated_substrings: If `True`, removes repeated substrings like headers/footers across pages.
|
||||
:param keep_id: If `True`, keeps the original document IDs.
|
||||
:param remove_substrings: A list of strings to remove from the document content.
|
||||
:param remove_regex: A regex pattern whose matches will be removed from the document content.
|
||||
:param unicode_normalization: Unicode normalization form to apply to the text, for example `"NFC"`.
|
||||
:param ascii_only: If `True`, converts text to ASCII only.
|
||||
"""
|
||||
# Store arguments for serialization
|
||||
self.remove_empty_lines = remove_empty_lines
|
||||
self.remove_extra_whitespaces = remove_extra_whitespaces
|
||||
self.remove_repeated_substrings = remove_repeated_substrings
|
||||
self.keep_id = keep_id
|
||||
self.remove_substrings = remove_substrings
|
||||
self.remove_regex = remove_regex
|
||||
self.unicode_normalization = unicode_normalization
|
||||
self.ascii_only = ascii_only
|
||||
|
||||
self.split_by = split_by
|
||||
self.split_length = split_length
|
||||
self.split_overlap = split_overlap
|
||||
self.split_threshold = split_threshold
|
||||
self.splitting_function = splitting_function
|
||||
self.respect_sentence_boundary = respect_sentence_boundary
|
||||
self.language = language
|
||||
self.use_split_rules = use_split_rules
|
||||
self.extend_abbreviations = extend_abbreviations
|
||||
|
||||
# Instantiate sub-components
|
||||
splitter = DocumentSplitter(
|
||||
split_by=self.split_by,
|
||||
split_length=self.split_length,
|
||||
split_overlap=self.split_overlap,
|
||||
split_threshold=self.split_threshold,
|
||||
splitting_function=self.splitting_function,
|
||||
respect_sentence_boundary=self.respect_sentence_boundary,
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
)
|
||||
|
||||
cleaner = DocumentCleaner(
|
||||
remove_empty_lines=self.remove_empty_lines,
|
||||
remove_extra_whitespaces=self.remove_extra_whitespaces,
|
||||
remove_repeated_substrings=self.remove_repeated_substrings,
|
||||
keep_id=self.keep_id,
|
||||
remove_substrings=self.remove_substrings,
|
||||
remove_regex=self.remove_regex,
|
||||
unicode_normalization=self.unicode_normalization,
|
||||
ascii_only=self.ascii_only,
|
||||
)
|
||||
|
||||
# Build the Pipeline
|
||||
pp = Pipeline()
|
||||
|
||||
pp.add_component("splitter", splitter)
|
||||
pp.add_component("cleaner", cleaner)
|
||||
|
||||
# Connect the splitter output to cleaner
|
||||
pp.connect("splitter.documents", "cleaner.documents")
|
||||
self.pipeline = pp
|
||||
|
||||
# Define how pipeline inputs/outputs map to sub-component inputs/outputs
|
||||
self.input_mapping = {
|
||||
# The pipeline input "documents" feeds into "splitter.documents"
|
||||
"documents": ["splitter.documents"]
|
||||
}
|
||||
# The pipeline output "documents" comes from "cleaner.documents"
|
||||
self.output_mapping = {"cleaner.documents": "documents"}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# fake method, never executed, but static analyzers will not complain about missing method
|
||||
def run(self, *, documents: list[Document]) -> dict[str, list[Document]]: # noqa: D102
|
||||
...
|
||||
def warm_up(self) -> None: # noqa: D102
|
||||
...
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize SuperComponent to a dictionary.
|
||||
|
||||
:return:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
splitting_function = None
|
||||
if self.splitting_function is not None:
|
||||
splitting_function = serialize_callable(self.splitting_function)
|
||||
|
||||
return default_to_dict(
|
||||
self,
|
||||
remove_empty_lines=self.remove_empty_lines,
|
||||
remove_extra_whitespaces=self.remove_extra_whitespaces,
|
||||
remove_repeated_substrings=self.remove_repeated_substrings,
|
||||
keep_id=self.keep_id,
|
||||
remove_substrings=self.remove_substrings,
|
||||
remove_regex=self.remove_regex,
|
||||
unicode_normalization=self.unicode_normalization,
|
||||
ascii_only=self.ascii_only,
|
||||
split_by=self.split_by,
|
||||
split_length=self.split_length,
|
||||
split_overlap=self.split_overlap,
|
||||
split_threshold=self.split_threshold,
|
||||
splitting_function=splitting_function,
|
||||
respect_sentence_boundary=self.respect_sentence_boundary,
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "DocumentPreprocessor":
|
||||
"""
|
||||
Deserializes the SuperComponent from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized SuperComponent.
|
||||
"""
|
||||
splitting_function = data["init_parameters"].get("splitting_function", None)
|
||||
if splitting_function:
|
||||
data["init_parameters"]["splitting_function"] = deserialize_callable(splitting_function)
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,499 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from typing import Any, Literal
|
||||
|
||||
from more_itertools import windowed
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.preprocessors.sentence_tokenizer import Language, SentenceSplitter, nltk_imports
|
||||
from haystack.core.serialization import default_from_dict, default_to_dict
|
||||
from haystack.utils import deserialize_callable, serialize_callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# mapping of split by character, 'function' and 'sentence' don't split by character
|
||||
_CHARACTER_SPLIT_BY_MAPPING = {"page": "\f", "passage": "\n\n", "period": ".", "word": " ", "line": "\n"}
|
||||
|
||||
|
||||
@component
|
||||
class DocumentSplitter:
|
||||
"""
|
||||
Splits long documents into smaller chunks.
|
||||
|
||||
This is a common preprocessing step during indexing. It helps Embedders create meaningful semantic representations
|
||||
and prevents exceeding language model context limits.
|
||||
|
||||
The DocumentSplitter is compatible with the following DocumentStores:
|
||||
- [Astra](https://docs.haystack.deepset.ai/docs/astradocumentstore)
|
||||
- [Chroma](https://docs.haystack.deepset.ai/docs/chromadocumentstore) limited support, overlapping information is
|
||||
not stored
|
||||
- [Elasticsearch](https://docs.haystack.deepset.ai/docs/elasticsearch-document-store)
|
||||
- [OpenSearch](https://docs.haystack.deepset.ai/docs/opensearch-document-store)
|
||||
- [Pgvector](https://docs.haystack.deepset.ai/docs/pgvectordocumentstore)
|
||||
- [Pinecone](https://docs.haystack.deepset.ai/docs/pinecone-document-store) limited support, overlapping
|
||||
information is not stored
|
||||
- [Qdrant](https://docs.haystack.deepset.ai/docs/qdrant-document-store)
|
||||
- [Weaviate](https://docs.haystack.deepset.ai/docs/weaviatedocumentstore)
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
|
||||
doc = Document(content="Moonlight shimmered softly, wolves howled nearby, night enveloped everything.")
|
||||
|
||||
splitter = DocumentSplitter(split_by="word", split_length=3, split_overlap=0)
|
||||
result = splitter.run(documents=[doc])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
split_by: Literal["function", "page", "passage", "period", "word", "line", "sentence"] = "word",
|
||||
split_length: int = 200,
|
||||
split_overlap: int = 0,
|
||||
split_threshold: int = 0,
|
||||
splitting_function: Callable[[str], list[str]] | None = None,
|
||||
respect_sentence_boundary: bool = False,
|
||||
language: Language = "en",
|
||||
use_split_rules: bool = True,
|
||||
extend_abbreviations: bool = True,
|
||||
*,
|
||||
skip_empty_documents: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize DocumentSplitter.
|
||||
|
||||
:param split_by: The unit for splitting your documents. Choose from:
|
||||
- `word` for splitting by spaces (" ")
|
||||
- `period` for splitting by periods (".")
|
||||
- `page` for splitting by form feed ("\\f")
|
||||
- `passage` for splitting by double line breaks ("\\n\\n")
|
||||
- `line` for splitting each line ("\\n")
|
||||
- `sentence` for splitting by NLTK sentence tokenizer
|
||||
|
||||
:param split_length: The maximum number of units in each split.
|
||||
:param split_overlap: The number of overlapping units for each split.
|
||||
:param split_threshold: The minimum number of units per split. If a split has fewer units
|
||||
than the threshold, it's attached to the previous split.
|
||||
:param splitting_function: Necessary when `split_by` is set to "function".
|
||||
This is a function which must accept a single `str` as input and return a `list` of `str` as output,
|
||||
representing the chunks after splitting.
|
||||
:param respect_sentence_boundary: Choose whether to respect sentence boundaries when splitting by "word".
|
||||
If True, uses NLTK to detect sentence boundaries, ensuring splits occur only between sentences.
|
||||
:param language: Choose the language for the NLTK tokenizer. The default is English ("en").
|
||||
:param use_split_rules: Choose whether to use additional split rules when splitting by `sentence`.
|
||||
:param extend_abbreviations: Choose whether to extend NLTK's PunktTokenizer abbreviations with a list
|
||||
of curated abbreviations, if available. This is currently supported for English ("en") and German ("de").
|
||||
:param skip_empty_documents: Choose whether to skip documents with empty content. Default is True.
|
||||
Set to False when downstream components in the Pipeline (like LLMDocumentContentExtractor) can extract text
|
||||
from non-textual documents.
|
||||
"""
|
||||
|
||||
self.split_by = split_by
|
||||
self.split_length = split_length
|
||||
self.split_overlap = split_overlap
|
||||
self.split_threshold = split_threshold
|
||||
self.splitting_function = splitting_function
|
||||
self.respect_sentence_boundary = respect_sentence_boundary
|
||||
self.language = language
|
||||
self.use_split_rules = use_split_rules
|
||||
self.extend_abbreviations = extend_abbreviations
|
||||
self.skip_empty_documents = skip_empty_documents
|
||||
|
||||
self._init_checks(
|
||||
split_by=split_by,
|
||||
split_length=split_length,
|
||||
split_overlap=split_overlap,
|
||||
splitting_function=splitting_function,
|
||||
respect_sentence_boundary=respect_sentence_boundary,
|
||||
)
|
||||
self._use_sentence_splitter = split_by == "sentence" or (respect_sentence_boundary and split_by == "word")
|
||||
if self._use_sentence_splitter:
|
||||
nltk_imports.check()
|
||||
self.sentence_splitter: SentenceSplitter | None = None
|
||||
|
||||
def _init_checks(
|
||||
self,
|
||||
*,
|
||||
split_by: str,
|
||||
split_length: int,
|
||||
split_overlap: int,
|
||||
splitting_function: Callable | None,
|
||||
respect_sentence_boundary: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Validates initialization parameters for DocumentSplitter.
|
||||
|
||||
:param split_by: The unit for splitting documents
|
||||
:param split_length: The maximum number of units in each split
|
||||
:param split_overlap: The number of overlapping units for each split
|
||||
:param splitting_function: Custom function for splitting when split_by="function"
|
||||
:param respect_sentence_boundary: Whether to respect sentence boundaries when splitting
|
||||
:raises ValueError: If any parameter is invalid
|
||||
"""
|
||||
valid_split_by = ["function", "page", "passage", "period", "word", "line", "sentence"]
|
||||
if split_by not in valid_split_by:
|
||||
raise ValueError(f"split_by must be one of {', '.join(valid_split_by)}.")
|
||||
|
||||
if split_by == "function" and splitting_function is None:
|
||||
raise ValueError("When 'split_by' is set to 'function', a valid 'splitting_function' must be provided.")
|
||||
|
||||
if split_length <= 0:
|
||||
raise ValueError("split_length must be greater than 0.")
|
||||
|
||||
if split_overlap < 0:
|
||||
raise ValueError("split_overlap must be greater than or equal to 0.")
|
||||
|
||||
if split_overlap >= split_length:
|
||||
raise ValueError("split_overlap must be less than split_length.")
|
||||
|
||||
if respect_sentence_boundary and split_by != "word":
|
||||
logger.warning(
|
||||
"The 'respect_sentence_boundary' option is only supported for `split_by='word'`. "
|
||||
"The option `respect_sentence_boundary` will be set to `False`."
|
||||
)
|
||||
self.respect_sentence_boundary = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the DocumentSplitter by loading the sentence tokenizer.
|
||||
"""
|
||||
if self._use_sentence_splitter and self.sentence_splitter is None:
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Split documents into smaller parts.
|
||||
|
||||
Splits documents by the unit expressed in `split_by`, with a length of `split_length`
|
||||
and an overlap of `split_overlap`.
|
||||
|
||||
:param documents: The documents to split.
|
||||
:returns: A dictionary with the following key:
|
||||
- `documents`: List of documents with the split texts. Each document includes:
|
||||
- A metadata field `source_id` to track the original document.
|
||||
- A metadata field `page_number` to track the original page number.
|
||||
- All other metadata copied from the original document.
|
||||
|
||||
:raises TypeError: if the input is not a list of Documents.
|
||||
:raises ValueError: if the content of a document is None.
|
||||
"""
|
||||
if self._use_sentence_splitter and self.sentence_splitter is None:
|
||||
self.warm_up()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError("DocumentSplitter expects a List of Documents as input.")
|
||||
|
||||
split_docs: list[Document] = []
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
raise ValueError(
|
||||
f"DocumentSplitter only works with text documents but content for document ID {doc.id} is None."
|
||||
)
|
||||
if doc.content == "" and self.skip_empty_documents:
|
||||
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
|
||||
split_docs += self._split_document(doc)
|
||||
return {"documents": split_docs}
|
||||
|
||||
def _split_document(self, doc: Document) -> list[Document]:
|
||||
if self.split_by == "sentence" or self.respect_sentence_boundary:
|
||||
return self._split_by_nltk_sentence(doc)
|
||||
|
||||
if self.split_by == "function" and self.splitting_function is not None:
|
||||
return self._split_by_function(doc)
|
||||
|
||||
return self._split_by_character(doc)
|
||||
|
||||
def _split_by_nltk_sentence(self, doc: Document) -> list[Document]:
|
||||
split_docs = []
|
||||
|
||||
result = self.sentence_splitter.split_sentences(doc.content) # type: ignore # None check is done in run()
|
||||
units = [sentence["sentence"] for sentence in result]
|
||||
|
||||
if self.respect_sentence_boundary:
|
||||
text_splits, splits_pages, splits_start_idxs = self._concatenate_sentences_based_on_word_amount(
|
||||
sentences=units, split_length=self.split_length, split_overlap=self.split_overlap
|
||||
)
|
||||
else:
|
||||
text_splits, splits_pages, splits_start_idxs = self._concatenate_units(
|
||||
elements=units,
|
||||
split_length=self.split_length,
|
||||
split_overlap=self.split_overlap,
|
||||
split_threshold=self.split_threshold,
|
||||
)
|
||||
metadata = deepcopy(doc.meta)
|
||||
metadata["source_id"] = doc.id
|
||||
split_docs += self._create_docs_from_splits(
|
||||
text_splits=text_splits, splits_pages=splits_pages, splits_start_idxs=splits_start_idxs, meta=metadata
|
||||
)
|
||||
|
||||
return split_docs
|
||||
|
||||
def _split_by_character(self, doc: Document) -> list[Document]:
|
||||
split_at = _CHARACTER_SPLIT_BY_MAPPING[self.split_by]
|
||||
units = doc.content.split(split_at) # type: ignore[union-attr]
|
||||
# Add the delimiter back to all units except the last one
|
||||
for i in range(len(units) - 1):
|
||||
units[i] += split_at
|
||||
text_splits, splits_pages, splits_start_idxs = self._concatenate_units(
|
||||
units, self.split_length, self.split_overlap, self.split_threshold
|
||||
)
|
||||
metadata = deepcopy(doc.meta)
|
||||
metadata["source_id"] = doc.id
|
||||
return self._create_docs_from_splits(
|
||||
text_splits=text_splits, splits_pages=splits_pages, splits_start_idxs=splits_start_idxs, meta=metadata
|
||||
)
|
||||
|
||||
def _split_by_function(self, doc: Document) -> list[Document]:
|
||||
# the check for None is done already in the run method
|
||||
splits = self.splitting_function(doc.content) # type: ignore
|
||||
docs: list[Document] = []
|
||||
for s in splits:
|
||||
meta = deepcopy(doc.meta)
|
||||
meta["source_id"] = doc.id
|
||||
docs.append(Document(content=s, meta=meta))
|
||||
return docs
|
||||
|
||||
def _concatenate_units(
|
||||
self, elements: list[str], split_length: int, split_overlap: int, split_threshold: int
|
||||
) -> tuple[list[str], list[int], list[int]]:
|
||||
"""
|
||||
Concatenates the elements into parts of split_length units.
|
||||
|
||||
Keeps track of the original page number that each element belongs. If the length of the current units is less
|
||||
than the pre-defined `split_threshold`, it does not create a new split. Instead, it concatenates the current
|
||||
units with the last split, preventing the creation of excessively small splits.
|
||||
"""
|
||||
|
||||
text_splits: list[str] = []
|
||||
splits_pages: list[int] = []
|
||||
splits_start_idxs: list[int] = []
|
||||
cur_start_idx = 0
|
||||
cur_page = 1
|
||||
segments = windowed(elements, n=split_length, step=split_length - split_overlap)
|
||||
|
||||
for seg in segments:
|
||||
current_units = [unit for unit in seg if unit is not None]
|
||||
txt = "".join(current_units)
|
||||
|
||||
# check if length of current units is below split_threshold
|
||||
if len(current_units) < split_threshold and len(text_splits) > 0:
|
||||
# concatenate the last split with the current one
|
||||
text_splits[-1] += txt
|
||||
|
||||
# NOTE: If skip_empty_documents is True, this line skips documents that have content=""
|
||||
elif not self.skip_empty_documents or len(txt) > 0:
|
||||
text_splits.append(txt)
|
||||
splits_pages.append(cur_page)
|
||||
splits_start_idxs.append(cur_start_idx)
|
||||
|
||||
processed_units = current_units[: split_length - split_overlap]
|
||||
cur_start_idx += len("".join(processed_units))
|
||||
|
||||
if self.split_by == "page":
|
||||
num_page_breaks = len(processed_units)
|
||||
else:
|
||||
num_page_breaks = sum(processed_unit.count("\f") for processed_unit in processed_units)
|
||||
|
||||
cur_page += num_page_breaks
|
||||
|
||||
return text_splits, splits_pages, splits_start_idxs
|
||||
|
||||
def _create_docs_from_splits(
|
||||
self, text_splits: list[str], splits_pages: list[int], splits_start_idxs: list[int], meta: dict[str, Any]
|
||||
) -> list[Document]:
|
||||
"""
|
||||
Creates Document objects from splits enriching them with page number and the metadata of the original document.
|
||||
"""
|
||||
documents: list[Document] = []
|
||||
|
||||
for i, (txt, split_idx) in enumerate(zip(text_splits, splits_start_idxs, strict=True)):
|
||||
copied_meta = deepcopy(meta)
|
||||
copied_meta["page_number"] = splits_pages[i]
|
||||
copied_meta["split_id"] = i
|
||||
copied_meta["split_idx_start"] = split_idx
|
||||
doc = Document(content=txt, meta=copied_meta)
|
||||
documents.append(doc)
|
||||
|
||||
if self.split_overlap <= 0:
|
||||
continue
|
||||
|
||||
doc.meta["_split_overlap"] = []
|
||||
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
doc_start_idx = splits_start_idxs[i]
|
||||
previous_doc = documents[i - 1]
|
||||
previous_doc_start_idx = splits_start_idxs[i - 1]
|
||||
self._add_split_overlap_information(doc, doc_start_idx, previous_doc, previous_doc_start_idx)
|
||||
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def _add_split_overlap_information(
|
||||
current_doc: Document, current_doc_start_idx: int, previous_doc: Document, previous_doc_start_idx: int
|
||||
) -> None:
|
||||
"""
|
||||
Adds split overlap information to the current and previous Document's meta.
|
||||
|
||||
:param current_doc: The Document that is being split.
|
||||
:param current_doc_start_idx: The starting index of the current Document.
|
||||
:param previous_doc: The Document that was split before the current Document.
|
||||
:param previous_doc_start_idx: The starting index of the previous Document.
|
||||
"""
|
||||
overlapping_range = (current_doc_start_idx - previous_doc_start_idx, len(previous_doc.content)) # type: ignore
|
||||
|
||||
if overlapping_range[0] < overlapping_range[1]:
|
||||
overlapping_str = previous_doc.content[overlapping_range[0] : overlapping_range[1]] # type: ignore
|
||||
|
||||
if current_doc.content.startswith(overlapping_str): # type: ignore
|
||||
# add split overlap information to this Document regarding the previous Document
|
||||
current_doc.meta["_split_overlap"].append({"doc_id": previous_doc.id, "range": overlapping_range})
|
||||
|
||||
# add split overlap information to previous Document regarding this Document
|
||||
overlapping_range = (0, overlapping_range[1] - overlapping_range[0])
|
||||
previous_doc.meta["_split_overlap"].append({"doc_id": current_doc.id, "range": overlapping_range})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
"""
|
||||
serialized = default_to_dict(
|
||||
self,
|
||||
split_by=self.split_by,
|
||||
split_length=self.split_length,
|
||||
split_overlap=self.split_overlap,
|
||||
split_threshold=self.split_threshold,
|
||||
respect_sentence_boundary=self.respect_sentence_boundary,
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
skip_empty_documents=self.skip_empty_documents,
|
||||
)
|
||||
if self.splitting_function:
|
||||
serialized["init_parameters"]["splitting_function"] = serialize_callable(self.splitting_function)
|
||||
return serialized
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "DocumentSplitter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
|
||||
splitting_function = init_params.get("splitting_function", None)
|
||||
if splitting_function:
|
||||
init_params["splitting_function"] = deserialize_callable(splitting_function)
|
||||
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@staticmethod
|
||||
def _concatenate_sentences_based_on_word_amount(
|
||||
sentences: list[str], split_length: int, split_overlap: int
|
||||
) -> tuple[list[str], list[int], list[int]]:
|
||||
"""
|
||||
Groups the sentences into chunks of `split_length` words while respecting sentence boundaries.
|
||||
|
||||
This function is only used when splitting by `word` and `respect_sentence_boundary` is set to `True`, i.e.:
|
||||
with NLTK sentence tokenizer.
|
||||
|
||||
:param sentences: The list of sentences to split.
|
||||
:param split_length: The maximum number of words in each split.
|
||||
:param split_overlap: The number of overlapping words in each split.
|
||||
:returns: A tuple containing the concatenated sentences, the start page numbers, and the start indices.
|
||||
"""
|
||||
# chunk information
|
||||
chunk_word_count = 0
|
||||
chunk_starting_page_number = 1
|
||||
chunk_start_idx = 0
|
||||
current_chunk: list[str] = []
|
||||
# output lists
|
||||
split_start_page_numbers = []
|
||||
list_of_splits: list[list[str]] = []
|
||||
split_start_indices = []
|
||||
|
||||
for sentence_idx, sentence in enumerate(sentences):
|
||||
current_chunk.append(sentence)
|
||||
chunk_word_count += len(sentence.split())
|
||||
next_sentence_word_count = (
|
||||
len(sentences[sentence_idx + 1].split()) if sentence_idx < len(sentences) - 1 else 0
|
||||
)
|
||||
|
||||
# Number of words in the current chunk plus the next sentence is larger than the split_length,
|
||||
# or we reached the last sentence
|
||||
if (chunk_word_count + next_sentence_word_count) > split_length or sentence_idx == len(sentences) - 1:
|
||||
# Save current chunk and start a new one
|
||||
list_of_splits.append(current_chunk)
|
||||
split_start_page_numbers.append(chunk_starting_page_number)
|
||||
split_start_indices.append(chunk_start_idx)
|
||||
|
||||
# Get the number of sentences that overlap with the next chunk
|
||||
num_sentences_to_keep = DocumentSplitter._number_of_sentences_to_keep(
|
||||
sentences=current_chunk, split_length=split_length, split_overlap=split_overlap
|
||||
)
|
||||
# Set up information for the new chunk
|
||||
if num_sentences_to_keep > 0:
|
||||
# Processed sentences are the ones that are not overlapping with the next chunk
|
||||
processed_sentences = current_chunk[:-num_sentences_to_keep]
|
||||
chunk_starting_page_number += sum(sent.count("\f") for sent in processed_sentences)
|
||||
chunk_start_idx += len("".join(processed_sentences))
|
||||
# Next chunk starts with the sentences that were overlapping with the previous chunk
|
||||
current_chunk = current_chunk[-num_sentences_to_keep:]
|
||||
chunk_word_count = sum(len(s.split()) for s in current_chunk)
|
||||
else:
|
||||
# Here processed_sentences is the same as current_chunk since there is no overlap
|
||||
chunk_starting_page_number += sum(sent.count("\f") for sent in current_chunk)
|
||||
chunk_start_idx += len("".join(current_chunk))
|
||||
current_chunk = []
|
||||
chunk_word_count = 0
|
||||
|
||||
# Concatenate the sentences together within each split
|
||||
text_splits = []
|
||||
for split in list_of_splits:
|
||||
text = "".join(split)
|
||||
if len(text) > 0:
|
||||
text_splits.append(text)
|
||||
|
||||
return text_splits, split_start_page_numbers, split_start_indices
|
||||
|
||||
@staticmethod
|
||||
def _number_of_sentences_to_keep(sentences: list[str], split_length: int, split_overlap: int) -> int:
|
||||
"""
|
||||
Returns the number of sentences to keep in the next chunk based on the `split_overlap` and `split_length`.
|
||||
|
||||
:param sentences: The list of sentences to split.
|
||||
:param split_length: The maximum number of words in each split.
|
||||
:param split_overlap: The number of overlapping words in each split.
|
||||
:returns: The number of sentences to keep in the next chunk.
|
||||
"""
|
||||
# If the split_overlap is 0, we don't need to keep any sentences
|
||||
if split_overlap == 0:
|
||||
return 0
|
||||
|
||||
num_sentences_to_keep = 0
|
||||
num_words = 0
|
||||
# Next overlapping Document should not start exactly the same as the previous one, so we skip the first sentence
|
||||
for sent in reversed(sentences[1:]):
|
||||
num_words += len(sent.split())
|
||||
# If the number of words is larger than the split_length then don't add any more sentences
|
||||
if num_words > split_length:
|
||||
break
|
||||
num_sentences_to_keep += 1
|
||||
if num_words > split_overlap:
|
||||
break
|
||||
return num_sentences_to_keep
|
||||
@@ -0,0 +1,545 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from asyncio import gather
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.embedders.types import DocumentEmbedder
|
||||
from haystack.components.preprocessors.sentence_tokenizer import Language, SentenceSplitter
|
||||
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.deserialization import deserialize_component_inplace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class EmbeddingBasedDocumentSplitter:
|
||||
"""
|
||||
Splits documents based on embedding similarity using cosine distances between sequential sentence groups.
|
||||
|
||||
This component first splits text into sentences, optionally groups them, calculates embeddings for each group,
|
||||
and then uses cosine distance between sequential embeddings to determine split points. Any distance above
|
||||
the specified percentile is treated as a break point. The component also tracks page numbers based on form feed
|
||||
characters (`\f`) in the original document.
|
||||
|
||||
This component is inspired by [5 Levels of Text Splitting](
|
||||
https://github.com/FullStackRetrieval-com/RetrievalTutorials/blob/main/tutorials/LevelsOfTextSplitting/5_Levels_Of_Text_Splitting.ipynb
|
||||
) by Greg Kamradt.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import OpenAIDocumentEmbedder
|
||||
from haystack.components.preprocessors import EmbeddingBasedDocumentSplitter
|
||||
|
||||
# Create a document with content that has a clear topic shift
|
||||
doc = Document(
|
||||
content="This is a first sentence. This is a second sentence. This is a third sentence. "
|
||||
"Completely different topic. The same completely different topic."
|
||||
)
|
||||
|
||||
# Initialize the embedder to calculate semantic similarities
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
|
||||
# Configure the splitter with parameters that control splitting behavior
|
||||
splitter = EmbeddingBasedDocumentSplitter(
|
||||
document_embedder=embedder,
|
||||
sentences_per_group=2, # Group 2 sentences before calculating embeddings
|
||||
percentile=0.95, # Split when cosine distance exceeds 95th percentile
|
||||
min_length=50, # Merge splits shorter than 50 characters
|
||||
max_length=1000 # Further split chunks longer than 1000 characters
|
||||
)
|
||||
result = splitter.run(documents=[doc])
|
||||
|
||||
# The result contains a list of Document objects, each representing a semantic chunk
|
||||
# Each split document includes metadata: source_id, split_id, and page_number
|
||||
print(f"Original document split into {len(result['documents'])} chunks")
|
||||
for i, split_doc in enumerate(result['documents']):
|
||||
print(f"Chunk {i}: {split_doc.content[:50]}...")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
document_embedder: DocumentEmbedder,
|
||||
sentences_per_group: int = 3,
|
||||
percentile: float = 0.95,
|
||||
min_length: int = 50,
|
||||
max_length: int = 1000,
|
||||
language: Language = "en",
|
||||
use_split_rules: bool = True,
|
||||
extend_abbreviations: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize EmbeddingBasedDocumentSplitter.
|
||||
|
||||
:param document_embedder: The DocumentEmbedder to use for calculating embeddings.
|
||||
:param sentences_per_group: Number of sentences to group together before embedding.
|
||||
:param percentile: Percentile threshold for cosine distance. Distances above this percentile
|
||||
are treated as break points.
|
||||
:param min_length: Minimum length of splits in characters. Splits below this length will be merged.
|
||||
:param max_length: Maximum length of splits in characters. Splits above this length will be recursively split.
|
||||
:param language: Language for sentence tokenization.
|
||||
:param use_split_rules: Whether to use additional split rules for sentence tokenization. Applies additional
|
||||
split rules from SentenceSplitter to the sentence spans.
|
||||
:param extend_abbreviations: If True, the abbreviations used by NLTK's PunktTokenizer are extended by a list
|
||||
of curated abbreviations. Currently supported languages are: en, de.
|
||||
If False, the default abbreviations are used.
|
||||
"""
|
||||
self.document_embedder = document_embedder
|
||||
|
||||
if sentences_per_group <= 0:
|
||||
raise ValueError("sentences_per_group must be greater than 0.")
|
||||
self.sentences_per_group = sentences_per_group
|
||||
|
||||
if not 0.0 <= percentile <= 1.0:
|
||||
raise ValueError("percentile must be between 0.0 and 1.0.")
|
||||
self.percentile = percentile
|
||||
|
||||
if min_length < 0:
|
||||
raise ValueError("min_length must be greater than or equal to 0.")
|
||||
self.min_length = min_length
|
||||
|
||||
if max_length <= min_length:
|
||||
raise ValueError("max_length must be greater than min_length.")
|
||||
self.max_length = max_length
|
||||
|
||||
self.language = language
|
||||
self.use_split_rules = use_split_rules
|
||||
self.extend_abbreviations = extend_abbreviations
|
||||
self.sentence_splitter: SentenceSplitter | None = None
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the component by initializing the sentence splitter and the document embedder.
|
||||
"""
|
||||
if self.sentence_splitter is None:
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
if hasattr(self.document_embedder, "warm_up"):
|
||||
self.document_embedder.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the component on the serving event loop.
|
||||
|
||||
Initializes the sentence splitter and warms up the document embedder using its async warm-up path when
|
||||
available, falling back to the synchronous one otherwise.
|
||||
"""
|
||||
if self.sentence_splitter is None:
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
if hasattr(self.document_embedder, "warm_up_async"):
|
||||
await self.document_embedder.warm_up_async()
|
||||
elif hasattr(self.document_embedder, "warm_up"):
|
||||
self.document_embedder.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the document embedder's resources.
|
||||
"""
|
||||
if hasattr(self.document_embedder, "close"):
|
||||
self.document_embedder.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the document embedder's async resources.
|
||||
"""
|
||||
if hasattr(self.document_embedder, "close_async"):
|
||||
await self.document_embedder.close_async()
|
||||
elif hasattr(self.document_embedder, "close"):
|
||||
self.document_embedder.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Split documents based on embedding similarity.
|
||||
|
||||
:param documents: The documents to split.
|
||||
:returns: A dictionary with the following key:
|
||||
- `documents`: List of documents with the split texts. Each document includes:
|
||||
- A metadata field `source_id` to track the original document.
|
||||
- A metadata field `split_id` to track the split number.
|
||||
- A metadata field `page_number` to track the original page number.
|
||||
- All other metadata copied from the original document.
|
||||
|
||||
:raises RuntimeError: If the component wasn't warmed up.
|
||||
:raises TypeError: If the input is not a list of Documents.
|
||||
:raises ValueError: If the document content is None or empty.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError("EmbeddingBasedDocumentSplitter expects a List of Documents as input.")
|
||||
|
||||
split_docs: list[Document] = []
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
raise ValueError(
|
||||
f"EmbeddingBasedDocumentSplitter only works with text documents but content for "
|
||||
f"document ID {doc.id} is None."
|
||||
)
|
||||
if doc.content == "":
|
||||
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
|
||||
doc_splits = self._split_document(doc=doc)
|
||||
split_docs.extend(doc_splits)
|
||||
|
||||
return {"documents": split_docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Asynchronously split documents based on embedding similarity.
|
||||
|
||||
This is the asynchronous version of the `run` method with the same parameters and return values.
|
||||
|
||||
:param documents: The documents to split.
|
||||
:returns: A dictionary with the following key:
|
||||
- `documents`: List of documents with the split texts. Each document includes:
|
||||
- A metadata field `source_id` to track the original document.
|
||||
- A metadata field `split_id` to track the split number.
|
||||
- A metadata field `page_number` to track the original page number.
|
||||
- All other metadata copied from the original document.
|
||||
|
||||
:raises RuntimeError: If the component wasn't warmed up.
|
||||
:raises TypeError: If the input is not a list of Documents.
|
||||
:raises ValueError: If the document content is None or empty.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError("EmbeddingBasedDocumentSplitter expects a List of Documents as input.")
|
||||
|
||||
tasks: list[Awaitable[list[Document]]] = []
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
raise ValueError(
|
||||
f"EmbeddingBasedDocumentSplitter only works with text documents but content for "
|
||||
f"document ID {doc.id} is None."
|
||||
)
|
||||
if doc.content == "":
|
||||
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
|
||||
tasks.append(self._split_document_async(doc=doc))
|
||||
|
||||
return {"documents": [*chain.from_iterable(await gather(*tasks))]}
|
||||
|
||||
def _split_document(self, doc: Document) -> list[Document]:
|
||||
"""
|
||||
Split a single document based on embedding similarity.
|
||||
"""
|
||||
# Create an initial split of the document content into smaller chunks
|
||||
# doc.content is validated in `run`
|
||||
splits = self._split_text(text=doc.content) # type: ignore[arg-type]
|
||||
|
||||
# Merge splits smaller than min_length
|
||||
merged_splits = self._merge_small_splits(splits=splits)
|
||||
|
||||
# Recursively split splits larger than max_length
|
||||
final_splits = self._split_large_splits(splits=merged_splits)
|
||||
|
||||
# Create Document objects from the final splits
|
||||
return EmbeddingBasedDocumentSplitter._create_documents_from_splits(splits=final_splits, original_doc=doc)
|
||||
|
||||
async def _split_document_async(self, doc: Document) -> list[Document]:
|
||||
"""
|
||||
Split a single document based on embedding similarity.
|
||||
"""
|
||||
# Create an initial split of the document content into smaller chunks
|
||||
# doc.content is validated in `run`
|
||||
splits = await self._split_text_async(text=doc.content) # type: ignore[arg-type]
|
||||
|
||||
# Merge splits smaller than min_length
|
||||
merged_splits = self._merge_small_splits(splits=splits)
|
||||
|
||||
# Recursively split splits larger than max_length
|
||||
final_splits = self._split_large_splits(splits=merged_splits)
|
||||
|
||||
# Create Document objects from the final splits
|
||||
return EmbeddingBasedDocumentSplitter._create_documents_from_splits(splits=final_splits, original_doc=doc)
|
||||
|
||||
def _prepare_sentence_groups(self, text: str) -> list[str]:
|
||||
"""Preprocess raw text into grouped sentences ready for embedding."""
|
||||
# NOTE: `self.sentence_splitter.split_sentences` strips all white space types (e.g. new lines, page breaks,
|
||||
# etc.) at the end of the provided text. So to not lose them, we need keep track of them and add them back to
|
||||
# the last sentence.
|
||||
rstripped_text = text.rstrip()
|
||||
trailing_whitespaces = text[len(rstripped_text) :]
|
||||
|
||||
# Split the text into sentences
|
||||
sentences_result = self.sentence_splitter.split_sentences(rstripped_text) # type: ignore[union-attr]
|
||||
|
||||
# Add back the stripped white spaces to the last sentence
|
||||
if sentences_result and trailing_whitespaces:
|
||||
sentences_result[-1]["sentence"] += trailing_whitespaces
|
||||
sentences_result[-1]["end"] += len(trailing_whitespaces)
|
||||
|
||||
sentences = [sentence["sentence"] for sentence in sentences_result]
|
||||
return self._group_sentences(sentences=sentences)
|
||||
|
||||
def _split_text(self, text: str) -> list[str]:
|
||||
"""
|
||||
Split a text into smaller chunks based on embedding similarity.
|
||||
"""
|
||||
sentence_groups = self._prepare_sentence_groups(text=text)
|
||||
embeddings = self._calculate_embeddings(sentence_groups=sentence_groups)
|
||||
split_points = self._find_split_points(embeddings=embeddings)
|
||||
return self._create_splits_from_points(sentence_groups=sentence_groups, split_points=split_points)
|
||||
|
||||
async def _split_text_async(self, text: str) -> list[str]:
|
||||
"""
|
||||
Asynchronously split a text into smaller chunks based on embedding similarity.
|
||||
"""
|
||||
sentence_groups = self._prepare_sentence_groups(text=text)
|
||||
embeddings = await self._calculate_embeddings_async(sentence_groups=sentence_groups)
|
||||
split_points = self._find_split_points(embeddings=embeddings)
|
||||
return self._create_splits_from_points(sentence_groups=sentence_groups, split_points=split_points)
|
||||
|
||||
def _group_sentences(self, sentences: list[str]) -> list[str]:
|
||||
"""
|
||||
Group sentences into groups of sentences_per_group.
|
||||
"""
|
||||
if self.sentences_per_group == 1:
|
||||
return sentences
|
||||
|
||||
groups = []
|
||||
for i in range(0, len(sentences), self.sentences_per_group):
|
||||
group = sentences[i : i + self.sentences_per_group]
|
||||
groups.append("".join(group))
|
||||
|
||||
return groups
|
||||
|
||||
def _calculate_embeddings(self, sentence_groups: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Calculate embeddings for each sentence group using the DocumentEmbedder.
|
||||
"""
|
||||
# Create Document objects for each group
|
||||
group_docs = [Document(content=group) for group in sentence_groups]
|
||||
result = self.document_embedder.run(group_docs)
|
||||
embedded_docs = result["documents"]
|
||||
return [doc.embedding for doc in embedded_docs]
|
||||
|
||||
async def _calculate_embeddings_async(self, sentence_groups: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Asynchronously Calculate embeddings for each sentence group using the DocumentEmbedder.
|
||||
"""
|
||||
# Create Document objects for each group
|
||||
group_docs = [Document(content=group) for group in sentence_groups]
|
||||
result = await _execute_component_async(self.document_embedder, documents=group_docs)
|
||||
embedded_docs = result["documents"]
|
||||
return [doc.embedding for doc in embedded_docs]
|
||||
|
||||
def _find_split_points(self, embeddings: list[list[float]]) -> list[int]:
|
||||
"""
|
||||
Find split points based on cosine distances between sequential embeddings.
|
||||
"""
|
||||
if len(embeddings) <= 1:
|
||||
return []
|
||||
|
||||
# Calculate cosine distances between sequential pairs
|
||||
distances = []
|
||||
for i in range(len(embeddings) - 1):
|
||||
distance = EmbeddingBasedDocumentSplitter._cosine_distance(
|
||||
embedding1=embeddings[i], embedding2=embeddings[i + 1]
|
||||
)
|
||||
distances.append(distance)
|
||||
|
||||
# Calculate threshold based on percentile
|
||||
threshold = np.percentile(distances, self.percentile * 100)
|
||||
|
||||
# Find indices where distance exceeds threshold
|
||||
split_points = []
|
||||
for i, distance in enumerate(distances):
|
||||
if distance > threshold:
|
||||
split_points.append(i + 1) # +1 because we want to split after this point
|
||||
|
||||
return split_points
|
||||
|
||||
@staticmethod
|
||||
def _cosine_distance(embedding1: list[float], embedding2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine distance between two embeddings.
|
||||
"""
|
||||
vec1 = np.array(embedding1)
|
||||
vec2 = np.array(embedding2)
|
||||
|
||||
norm1 = float(np.linalg.norm(vec1))
|
||||
norm2 = float(np.linalg.norm(vec2))
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 1.0
|
||||
|
||||
cosine_sim = float(np.dot(vec1, vec2) / (norm1 * norm2))
|
||||
|
||||
return 1.0 - cosine_sim
|
||||
|
||||
@staticmethod
|
||||
def _create_splits_from_points(sentence_groups: list[str], split_points: list[int]) -> list[str]:
|
||||
"""
|
||||
Create splits based on split points.
|
||||
"""
|
||||
if not split_points:
|
||||
return ["".join(sentence_groups)]
|
||||
|
||||
splits = []
|
||||
start = 0
|
||||
|
||||
for point in split_points:
|
||||
split_text = "".join(sentence_groups[start:point])
|
||||
if split_text:
|
||||
splits.append(split_text)
|
||||
start = point
|
||||
|
||||
# Add the last split
|
||||
if start < len(sentence_groups):
|
||||
split_text = "".join(sentence_groups[start:])
|
||||
if split_text:
|
||||
splits.append(split_text)
|
||||
|
||||
return splits
|
||||
|
||||
def _merge_small_splits(self, splits: list[str]) -> list[str]:
|
||||
"""
|
||||
Merge splits that are below min_length.
|
||||
"""
|
||||
if not splits:
|
||||
return splits
|
||||
|
||||
merged = []
|
||||
current_split = splits[0]
|
||||
|
||||
for split in splits[1:]:
|
||||
# We merge splits that are smaller than min_length but only if the newly merged split is still below
|
||||
# max_length.
|
||||
if len(current_split) < self.min_length and len(current_split) + len(split) < self.max_length:
|
||||
# Merge with next split
|
||||
current_split += split
|
||||
else:
|
||||
# Current split is long enough, save it and start a new one
|
||||
merged.append(current_split)
|
||||
current_split = split
|
||||
|
||||
# Don't forget the last split
|
||||
merged.append(current_split)
|
||||
|
||||
return merged
|
||||
|
||||
def _split_large_splits(self, splits: list[str]) -> list[str]:
|
||||
"""
|
||||
Recursively split splits that are above max_length.
|
||||
|
||||
This method checks each split and if it exceeds max_length, it attempts to split it further using the same
|
||||
embedding-based approach. This is done recursively until all splits are within the max_length limit or no
|
||||
further splitting is possible.
|
||||
|
||||
This works because the threshold for splits is calculated dynamically based on the provided of embeddings.
|
||||
"""
|
||||
final_splits = []
|
||||
|
||||
for split in splits:
|
||||
if len(split) <= self.max_length:
|
||||
final_splits.append(split)
|
||||
else:
|
||||
# Recursively split large splits
|
||||
# We can reuse the same _split_text method to split the text into smaller chunks because the threshold
|
||||
# for splits is calculated dynamically based on embeddings from `split`.
|
||||
sub_splits = self._split_text(text=split)
|
||||
|
||||
# Stop splitting if no further split is possible or continue with recursion
|
||||
if len(sub_splits) == 1:
|
||||
logger.warning(
|
||||
"Could not split a chunk further below max_length={max_length}. "
|
||||
"Returning chunk of length {length}.",
|
||||
max_length=self.max_length,
|
||||
length=len(split),
|
||||
)
|
||||
final_splits.append(split)
|
||||
else:
|
||||
final_splits.extend(self._split_large_splits(splits=sub_splits))
|
||||
|
||||
return final_splits
|
||||
|
||||
@staticmethod
|
||||
def _create_documents_from_splits(splits: list[str], original_doc: Document) -> list[Document]:
|
||||
"""
|
||||
Create Document objects from splits.
|
||||
"""
|
||||
documents = []
|
||||
metadata = deepcopy(original_doc.meta)
|
||||
metadata["source_id"] = original_doc.id
|
||||
|
||||
# Calculate page numbers for each split
|
||||
current_page = 1
|
||||
|
||||
for i, split_text in enumerate(splits):
|
||||
split_meta = deepcopy(metadata)
|
||||
split_meta["split_id"] = i
|
||||
|
||||
# Calculate page number for this split
|
||||
# Count page breaks in the split itself
|
||||
page_breaks_in_split = split_text.count("\f")
|
||||
|
||||
# Calculate the page number for this split
|
||||
split_meta["page_number"] = current_page
|
||||
|
||||
doc = Document(content=split_text, meta=split_meta)
|
||||
documents.append(doc)
|
||||
|
||||
# Update page counter for next split
|
||||
current_page += page_breaks_in_split
|
||||
|
||||
return documents
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Serialized dictionary representation of the component.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
document_embedder=component_to_dict(obj=self.document_embedder, name="document_embedder"),
|
||||
sentences_per_group=self.sentences_per_group,
|
||||
percentile=self.percentile,
|
||||
min_length=self.min_length,
|
||||
max_length=self.max_length,
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "EmbeddingBasedDocumentSplitter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize and create the component.
|
||||
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
deserialize_component_inplace(data["init_parameters"], key="document_embedder")
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,156 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
|
||||
|
||||
@component
|
||||
class HierarchicalDocumentSplitter:
|
||||
"""
|
||||
Splits a documents into different block sizes building a hierarchical tree structure of blocks of different sizes.
|
||||
|
||||
The root node of the tree is the original document, the leaf nodes are the smallest blocks. The blocks in between
|
||||
are connected such that the smaller blocks are children of the parent-larger blocks.
|
||||
|
||||
## Usage example
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import HierarchicalDocumentSplitter
|
||||
|
||||
doc = Document(content="This is a simple test document")
|
||||
splitter = HierarchicalDocumentSplitter(block_sizes={3, 2}, split_overlap=0, split_by="word")
|
||||
splitter.run([doc])
|
||||
# >> {'documents': [Document(id=3f7..., content: 'This is a simple test document', meta: {'block_size': 0, 'parent_id': None, 'children_ids': ['5ff..', '8dc..'], 'level': 0}),
|
||||
# >> Document(id=5ff.., content: 'This is a ', meta: {'block_size': 3, 'parent_id': '3f7..', 'children_ids': ['f19..', '52c..'], 'level': 1, 'source_id': '3f7..', 'page_number': 1, 'split_id': 0, 'split_idx_start': 0}),
|
||||
# >> Document(id=8dc.., content: 'simple test document', meta: {'block_size': 3, 'parent_id': '3f7..', 'children_ids': ['39d..', 'e23..'], 'level': 1, 'source_id': '3f7..', 'page_number': 1, 'split_id': 1, 'split_idx_start': 10}),
|
||||
# >> Document(id=f19.., content: 'This is ', meta: {'block_size': 2, 'parent_id': '5ff..', 'children_ids': [], 'level': 2, 'source_id': '5ff..', 'page_number': 1, 'split_id': 0, 'split_idx_start': 0}),
|
||||
# >> Document(id=52c.., content: 'a ', meta: {'block_size': 2, 'parent_id': '5ff..', 'children_ids': [], 'level': 2, 'source_id': '5ff..', 'page_number': 1, 'split_id': 1, 'split_idx_start': 8}),
|
||||
# >> Document(id=39d.., content: 'simple test ', meta: {'block_size': 2, 'parent_id': '8dc..', 'children_ids': [], 'level': 2, 'source_id': '8dc..', 'page_number': 1, 'split_id': 0, 'split_idx_start': 0}),
|
||||
# >> Document(id=e23.., content: 'document', meta: {'block_size': 2, 'parent_id': '8dc..', 'children_ids': [], 'level': 2, 'source_id': '8dc..', 'page_number': 1, 'split_id': 1, 'split_idx_start': 12})]}
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
block_sizes: set[int],
|
||||
split_overlap: int = 0,
|
||||
split_by: Literal["word", "sentence", "page", "passage"] = "word",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize HierarchicalDocumentSplitter.
|
||||
|
||||
:param block_sizes: Set of block sizes to split the document into. The blocks are split in descending order.
|
||||
:param split_overlap: The number of overlapping units for each split.
|
||||
:param split_by: The unit for splitting your documents.
|
||||
:raises ValueError: If `block_sizes` is empty, if `split_overlap` is negative, or if `split_overlap` is
|
||||
greater than or equal to the smallest value in `block_sizes`.
|
||||
"""
|
||||
|
||||
if not block_sizes:
|
||||
raise ValueError("block_sizes must not be empty. Provide at least one block size.")
|
||||
|
||||
if split_overlap < 0:
|
||||
raise ValueError("split_overlap must be greater than or equal to 0.")
|
||||
|
||||
smallest_block_size = min(block_sizes)
|
||||
if split_overlap >= smallest_block_size:
|
||||
raise ValueError(
|
||||
f"split_overlap ({split_overlap}) must be less than the smallest value in block_sizes "
|
||||
f"({smallest_block_size}). Reduce split_overlap or increase the smallest block size."
|
||||
)
|
||||
|
||||
self.block_sizes = sorted(set(block_sizes), reverse=True)
|
||||
self.splitters: dict[int, DocumentSplitter] = {}
|
||||
self.split_overlap = split_overlap
|
||||
self.split_by = split_by
|
||||
self._build_block_sizes()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Builds a hierarchical document structure for each document in a list of documents.
|
||||
|
||||
:param documents: List of Documents to split into hierarchical blocks.
|
||||
:returns: List of HierarchicalDocument
|
||||
"""
|
||||
hierarchical_docs = []
|
||||
for doc in documents:
|
||||
hierarchical_docs.extend(self.build_hierarchy_from_doc(doc))
|
||||
return {"documents": hierarchical_docs}
|
||||
|
||||
def _build_block_sizes(self) -> None:
|
||||
for block_size in self.block_sizes:
|
||||
self.splitters[block_size] = DocumentSplitter(
|
||||
split_length=block_size, split_overlap=self.split_overlap, split_by=self.split_by
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_meta_data(document: Document) -> Document:
|
||||
new_meta = {**document.meta, "__block_size": 0, "__parent_id": None, "__children_ids": [], "__level": 0}
|
||||
return replace(document, meta=new_meta)
|
||||
|
||||
def build_hierarchy_from_doc(self, document: Document) -> list[Document]:
|
||||
"""
|
||||
Build a hierarchical tree document structure from a single document.
|
||||
|
||||
Given a document, this function splits the document into hierarchical blocks of different sizes represented
|
||||
as HierarchicalDocument objects.
|
||||
|
||||
:param document: Document to split into hierarchical blocks.
|
||||
:returns:
|
||||
List of HierarchicalDocument
|
||||
"""
|
||||
|
||||
root = self._add_meta_data(document)
|
||||
current_level_nodes = [root]
|
||||
all_docs = []
|
||||
|
||||
for block in self.block_sizes:
|
||||
next_level_nodes = []
|
||||
for doc in current_level_nodes:
|
||||
splitted_docs = self.splitters[block].run([doc])
|
||||
child_docs = splitted_docs["documents"]
|
||||
# if it's only one document skip
|
||||
if len(child_docs) == 1:
|
||||
next_level_nodes.append(doc)
|
||||
continue
|
||||
for child_doc in child_docs:
|
||||
child_doc = self._add_meta_data(child_doc)
|
||||
child_doc.meta["__level"] = doc.meta["__level"] + 1
|
||||
child_doc.meta["__block_size"] = block
|
||||
child_doc.meta["__parent_id"] = doc.id
|
||||
all_docs.append(child_doc)
|
||||
doc.meta["__children_ids"].append(child_doc.id)
|
||||
next_level_nodes.append(child_doc)
|
||||
current_level_nodes = next_level_nodes
|
||||
|
||||
return [root] + all_docs
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Returns a dictionary representation of the component.
|
||||
|
||||
:returns:
|
||||
Serialized dictionary representation of the component.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, block_sizes=self.block_sizes, split_overlap=self.split_overlap, split_by=self.split_by
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "HierarchicalDocumentSplitter":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize and create the component.
|
||||
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,382 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class MarkdownHeaderSplitter:
|
||||
"""
|
||||
Split documents at ATX-style Markdown headers (#), with optional secondary splitting.
|
||||
|
||||
This component processes text documents by:
|
||||
- Splitting them into chunks at Markdown headers (e.g., '#', '##', etc.), preserving header hierarchy as metadata.
|
||||
- Optionally applying a secondary split (by word, passage, period, or line) to each chunk
|
||||
(using haystack's DocumentSplitter).
|
||||
- Preserving and propagating metadata such as parent headers, page numbers, and split IDs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
page_break_character: str = "\f",
|
||||
keep_headers: bool = True,
|
||||
header_split_levels: list[int] | None = None,
|
||||
secondary_split: Literal["word", "passage", "period", "line"] | None = None,
|
||||
split_length: int = 200,
|
||||
split_overlap: int = 0,
|
||||
split_threshold: int = 0,
|
||||
skip_empty_documents: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the MarkdownHeaderSplitter.
|
||||
|
||||
:param page_break_character: Character used to identify page breaks. Defaults to form feed ("\f").
|
||||
:param keep_headers: If True, headers are kept in the content. If False, headers are moved to metadata.
|
||||
Defaults to True.
|
||||
:param header_split_levels: List of header levels (1–6) to split on. For example, `[1, 2]` splits only
|
||||
on `#` and `##` headers, merging content under deeper headers into the preceding chunk. Defaults to
|
||||
all levels `[1, 2, 3, 4, 5, 6]`.
|
||||
:param secondary_split: Optional secondary split condition after header splitting.
|
||||
Options are None, "word", "passage", "period", "line". Defaults to None.
|
||||
:param split_length: The maximum number of units in each split when using secondary splitting. Defaults to 200.
|
||||
:param split_overlap: The number of overlapping units for each split when using secondary splitting.
|
||||
Defaults to 0.
|
||||
:param split_threshold: The minimum number of units per split when using secondary splitting. Defaults to 0.
|
||||
:param skip_empty_documents: Choose whether to skip documents with empty content. Default is True.
|
||||
Set to False when downstream components in the Pipeline (like LLMDocumentContentExtractor) can extract text
|
||||
from non-textual documents.
|
||||
"""
|
||||
if header_split_levels is None:
|
||||
header_split_levels = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
if not isinstance(header_split_levels, list) or len(header_split_levels) == 0:
|
||||
raise ValueError("header_split_levels must be a non-empty list.")
|
||||
invalid = [lvl for lvl in header_split_levels if not isinstance(lvl, int) or lvl < 1 or lvl > 6]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"header_split_levels contains invalid values: {invalid}. All levels must be integers between 1 and 6."
|
||||
)
|
||||
if len(header_split_levels) != len(set(header_split_levels)):
|
||||
raise ValueError("header_split_levels must not contain duplicate values.")
|
||||
|
||||
self.page_break_character = page_break_character
|
||||
self.secondary_split = secondary_split
|
||||
self.split_length = split_length
|
||||
self.split_overlap = split_overlap
|
||||
self.split_threshold = split_threshold
|
||||
self.skip_empty_documents = skip_empty_documents
|
||||
self.keep_headers = keep_headers
|
||||
self.header_split_levels = header_split_levels
|
||||
self._header_split_levels_set = set(header_split_levels)
|
||||
self._header_pattern = re.compile(r"(?m)^(#{1,6}) (.+)$") # ATX-style .md-headers
|
||||
|
||||
# Matches fenced code blocks delimited by triple backticks (```) or triple tildes (~~~).
|
||||
# Broken down:
|
||||
# ^ - fence must start at the beginning of a line (MULTILINE)
|
||||
# (?P<fence>`{3,}|~{3,})
|
||||
# - named capture group "fence": three or more backticks OR three or
|
||||
# more tildes. Capturing it allows the closing fence to be matched
|
||||
# with a backreference, so ```-opened blocks must close with ```
|
||||
# and ~~~-opened blocks must close with ~~~.
|
||||
# [^\n]* - optional language identifier (e.g. "python") and any other text
|
||||
# on the opening fence line, up to the newline
|
||||
# \n - newline ending the opening fence line
|
||||
# .*? - the code block body, matched lazily (DOTALL so . matches newlines)
|
||||
# ^(?P=fence) - closing fence: must be identical to the opening fence (backreference),
|
||||
# and must start at the beginning of a line
|
||||
# \s*$ - optional trailing whitespace after the closing fence
|
||||
self._code_block_pattern = re.compile(
|
||||
r"^(?P<fence>`{3,}|~{3,})[^\n]*\n.*?^(?P=fence)\s*$", re.MULTILINE | re.DOTALL
|
||||
)
|
||||
|
||||
self._is_warmed_up = False
|
||||
|
||||
# initialize secondary_splitter only if needed
|
||||
if self.secondary_split:
|
||||
self.secondary_splitter = DocumentSplitter(
|
||||
split_by=self.secondary_split,
|
||||
split_length=self.split_length,
|
||||
split_overlap=self.split_overlap,
|
||||
split_threshold=self.split_threshold,
|
||||
)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the MarkdownHeaderSplitter.
|
||||
"""
|
||||
if self.secondary_split and not self._is_warmed_up:
|
||||
self.secondary_splitter.warm_up()
|
||||
self._is_warmed_up = True
|
||||
|
||||
def _code_block_spans(self, text: str) -> list[tuple[int, int]]:
|
||||
"""Return the (start, end) character spans of all fenced code blocks in text."""
|
||||
return [(m.start(), m.end()) for m in self._code_block_pattern.finditer(text)]
|
||||
|
||||
def _split_text_by_markdown_headers(self, text: str, doc_id: str) -> list[dict]:
|
||||
"""Split text by ATX-style headers (#) and create chunks with appropriate metadata."""
|
||||
logger.debug("Splitting text by markdown headers")
|
||||
|
||||
# Pre-compute fenced code block spans so that # lines inside code blocks (e.g. Python comments) are not
|
||||
# mistaken for Markdown headers.
|
||||
code_spans = self._code_block_spans(text)
|
||||
|
||||
# find headers at the configured levels only, excluding any that fall inside a code block. Content between
|
||||
# skipped headers is absorbed into the preceding chunk's span since end = next_match.start().
|
||||
matches = [
|
||||
m
|
||||
for m in re.finditer(self._header_pattern, text)
|
||||
if len(m.group(1)) in self._header_split_levels_set
|
||||
and not any(start <= m.start() < end for start, end in code_spans)
|
||||
]
|
||||
|
||||
# return unsplit if no headers found
|
||||
if not matches:
|
||||
logger.info(
|
||||
"No headers found in document {doc_id}; returning full document as single chunk.", doc_id=doc_id
|
||||
)
|
||||
return [{"content": text, "meta": {}}]
|
||||
|
||||
# process headers and build chunks
|
||||
chunks: list[dict] = []
|
||||
header_stack: list[str | None] = [None] * 6
|
||||
pending_headers: list[str] = [] # store empty headers to prepend to next content
|
||||
has_content = False # flag to track if any header has content
|
||||
|
||||
for i, match in enumerate(matches):
|
||||
# extract header info
|
||||
header_prefix = match.group(1)
|
||||
header_text = match.group(2)
|
||||
level = len(header_prefix)
|
||||
|
||||
# get content
|
||||
start = match.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
content = text[start:end]
|
||||
|
||||
# update header stack to track nesting
|
||||
header_stack[level - 1] = header_text
|
||||
for j in range(level, 6):
|
||||
header_stack[j] = None
|
||||
|
||||
# skip splits w/o content
|
||||
if not content.strip(): # this strip is needed to avoid counting whitespace as content
|
||||
if self.keep_headers:
|
||||
header_line = f"{header_prefix} {header_text}"
|
||||
pending_headers.append(header_line)
|
||||
continue
|
||||
|
||||
has_content = True # at least one header has content
|
||||
# Build parent metadata from the current header stack so the first child of a
|
||||
# contentful section still inherits its full ancestor chain.
|
||||
parent_headers = [h for h in header_stack[: level - 1] if h is not None]
|
||||
|
||||
logger.debug(
|
||||
"Creating chunk for header '{header_text}' at level {level}", header_text=header_text, level=level
|
||||
)
|
||||
|
||||
if self.keep_headers:
|
||||
header_line = f"{header_prefix} {header_text}"
|
||||
# add pending & current header to content
|
||||
chunk_content = ""
|
||||
if pending_headers:
|
||||
chunk_content += "\n".join(pending_headers) + "\n"
|
||||
chunk_content += f"{header_line}{content}"
|
||||
chunks.append(
|
||||
{"content": chunk_content, "meta": {"header": header_text, "parent_headers": parent_headers}}
|
||||
)
|
||||
pending_headers = [] # reset pending headers
|
||||
else:
|
||||
chunks.append({"content": content, "meta": {"header": header_text, "parent_headers": parent_headers}})
|
||||
|
||||
# return doc unchunked if no headers have content
|
||||
if not has_content:
|
||||
logger.info(
|
||||
"Document {doc_id} contains only headers with no content; returning original document.", doc_id=doc_id
|
||||
)
|
||||
return [{"content": text, "meta": {}}]
|
||||
|
||||
return chunks
|
||||
|
||||
def _apply_secondary_splitting(self, documents: list[Document]) -> list[Document]:
|
||||
"""
|
||||
Apply secondary splitting while preserving header metadata and structure.
|
||||
|
||||
Ensures page counting is maintained across splits.
|
||||
"""
|
||||
result_docs = []
|
||||
current_split_id = 0 # track split_id across all secondary splits from the same parent
|
||||
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
result_docs.append(doc)
|
||||
continue
|
||||
|
||||
content_for_splitting: str = doc.content
|
||||
|
||||
if not self.keep_headers: # skip header extraction if keep_headers
|
||||
# extract header information
|
||||
header_match = re.match(self._header_pattern, doc.content)
|
||||
if header_match:
|
||||
content_for_splitting = doc.content[header_match.end() :]
|
||||
|
||||
# track page from meta
|
||||
current_page = doc.meta.get("page_number", 1)
|
||||
|
||||
# create a clean meta dict without split_id for secondary splitting
|
||||
clean_meta = {k: v for k, v in doc.meta.items() if k != "split_id"}
|
||||
|
||||
secondary_splits = self.secondary_splitter.run(
|
||||
documents=[Document(content=content_for_splitting, meta=clean_meta)]
|
||||
)["documents"]
|
||||
|
||||
# split processing
|
||||
for i, split in enumerate(secondary_splits):
|
||||
# calculate page number for this split
|
||||
if i > 0 and secondary_splits[i - 1].content:
|
||||
current_page = self._update_page_number_with_breaks(secondary_splits[i - 1].content, current_page)
|
||||
|
||||
# set page number and split_id to meta
|
||||
split.meta["page_number"] = current_page
|
||||
split.meta["split_id"] = current_split_id
|
||||
# ensure source_id is preserved from the original document
|
||||
if "source_id" in doc.meta:
|
||||
split.meta["source_id"] = doc.meta["source_id"]
|
||||
current_split_id += 1
|
||||
|
||||
# preserve header metadata if we're not keeping headers in content
|
||||
if not self.keep_headers:
|
||||
for key in ["header", "parent_headers"]:
|
||||
if key in doc.meta:
|
||||
split.meta[key] = doc.meta[key]
|
||||
|
||||
result_docs.append(split)
|
||||
|
||||
logger.debug(
|
||||
"Secondary splitting complete. Final count: {final_count} documents.", final_count=len(result_docs)
|
||||
)
|
||||
return result_docs
|
||||
|
||||
def _update_page_number_with_breaks(self, content: str | None, current_page: int) -> int:
|
||||
"""
|
||||
Update page number based on page breaks in content.
|
||||
|
||||
:param content: Content to check for page breaks
|
||||
:param current_page: Current page number
|
||||
:return: New current page number
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return current_page
|
||||
|
||||
page_breaks = content.count(self.page_break_character)
|
||||
new_page_number = current_page + page_breaks
|
||||
|
||||
if page_breaks > 0:
|
||||
logger.debug(
|
||||
"Found {page_breaks} page breaks, page number updated: {old} → {new}",
|
||||
page_breaks=page_breaks,
|
||||
old=current_page,
|
||||
new=new_page_number,
|
||||
)
|
||||
|
||||
return new_page_number
|
||||
|
||||
def _split_documents_by_markdown_headers(self, documents: list[Document]) -> list[Document]:
|
||||
"""Split a list of documents by markdown headers, preserving metadata."""
|
||||
|
||||
result_docs = []
|
||||
for doc in documents:
|
||||
logger.debug("Splitting document with id={doc_id}", doc_id=doc.id)
|
||||
# mypy: doc.content is Optional[str], so we must check for None before passing to splitting method
|
||||
if doc.content is None:
|
||||
continue
|
||||
splits = self._split_text_by_markdown_headers(doc.content, doc.id)
|
||||
docs = []
|
||||
|
||||
current_page = doc.meta.get("page_number", 1) if doc.meta else 1
|
||||
total_page_breaks = doc.content.count(self.page_break_character)
|
||||
logger.debug(
|
||||
"Processing document with id={doc_id}: starting at page {start_page}, "
|
||||
"contains {page_breaks} page breaks in total",
|
||||
doc_id=doc.id,
|
||||
start_page=current_page,
|
||||
page_breaks=total_page_breaks,
|
||||
)
|
||||
for split_idx, split in enumerate(splits):
|
||||
meta = doc.meta.copy() if doc.meta else {}
|
||||
meta.update({"source_id": doc.id, "page_number": current_page, "split_id": split_idx})
|
||||
if split.get("meta"):
|
||||
meta.update(split["meta"])
|
||||
current_page = self._update_page_number_with_breaks(split["content"], current_page)
|
||||
docs.append(Document(content=split["content"], meta=meta))
|
||||
logger.debug(
|
||||
"Split into {num_docs} documents for id={doc_id}, final page: {current_page}",
|
||||
num_docs=len(docs),
|
||||
doc_id=doc.id,
|
||||
current_page=current_page,
|
||||
)
|
||||
result_docs.extend(docs)
|
||||
return result_docs
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run the markdown header splitter with optional secondary splitting.
|
||||
|
||||
:param documents: List of documents to split
|
||||
|
||||
:returns: A dictionary with the following key:
|
||||
- `documents`: List of documents with the split texts. Each document includes:
|
||||
- A metadata field `source_id` to track the original document.
|
||||
- A metadata field `page_number` to track the original page number.
|
||||
- A metadata field `split_id` to identify the split chunk index within its parent document.
|
||||
- All other metadata copied from the original document.
|
||||
:raises ValueError: If a document has `None` content.
|
||||
:raises TypeError: If a document's content is not a string.
|
||||
"""
|
||||
if self.secondary_split and not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
# validate input documents
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
raise ValueError(
|
||||
"MarkdownHeaderSplitter only works with text documents but content for document ID"
|
||||
f" {doc.id} is None."
|
||||
)
|
||||
if not isinstance(doc.content, str):
|
||||
raise TypeError("MarkdownHeaderSplitter only works with text documents (str content).")
|
||||
|
||||
final_docs = []
|
||||
for doc in documents:
|
||||
# handle empty documents
|
||||
if not doc.content or not doc.content.strip(): # avoid counting whitespace as content
|
||||
if self.skip_empty_documents:
|
||||
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
# keep empty documents
|
||||
final_docs.append(doc)
|
||||
logger.warning(
|
||||
"Document ID {doc_id} has an empty content. Keeping this document as per configuration.",
|
||||
doc_id=doc.id,
|
||||
)
|
||||
continue
|
||||
|
||||
# split this document by headers
|
||||
header_split_docs = self._split_documents_by_markdown_headers([doc])
|
||||
|
||||
# apply secondary splitting if configured
|
||||
if self.secondary_split:
|
||||
doc_splits = self._apply_secondary_splitting(header_split_docs)
|
||||
else:
|
||||
doc_splits = header_split_docs
|
||||
|
||||
final_docs.extend(doc_splits)
|
||||
|
||||
return {"documents": final_docs}
|
||||
@@ -0,0 +1,612 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ast
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.preprocessors.document_splitter import DocumentSplitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CodeUnit:
|
||||
"""One syntactic split unit (function, class header, method, imports block, statement, ...)."""
|
||||
|
||||
source: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
kind: str
|
||||
name: str | None = None
|
||||
class_name: str | None = None
|
||||
class_signature: str | None = None
|
||||
decorators: list[str] = field(default_factory=list)
|
||||
docstring: str | None = None
|
||||
|
||||
|
||||
@component
|
||||
class PythonCodeSplitter:
|
||||
"""
|
||||
Split Python source code into syntax-aware chunks.
|
||||
|
||||
The component parses each source with :mod:`ast` into *units* (module docstring,
|
||||
consecutive ``import`` blocks, top-level functions, class headers, methods, nested
|
||||
classes, and remaining statements) and merges them greedily in source order toward
|
||||
``max_effective_lines`` per chunk, where effective lines are
|
||||
``ceil(len(source) / expected_chars_per_line)``. Functions and methods are kept
|
||||
whole; the resulting chunks read top-to-bottom like the original file with comments
|
||||
and blank lines preserved.
|
||||
|
||||
A function whose effective length exceeds ``oversized_factor * max_effective_lines``
|
||||
is the only case where chunks may overlap: it is broken down with a line-based
|
||||
secondary split (:class:`DocumentSplitter`, ``split_by="line"``) and the resulting
|
||||
pieces carry ``secondary_split=True`` along with the originating function's metadata.
|
||||
The primary split never adds overlap.
|
||||
|
||||
Per-chunk metadata: ``source_id``, ``split_id``, ``start_line``, ``end_line``,
|
||||
``unit_kinds``; plus ``include_classes``, ``decorators``, and ``docstrings`` (when
|
||||
``strip_docstrings=True``) where applicable. ``file_name`` and any other parent
|
||||
document meta are propagated.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import PythonCodeSplitter
|
||||
|
||||
source = '''
|
||||
\"\"\"Example module.\"\"\"
|
||||
from math import sqrt
|
||||
|
||||
|
||||
class Circle:
|
||||
def __init__(self, r: float) -> None:
|
||||
self.r = r
|
||||
|
||||
def area(self) -> float:
|
||||
return 3.14159 * self.r * self.r
|
||||
'''
|
||||
|
||||
splitter = PythonCodeSplitter(min_effective_lines=4, max_effective_lines=6)
|
||||
result = splitter.run(documents=[Document(content=source, meta={"file_name": "circle.py"})])
|
||||
for chunk in result["documents"]:
|
||||
print(chunk.meta["start_line"], chunk.meta["end_line"], chunk.meta.get("include_classes"))
|
||||
```
|
||||
|
||||
Pass ``strip_docstrings=True`` to move docstrings out of the chunk content and into
|
||||
each chunk's ``meta["docstrings"]`` list. This is useful for RAG when docstrings are
|
||||
large: stripping shrinks the stored content while the docstring text can still
|
||||
influence retrieval via ``meta_fields_to_embed=["docstrings"]`` on the embedder.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
min_effective_lines: int = 20,
|
||||
max_effective_lines: int = 100,
|
||||
expected_chars_per_line: int = 45,
|
||||
oversized_factor: int = 3,
|
||||
strip_docstrings: bool = False,
|
||||
preserve_class_definition: bool = True,
|
||||
secondary_split_overlap: int = 5,
|
||||
secondary_split_length: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the PythonCodeSplitter.
|
||||
|
||||
:param min_effective_lines: Minimum effective lines per chunk. While the running
|
||||
chunk is below this threshold the splitter keeps merging in the next unit.
|
||||
:param max_effective_lines: Target effective lines per chunk. Units are merged
|
||||
greedily while doing so brings the running total closer to this target.
|
||||
:param expected_chars_per_line: Used to convert characters into effective lines as
|
||||
``ceil(len(source) / expected_chars_per_line)``; long lines count as more than one.
|
||||
:param oversized_factor: A function whose effective length exceeds
|
||||
``oversized_factor * max_effective_lines`` triggers the line-based secondary
|
||||
split with overlap.
|
||||
:param strip_docstrings: If ``True``, function/method/class docstrings are moved
|
||||
from the chunk content into ``meta["docstrings"]`` (source order). The
|
||||
module-level docstring is kept in place since it is itself a top-level unit.
|
||||
:param preserve_class_definition: If ``True`` (default), chunks that contain class
|
||||
members but not the class header are prefixed with the bare class signature
|
||||
(decorators plus the ``class Foo(...):`` lines) in source order.
|
||||
:param secondary_split_overlap: Line overlap for the secondary splitter; only used
|
||||
in the oversized fallback. The primary AST split never adds overlap.
|
||||
:param secondary_split_length: Lines per chunk for the secondary splitter.
|
||||
Defaults to ``max_effective_lines`` when ``None``.
|
||||
:raises ValueError: If any parameter is invalid (negative, zero where positive is
|
||||
required, or ``min_effective_lines > max_effective_lines``).
|
||||
"""
|
||||
if min_effective_lines < 1:
|
||||
raise ValueError("min_effective_lines must be at least 1.")
|
||||
if max_effective_lines < 1:
|
||||
raise ValueError("max_effective_lines must be at least 1.")
|
||||
if min_effective_lines > max_effective_lines:
|
||||
raise ValueError("min_effective_lines must not be greater than max_effective_lines.")
|
||||
if expected_chars_per_line < 1:
|
||||
raise ValueError("expected_chars_per_line must be at least 1.")
|
||||
if oversized_factor < 1:
|
||||
raise ValueError("oversized_factor must be at least 1.")
|
||||
if secondary_split_overlap < 0:
|
||||
raise ValueError("secondary_split_overlap must be non-negative.")
|
||||
if secondary_split_length is not None and secondary_split_length < 1:
|
||||
raise ValueError("secondary_split_length must be at least 1.")
|
||||
|
||||
self.min_effective_lines = min_effective_lines
|
||||
self.max_effective_lines = max_effective_lines
|
||||
self.expected_chars_per_line = expected_chars_per_line
|
||||
self.oversized_factor = oversized_factor
|
||||
self.strip_docstrings = strip_docstrings
|
||||
self.preserve_class_definition = preserve_class_definition
|
||||
self.secondary_split_overlap = secondary_split_overlap
|
||||
self.secondary_split_length = secondary_split_length
|
||||
|
||||
def _effective_lines(self, text: str) -> int:
|
||||
"""Return the number of *effective lines* for ``text`` (see class docstring)."""
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, math.ceil(len(text) / self.expected_chars_per_line))
|
||||
|
||||
def _is_oversized(self, unit: "_CodeUnit") -> bool:
|
||||
"""Return ``True`` if ``unit`` should trigger the secondary line-based split."""
|
||||
return self._effective_lines(unit.source) > self.oversized_factor * self.max_effective_lines
|
||||
|
||||
@staticmethod
|
||||
def _slice_lines(source_lines: list[str], start: int, end: int) -> str:
|
||||
"""Slice ``source_lines`` between the 1-indexed ``start`` and ``end`` (inclusive)."""
|
||||
start = max(start, 1)
|
||||
if end < start:
|
||||
return ""
|
||||
return "".join(source_lines[start - 1 : end])
|
||||
|
||||
@staticmethod
|
||||
def _safe_unparse(node: ast.AST) -> str:
|
||||
"""Return ``ast.unparse(node)`` but tolerate exotic nodes by falling back to ``repr``."""
|
||||
try:
|
||||
return ast.unparse(node)
|
||||
except Exception: # pragma: no cover - defensive guard
|
||||
return repr(node)
|
||||
|
||||
def _strip_docstring(
|
||||
self,
|
||||
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
||||
source_lines: list[str],
|
||||
unit_start: int,
|
||||
unit_end: int,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Strip ``node``'s docstring from ``source_lines[unit_start..unit_end]`` if safely possible."""
|
||||
docstring = ast.get_docstring(node)
|
||||
body = node.body
|
||||
if not docstring or not body:
|
||||
return self._slice_lines(source_lines, unit_start, unit_end), None
|
||||
|
||||
first = body[0]
|
||||
if not (
|
||||
isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str)
|
||||
):
|
||||
return self._slice_lines(source_lines, unit_start, unit_end), None
|
||||
|
||||
# Skip stripping when the docstring shares a line with the def/class (would
|
||||
# leave broken syntax) or extends past the caller's slice (e.g. class_header).
|
||||
ds_start = first.lineno
|
||||
ds_end = first.end_lineno or first.lineno
|
||||
if ds_start <= node.lineno or ds_end > unit_end:
|
||||
return self._slice_lines(source_lines, unit_start, unit_end), None
|
||||
|
||||
before = source_lines[unit_start - 1 : ds_start - 1]
|
||||
after = source_lines[ds_end:unit_end]
|
||||
return "".join(before + after), docstring
|
||||
|
||||
def _emit_class_units(self, cls: ast.ClassDef, source_lines: list[str], cursor: int, units: list[_CodeUnit]) -> int:
|
||||
"""Emit class header and per-method units for ``cls``; return the next cursor (1-indexed)."""
|
||||
class_start = cls.decorator_list[0].lineno if cls.decorator_list else cls.lineno
|
||||
class_end = cls.end_lineno or cls.lineno
|
||||
class_name = cls.name
|
||||
class_decorators = [self._safe_unparse(d) for d in cls.decorator_list]
|
||||
|
||||
# Methods, async methods, and nested classes become their own units so a
|
||||
# method is never split mid-statement.
|
||||
split_children_idx = [
|
||||
k
|
||||
for k, child in enumerate(cls.body)
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||
]
|
||||
|
||||
# Bare class signature (decorators + ``class Foo(...):`` lines) used by
|
||||
# ``preserve_class_definition`` to prefix later chunks of the same class.
|
||||
class_signature: str | None = None
|
||||
if cls.body:
|
||||
body_start = cls.body[0].lineno
|
||||
if body_start > class_start:
|
||||
class_signature = self._slice_lines(source_lines, class_start, body_start - 1)
|
||||
|
||||
# Whole class fits in one unit when there are no inner split points.
|
||||
if not split_children_idx:
|
||||
unit_slice = self._slice_lines(source_lines, cursor, class_end)
|
||||
stripped_docstring: str | None = None
|
||||
if self.strip_docstrings:
|
||||
unit_slice, stripped_docstring = self._strip_docstring(cls, source_lines, cursor, class_end)
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=unit_slice,
|
||||
start_line=class_start,
|
||||
end_line=class_end,
|
||||
kind="class",
|
||||
name=class_name,
|
||||
class_name=class_name,
|
||||
class_signature=class_signature,
|
||||
decorators=class_decorators,
|
||||
docstring=stripped_docstring,
|
||||
)
|
||||
)
|
||||
return class_end + 1
|
||||
|
||||
# Class header: from outer cursor up to (but excluding) the first split child.
|
||||
first_child = cls.body[split_children_idx[0]]
|
||||
if (
|
||||
isinstance(first_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||
and first_child.decorator_list
|
||||
):
|
||||
first_child_start = first_child.decorator_list[0].lineno
|
||||
else:
|
||||
first_child_start = first_child.lineno
|
||||
header_end = first_child_start - 1
|
||||
header_slice = self._slice_lines(source_lines, cursor, header_end)
|
||||
header_docstring: str | None = None
|
||||
if self.strip_docstrings:
|
||||
header_slice, header_docstring = self._strip_docstring(cls, source_lines, cursor, header_end)
|
||||
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=header_slice,
|
||||
start_line=class_start,
|
||||
end_line=header_end,
|
||||
kind="class_header",
|
||||
name=class_name,
|
||||
class_name=class_name,
|
||||
class_signature=class_signature,
|
||||
decorators=class_decorators,
|
||||
docstring=header_docstring,
|
||||
)
|
||||
)
|
||||
inner_cursor = header_end + 1
|
||||
|
||||
for idx in split_children_idx:
|
||||
child = cls.body[idx]
|
||||
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
continue # narrowed above; kept for the type checker
|
||||
child_start = child.decorator_list[0].lineno if child.decorator_list else child.lineno
|
||||
child_end = child.end_lineno or child.lineno
|
||||
decorators = [self._safe_unparse(d) for d in child.decorator_list]
|
||||
|
||||
unit_slice = self._slice_lines(source_lines, inner_cursor, child_end)
|
||||
stripped_docstring = None
|
||||
if self.strip_docstrings:
|
||||
unit_slice, stripped_docstring = self._strip_docstring(child, source_lines, inner_cursor, child_end)
|
||||
|
||||
kind = "method" if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) else "nested_class"
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=unit_slice,
|
||||
start_line=child_start,
|
||||
end_line=child_end,
|
||||
kind=kind,
|
||||
name=child.name,
|
||||
class_name=class_name,
|
||||
class_signature=class_signature,
|
||||
decorators=decorators,
|
||||
docstring=stripped_docstring,
|
||||
)
|
||||
)
|
||||
inner_cursor = child_end + 1
|
||||
|
||||
# Append trailing class-body lines (comments / blanks after the last method).
|
||||
if inner_cursor <= class_end and units:
|
||||
trailing = self._slice_lines(source_lines, inner_cursor, class_end)
|
||||
units[-1].source += trailing
|
||||
units[-1].end_line = class_end
|
||||
|
||||
return class_end + 1
|
||||
|
||||
def _extract_units(self, source: str) -> list[_CodeUnit]:
|
||||
"""Parse ``source`` and produce the ordered list of syntactic split units."""
|
||||
tree = ast.parse(source)
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
total_lines = len(source_lines)
|
||||
|
||||
units: list[_CodeUnit] = []
|
||||
cursor = 1
|
||||
body = tree.body
|
||||
node_idx = 0
|
||||
node_count = len(body)
|
||||
|
||||
while node_idx < node_count:
|
||||
node = body[node_idx]
|
||||
|
||||
# Module docstring (only valid as the very first statement).
|
||||
if (
|
||||
node_idx == 0
|
||||
and isinstance(node, ast.Expr)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and isinstance(node.value.value, str)
|
||||
):
|
||||
end = node.end_lineno or node.lineno
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=self._slice_lines(source_lines, cursor, end),
|
||||
start_line=node.lineno,
|
||||
end_line=end,
|
||||
kind="module_docstring",
|
||||
)
|
||||
)
|
||||
cursor = end + 1
|
||||
node_idx += 1
|
||||
continue
|
||||
|
||||
# Group consecutive imports into one unit.
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
import_end_idx = node_idx
|
||||
while import_end_idx < node_count and isinstance(body[import_end_idx], (ast.Import, ast.ImportFrom)):
|
||||
import_end_idx += 1
|
||||
last = body[import_end_idx - 1]
|
||||
end = last.end_lineno or last.lineno
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=self._slice_lines(source_lines, cursor, end),
|
||||
start_line=node.lineno,
|
||||
end_line=end,
|
||||
kind="imports",
|
||||
)
|
||||
)
|
||||
cursor = end + 1
|
||||
node_idx = import_end_idx
|
||||
continue
|
||||
|
||||
if isinstance(node, ast.ClassDef):
|
||||
cursor = self._emit_class_units(node, source_lines, cursor, units)
|
||||
node_idx += 1
|
||||
continue
|
||||
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
start = node.decorator_list[0].lineno if node.decorator_list else node.lineno
|
||||
end = node.end_lineno or node.lineno
|
||||
decorators = [self._safe_unparse(d) for d in node.decorator_list]
|
||||
unit_slice = self._slice_lines(source_lines, cursor, end)
|
||||
stripped_docstring: str | None = None
|
||||
if self.strip_docstrings:
|
||||
unit_slice, stripped_docstring = self._strip_docstring(node, source_lines, cursor, end)
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=unit_slice,
|
||||
start_line=start,
|
||||
end_line=end,
|
||||
kind="function",
|
||||
name=node.name,
|
||||
decorators=decorators,
|
||||
docstring=stripped_docstring,
|
||||
)
|
||||
)
|
||||
cursor = end + 1
|
||||
node_idx += 1
|
||||
continue
|
||||
|
||||
# Catch-all for top-level statements (assignments, conditionals, etc.).
|
||||
end = node.end_lineno or node.lineno
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=self._slice_lines(source_lines, cursor, end),
|
||||
start_line=node.lineno,
|
||||
end_line=end,
|
||||
kind="statement",
|
||||
)
|
||||
)
|
||||
cursor = end + 1
|
||||
node_idx += 1
|
||||
|
||||
# Append trailing content (comments after the last node) so the split is loss-less.
|
||||
if cursor <= total_lines and units:
|
||||
trailing = self._slice_lines(source_lines, cursor, total_lines)
|
||||
units[-1].source += trailing
|
||||
units[-1].end_line = total_lines
|
||||
elif cursor <= total_lines and not units:
|
||||
units.append(
|
||||
_CodeUnit(
|
||||
source=self._slice_lines(source_lines, cursor, total_lines),
|
||||
start_line=cursor,
|
||||
end_line=total_lines,
|
||||
kind="statement",
|
||||
)
|
||||
)
|
||||
|
||||
return units
|
||||
|
||||
def _merge_units(self, units: list[_CodeUnit]) -> list[list[_CodeUnit]]:
|
||||
"""Greedily merge units toward ``max_effective_lines``; oversized units become solo chunks."""
|
||||
chunks: list[list[_CodeUnit]] = []
|
||||
current: list[_CodeUnit] = []
|
||||
current_lines = 0
|
||||
target = self.max_effective_lines
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current, current_lines
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = []
|
||||
current_lines = 0
|
||||
|
||||
for unit in units:
|
||||
if self._is_oversized(unit):
|
||||
flush()
|
||||
chunks.append([unit])
|
||||
continue
|
||||
|
||||
unit_eff = self._effective_lines(unit.source)
|
||||
|
||||
if not current:
|
||||
current = [unit]
|
||||
current_lines = unit_eff
|
||||
continue
|
||||
|
||||
# Keep merging while below the minimum or while adding moves us closer to the target.
|
||||
new_total = current_lines + unit_eff
|
||||
if current_lines < self.min_effective_lines or abs(new_total - target) < abs(current_lines - target):
|
||||
current.append(unit)
|
||||
current_lines = new_total
|
||||
else:
|
||||
flush()
|
||||
current = [unit]
|
||||
current_lines = unit_eff
|
||||
|
||||
flush()
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _ordered_unique(items: list[str]) -> list[str]:
|
||||
"""Return the list of unique items in their first-seen order."""
|
||||
return list(dict.fromkeys(items))
|
||||
|
||||
def _build_chunk_meta(self, chunk: list[_CodeUnit], parent_doc: Document) -> dict[str, Any]:
|
||||
"""Construct the output meta dict for a chunk of merged units."""
|
||||
meta: dict[str, Any] = {}
|
||||
if parent_doc.meta:
|
||||
meta.update({k: v for k, v in parent_doc.meta.items() if k not in {"split_id"}})
|
||||
meta["source_id"] = parent_doc.id
|
||||
|
||||
# Units are emitted in source order, so chunk[0]/chunk[-1] give the extremes.
|
||||
meta["start_line"] = chunk[0].start_line
|
||||
meta["end_line"] = chunk[-1].end_line
|
||||
meta["unit_kinds"] = [u.kind for u in chunk]
|
||||
|
||||
include_classes = self._ordered_unique([u.class_name for u in chunk if u.class_name])
|
||||
if include_classes:
|
||||
meta["include_classes"] = include_classes
|
||||
|
||||
decorators: list[str] = []
|
||||
for u in chunk:
|
||||
decorators.extend(u.decorators)
|
||||
decorators = self._ordered_unique(decorators)
|
||||
if decorators:
|
||||
meta["decorators"] = decorators
|
||||
|
||||
if self.strip_docstrings:
|
||||
docstrings = [u.docstring for u in chunk if u.docstring]
|
||||
if docstrings:
|
||||
meta["docstrings"] = docstrings
|
||||
|
||||
return meta
|
||||
|
||||
def _render_chunk_content(self, chunk: list[_CodeUnit]) -> str:
|
||||
"""Render chunk content, optionally prefixing class signatures for orphan members."""
|
||||
body = "".join(u.source for u in chunk)
|
||||
if not self.preserve_class_definition:
|
||||
return body
|
||||
|
||||
classes_with_header = {u.class_name for u in chunk if u.kind in {"class", "class_header"} and u.class_name}
|
||||
prepended: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for u in chunk:
|
||||
if (
|
||||
u.class_name
|
||||
and u.class_name not in classes_with_header
|
||||
and u.class_name not in seen
|
||||
and u.class_signature
|
||||
):
|
||||
prepended.append(u.class_signature)
|
||||
seen.add(u.class_name)
|
||||
|
||||
if not prepended:
|
||||
return body
|
||||
return "".join(prepended) + body
|
||||
|
||||
def _secondary_split(self, unit: _CodeUnit, parent_doc: Document) -> list[Document]:
|
||||
"""Apply a line-based fallback split with overlap to a single oversized unit."""
|
||||
qualified_name = unit.name or unit.kind
|
||||
if unit.class_name and unit.name:
|
||||
qualified_name = f"{unit.class_name}.{unit.name}"
|
||||
logger.warning(
|
||||
"Oversized {kind} '{func_name}' at lines {start}-{end} ({eff} effective lines) exceeds "
|
||||
"{factor}x max_effective_lines={max_effective_lines}; falling back to line-based secondary split "
|
||||
"with overlap={overlap}.",
|
||||
kind=unit.kind,
|
||||
func_name=qualified_name,
|
||||
start=unit.start_line,
|
||||
end=unit.end_line,
|
||||
eff=self._effective_lines(unit.source),
|
||||
factor=self.oversized_factor,
|
||||
max_effective_lines=self.max_effective_lines,
|
||||
overlap=self.secondary_split_overlap,
|
||||
)
|
||||
|
||||
# DocumentSplitter measures in physical lines; this approximates effective lines.
|
||||
split_length = (
|
||||
self.secondary_split_length if self.secondary_split_length is not None else self.max_effective_lines
|
||||
)
|
||||
overlap = min(self.secondary_split_overlap, max(0, split_length - 1))
|
||||
|
||||
splitter = DocumentSplitter(split_by="line", split_length=split_length, split_overlap=overlap)
|
||||
intermediate = splitter.run(documents=[Document(content=unit.source)])["documents"]
|
||||
|
||||
base_meta = self._build_chunk_meta([unit], parent_doc)
|
||||
results: list[Document] = []
|
||||
for idx, piece in enumerate(intermediate):
|
||||
meta = dict(base_meta)
|
||||
meta["secondary_split"] = True
|
||||
meta["secondary_split_index"] = idx
|
||||
meta["secondary_split_total"] = len(intermediate)
|
||||
results.append(Document(content=piece.content or "", meta=meta))
|
||||
return results
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Split each Python source ``Document`` into syntax-aware chunks.
|
||||
|
||||
:param documents: Documents whose ``content`` is Python source code. Each
|
||||
document's ``meta`` is propagated onto its chunks.
|
||||
:returns: ``{"documents": [...]}`` where each chunk's meta additionally carries
|
||||
``source_id``, ``split_id``, ``start_line``, ``end_line``, ``unit_kinds`` and
|
||||
- where applicable - ``include_classes``, ``decorators``, ``docstrings``,
|
||||
``secondary_split``.
|
||||
:raises ValueError: If any document's content is ``None``.
|
||||
:raises TypeError: If any document's content is not a string.
|
||||
:raises SyntaxError: If a document's content is not valid Python.
|
||||
"""
|
||||
for doc in documents:
|
||||
if doc.content is None:
|
||||
raise ValueError(
|
||||
f"PythonCodeSplitter only works with text documents but content for document ID {doc.id} is None."
|
||||
)
|
||||
if not isinstance(doc.content, str):
|
||||
raise TypeError("PythonCodeSplitter only works with text documents (str content).")
|
||||
|
||||
final_docs: list[Document] = []
|
||||
for doc in documents:
|
||||
assert doc.content is not None # narrowed by the loop above
|
||||
if not doc.content.strip():
|
||||
logger.warning("Document ID {doc_id} has empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
|
||||
units = self._extract_units(doc.content)
|
||||
if not units:
|
||||
continue
|
||||
|
||||
chunks = self._merge_units(units)
|
||||
split_id = 0
|
||||
for chunk in chunks:
|
||||
if len(chunk) == 1 and self._is_oversized(chunk[0]):
|
||||
for piece in self._secondary_split(chunk[0], doc):
|
||||
piece.meta["split_id"] = split_id
|
||||
split_id += 1
|
||||
final_docs.append(piece)
|
||||
continue
|
||||
|
||||
content = self._render_chunk_content(chunk)
|
||||
meta = self._build_chunk_meta(chunk, doc)
|
||||
meta["split_id"] = split_id
|
||||
split_id += 1
|
||||
final_docs.append(Document(content=content, meta=meta))
|
||||
|
||||
return {"documents": final_docs}
|
||||
@@ -0,0 +1,484 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports:
|
||||
import tiktoken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class RecursiveDocumentSplitter:
|
||||
"""
|
||||
Recursively chunk text into smaller chunks.
|
||||
|
||||
This component is used to split text into smaller chunks, it does so by recursively applying a list of separators
|
||||
to the text.
|
||||
|
||||
The separators are applied in the order they are provided, typically this is a list of separators that are
|
||||
applied in a specific order, being the last separator the most specific one.
|
||||
|
||||
Each separator is applied to the text, it then checks each of the resulting chunks, it keeps the chunks that
|
||||
are within the split_length, for the ones that are larger than the split_length, it applies the next separator in the
|
||||
list to the remaining text.
|
||||
|
||||
This is done until all chunks are smaller than the split_length parameter.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.preprocessors import RecursiveDocumentSplitter
|
||||
|
||||
chunker = RecursiveDocumentSplitter(split_length=260, split_overlap=0, separators=["\\n\\n", "\\n", ".", " "])
|
||||
text = ('''Artificial intelligence (AI) - Introduction
|
||||
|
||||
AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.
|
||||
AI technology is widely used throughout industry, government, and science. Some high-profile applications include advanced web search engines; recommendation systems; interacting via human speech; autonomous vehicles; generative and creative tools; and superhuman play and analysis in strategy games.''')
|
||||
doc = Document(content=text)
|
||||
doc_chunks = chunker.run([doc])
|
||||
print(doc_chunks["documents"])
|
||||
# [
|
||||
# Document(id=..., content: 'Artificial intelligence (AI) - Introduction\\n\\n', meta: {'original_id': '...', 'split_id': 0, 'split_idx_start': 0, '_split_overlap': []})
|
||||
# Document(id=..., content: 'AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.\\n', meta: {'original_id': '...', 'split_id': 1, 'split_idx_start': 45, '_split_overlap': []})
|
||||
# Document(id=..., content: 'AI technology is widely used throughout industry, government, and science.', meta: {'original_id': '...', 'split_id': 2, 'split_idx_start': 142, '_split_overlap': []})
|
||||
# Document(id=..., content: ' Some high-profile applications include advanced web search engines; recommendation systems; interac...', meta: {'original_id': '...', 'split_id': 3, 'split_idx_start': 216, '_split_overlap': []})
|
||||
# ]
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
split_length: int = 200,
|
||||
split_overlap: int = 0,
|
||||
split_unit: Literal["word", "char", "token"] = "word",
|
||||
separators: list[str] | None = None,
|
||||
sentence_splitter_params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes a RecursiveDocumentSplitter.
|
||||
|
||||
:param split_length: The maximum length of each chunk by default in words, but can be in characters or tokens.
|
||||
See the `split_units` parameter.
|
||||
:param split_overlap: The number of characters to overlap between consecutive chunks.
|
||||
:param split_unit: The unit of the split_length parameter. It can be either "word", "char", or "token".
|
||||
If "token" is selected, the text will be split into tokens using the tiktoken tokenizer (o200k_base).
|
||||
:param separators: An optional list of separator strings to use for splitting the text. The string
|
||||
separators will be treated as regular expressions unless the separator is "sentence", in that case the
|
||||
text will be split into sentences using a custom sentence tokenizer based on NLTK.
|
||||
See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter.
|
||||
If no separators are provided, the default separators ["\\n\\n", "sentence", "\\n", " "] are used.
|
||||
:param sentence_splitter_params: Optional parameters to pass to the sentence tokenizer.
|
||||
See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter for more information.
|
||||
|
||||
:raises ValueError: If the overlap is greater than or equal to the chunk size or if the overlap is negative, or
|
||||
if any separator is not a string.
|
||||
"""
|
||||
self.split_length = split_length
|
||||
self.split_overlap = split_overlap
|
||||
self.split_units = split_unit
|
||||
self.separators = separators if separators else ["\n\n", "sentence", "\n", " "] # default separators
|
||||
self._check_params()
|
||||
self.nltk_tokenizer = None
|
||||
self.sentence_splitter_params = (
|
||||
{"keep_white_spaces": True} if sentence_splitter_params is None else sentence_splitter_params
|
||||
)
|
||||
self.tiktoken_tokenizer: "tiktoken.Encoding" | None = None
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the sentence tokenizer and tiktoken tokenizer if needed.
|
||||
"""
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
if "sentence" in self.separators:
|
||||
self.nltk_tokenizer = self._get_custom_sentence_tokenizer(self.sentence_splitter_params)
|
||||
if self.split_units == "token":
|
||||
tiktoken_imports.check()
|
||||
self.tiktoken_tokenizer = tiktoken.get_encoding("o200k_base")
|
||||
self._is_warmed_up = True
|
||||
|
||||
def _check_params(self) -> None:
|
||||
if self.split_length < 1:
|
||||
raise ValueError("Split length must be at least 1 character.")
|
||||
if self.split_overlap < 0:
|
||||
raise ValueError("Overlap must be greater than zero.")
|
||||
if self.split_overlap >= self.split_length:
|
||||
raise ValueError("Overlap cannot be greater than or equal to the chunk size.")
|
||||
if not all(isinstance(separator, str) for separator in self.separators):
|
||||
raise ValueError("All separators must be strings.")
|
||||
|
||||
@staticmethod
|
||||
def _get_custom_sentence_tokenizer(sentence_splitter_params: dict[str, Any]) -> Any:
|
||||
from haystack.components.preprocessors.sentence_tokenizer import SentenceSplitter
|
||||
|
||||
return SentenceSplitter(**sentence_splitter_params)
|
||||
|
||||
def _split_chunk(self, current_chunk: str) -> tuple[str, str]:
|
||||
"""
|
||||
Splits a chunk based on the split_length and split_units attribute.
|
||||
|
||||
:param current_chunk: The current chunk to be split.
|
||||
:returns:
|
||||
A tuple containing the current chunk and the remaining chunk.
|
||||
"""
|
||||
if self.split_units == "word":
|
||||
words = current_chunk.split()
|
||||
current_chunk = " ".join(words[: self.split_length])
|
||||
remaining_words = words[self.split_length :]
|
||||
return current_chunk, " ".join(remaining_words)
|
||||
if self.split_units == "char":
|
||||
text = current_chunk
|
||||
current_chunk = text[: self.split_length]
|
||||
remaining_chars = text[self.split_length :]
|
||||
return current_chunk, remaining_chars
|
||||
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
tokens = self.tiktoken_tokenizer.encode(current_chunk) # type: ignore
|
||||
current_tokens = tokens[: self.split_length]
|
||||
remaining_tokens = tokens[self.split_length :]
|
||||
return self.tiktoken_tokenizer.decode(current_tokens), self.tiktoken_tokenizer.decode(remaining_tokens) # type: ignore
|
||||
|
||||
def _apply_overlap(self, chunks: list[str]) -> list[str]:
|
||||
"""
|
||||
Applies an overlap between consecutive chunks if the chunk_overlap attribute is greater than zero.
|
||||
|
||||
Works for both word- and character-level splitting. It trims the last chunk if it exceeds the split_length and
|
||||
adds the trimmed content to the next chunk. If the last chunk is still too long after trimming, it splits it
|
||||
and adds the first chunk to the list. This process continues until the last chunk is within the split_length.
|
||||
|
||||
:param chunks: A list of text chunks.
|
||||
:returns:
|
||||
A list of text chunks with the overlap applied.
|
||||
"""
|
||||
overlapped_chunks: list[str] = []
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
if idx == 0:
|
||||
overlapped_chunks.append(chunk)
|
||||
continue
|
||||
|
||||
# get the overlap between the current and previous chunk
|
||||
overlap, prev_chunk = self._get_overlap(overlapped_chunks)
|
||||
if overlap == prev_chunk:
|
||||
logger.warning(
|
||||
"Overlap is the same as the previous chunk. "
|
||||
"Consider increasing the `split_length` parameter or decreasing the `split_overlap` parameter."
|
||||
)
|
||||
|
||||
current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap)
|
||||
|
||||
# if this new chunk exceeds 'split_length', trim it and move the remaining text to the next chunk
|
||||
# if this is the last chunk, another new chunk will contain the trimmed text preceded by the overlap
|
||||
# of the last chunk
|
||||
if self._chunk_length(current_chunk) > self.split_length:
|
||||
current_chunk, remaining_text = self._split_chunk(current_chunk)
|
||||
if idx < len(chunks) - 1:
|
||||
if self.split_units == "word":
|
||||
chunks[idx + 1] = remaining_text + " " + chunks[idx + 1]
|
||||
elif self.split_units == "token":
|
||||
# For token-based splitting, combine at token level
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
remaining_tokens = self.tiktoken_tokenizer.encode(remaining_text) # type: ignore
|
||||
next_chunk_tokens = self.tiktoken_tokenizer.encode(chunks[idx + 1]) # type: ignore
|
||||
chunks[idx + 1] = self.tiktoken_tokenizer.decode(remaining_tokens + next_chunk_tokens) # type: ignore
|
||||
else: # char
|
||||
chunks[idx + 1] = remaining_text + chunks[idx + 1]
|
||||
elif remaining_text:
|
||||
# create a new chunk with the trimmed text preceded by the overlap of the last chunk
|
||||
overlapped_chunks.append(current_chunk)
|
||||
chunk = remaining_text
|
||||
overlap, _ = self._get_overlap(overlapped_chunks)
|
||||
current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap)
|
||||
|
||||
overlapped_chunks.append(current_chunk)
|
||||
|
||||
# it can still be that the new last chunk exceeds the 'split_length'
|
||||
# continue splitting until the last chunk is within 'split_length'
|
||||
if idx == len(chunks) - 1 and self._chunk_length(current_chunk) > self.split_length:
|
||||
last_chunk = overlapped_chunks.pop()
|
||||
first_chunk, remaining_chunk = self._split_chunk(last_chunk)
|
||||
overlapped_chunks.append(first_chunk)
|
||||
|
||||
while remaining_chunk:
|
||||
# combine overlap with remaining chunk
|
||||
overlap, _ = self._get_overlap(overlapped_chunks)
|
||||
current = self._create_chunk_starting_with_overlap(remaining_chunk, overlap)
|
||||
|
||||
# if it fits within split_length we are done
|
||||
if self._chunk_length(current) <= self.split_length:
|
||||
overlapped_chunks.append(current)
|
||||
break
|
||||
|
||||
# otherwise split it again
|
||||
first_chunk, remaining_chunk = self._split_chunk(current)
|
||||
overlapped_chunks.append(first_chunk)
|
||||
|
||||
return overlapped_chunks
|
||||
|
||||
def _create_chunk_starting_with_overlap(self, chunk: str, overlap: str) -> str:
|
||||
if self.split_units == "word":
|
||||
current_chunk = overlap + " " + chunk
|
||||
elif self.split_units == "token":
|
||||
# For token-based splitting, combine at token level
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
overlap_tokens = self.tiktoken_tokenizer.encode(overlap) # type: ignore
|
||||
chunk_tokens = self.tiktoken_tokenizer.encode(chunk) # type: ignore
|
||||
current_chunk = self.tiktoken_tokenizer.decode(overlap_tokens + chunk_tokens) # type: ignore
|
||||
else: # char
|
||||
current_chunk = overlap + chunk
|
||||
return current_chunk
|
||||
|
||||
def _get_overlap(self, overlapped_chunks: list[str]) -> tuple[str, str]:
|
||||
"""Get the previous overlapped chunk instead of the original chunk."""
|
||||
prev_chunk = overlapped_chunks[-1]
|
||||
overlap_start = max(0, self._chunk_length(prev_chunk) - self.split_overlap)
|
||||
|
||||
if self.split_units == "word":
|
||||
word_chunks = prev_chunk.split()
|
||||
overlap = " ".join(word_chunks[overlap_start:])
|
||||
elif self.split_units == "token":
|
||||
# For token-based splitting, handle overlap at token level
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
tokens = self.tiktoken_tokenizer.encode(prev_chunk) # type: ignore
|
||||
overlap_tokens = tokens[overlap_start:]
|
||||
overlap = self.tiktoken_tokenizer.decode(overlap_tokens) # type: ignore
|
||||
else: # char
|
||||
overlap = prev_chunk[overlap_start:]
|
||||
|
||||
return overlap, prev_chunk
|
||||
|
||||
def _chunk_length(self, text: str) -> int:
|
||||
"""
|
||||
Get the length of the chunk in the specified units (words, characters, or tokens).
|
||||
|
||||
:param text: The text to measure.
|
||||
:returns: The length of the text in the specified units.
|
||||
"""
|
||||
if self.split_units == "word":
|
||||
words = [word for word in text.split(" ") if word]
|
||||
return len(words)
|
||||
if self.split_units == "char":
|
||||
return len(text)
|
||||
# token
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
return len(self.tiktoken_tokenizer.encode(text)) # type: ignore
|
||||
|
||||
def _chunk_text(self, text: str) -> list[str]:
|
||||
"""
|
||||
Recursive chunking algorithm that divides text into smaller chunks based on a list of separator characters.
|
||||
|
||||
It starts with a list of separator characters (e.g., ["\n\n", "sentence", "\n", " "]) and attempts to divide
|
||||
the text using the first separator. If the resulting chunks are still larger than the specified chunk size,
|
||||
it moves to the next separator in the list. This process continues recursively, progressively applying each
|
||||
specific separator until the chunks meet the desired size criteria.
|
||||
|
||||
:param text: The text to be split into chunks.
|
||||
:returns:
|
||||
A list of text chunks.
|
||||
"""
|
||||
if self._chunk_length(text) <= self.split_length:
|
||||
return [text]
|
||||
|
||||
for curr_separator in self.separators:
|
||||
if curr_separator == "sentence":
|
||||
# re. ignore: correct SentenceSplitter initialization is checked at the initialization of the component
|
||||
sentence_with_spans = self.nltk_tokenizer.split_sentences(text) # type: ignore
|
||||
splits = [sentence["sentence"] for sentence in sentence_with_spans]
|
||||
else:
|
||||
# add escape "\" to the separator and wrapped it in a group so that it's included in the splits as well
|
||||
escaped_separator = re.escape(curr_separator)
|
||||
escaped_separator = f"({escaped_separator})"
|
||||
|
||||
# split the text and merge every two consecutive splits, i.e.: the text and the separator after it
|
||||
splits = re.split(escaped_separator, text)
|
||||
splits = [
|
||||
"".join([splits[i], splits[i + 1]]) if i < len(splits) - 1 else splits[i]
|
||||
for i in range(0, len(splits), 2)
|
||||
]
|
||||
|
||||
# remove last split if it's empty
|
||||
splits = splits[:-1] if splits[-1] == "" else splits
|
||||
|
||||
if len(splits) == 1: # go to next separator, if current separator not found in the text
|
||||
continue
|
||||
|
||||
chunks = []
|
||||
current_chunk: list[str] = []
|
||||
current_length = 0
|
||||
|
||||
# check splits, if any is too long, recursively chunk it, otherwise add to current chunk
|
||||
for split in splits:
|
||||
split_text = split
|
||||
|
||||
# if adding this split exceeds chunk_size, process current_chunk
|
||||
if current_length + self._chunk_length(split_text) > self.split_length:
|
||||
# process current_chunk
|
||||
if current_chunk: # keep the good splits
|
||||
chunks.append("".join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
# recursively handle splits that are too large
|
||||
if self._chunk_length(split_text) > self.split_length:
|
||||
if curr_separator == self.separators[-1]:
|
||||
# tried last separator, can't split further, do a fixed-split based on word/character/token
|
||||
fall_back_chunks = self._fall_back_to_fixed_chunking(split_text, self.split_units)
|
||||
chunks.extend(fall_back_chunks)
|
||||
else:
|
||||
chunks.extend(self._chunk_text(split_text))
|
||||
|
||||
else:
|
||||
current_chunk.append(split_text)
|
||||
current_length += self._chunk_length(split_text)
|
||||
else:
|
||||
current_chunk.append(split_text)
|
||||
current_length += self._chunk_length(split_text)
|
||||
|
||||
if current_chunk:
|
||||
chunks.append("".join(current_chunk))
|
||||
|
||||
if self.split_overlap > 0:
|
||||
chunks = self._apply_overlap(chunks)
|
||||
|
||||
if chunks:
|
||||
return chunks
|
||||
|
||||
# if no separator worked, fall back to word- or character-level chunking
|
||||
chunks = self._fall_back_to_fixed_chunking(text, self.split_units)
|
||||
if self.split_overlap > 0:
|
||||
chunks = self._apply_overlap(chunks)
|
||||
return chunks
|
||||
|
||||
def _fall_back_to_fixed_chunking(self, text: str, split_units: Literal["word", "char", "token"]) -> list[str]:
|
||||
"""
|
||||
Fall back to a fixed chunking approach if no separator works for the text.
|
||||
|
||||
Splits the text into smaller chunks based on the split_length and split_units attributes, either by words,
|
||||
characters, or tokens.
|
||||
|
||||
:param text: The text to be split into chunks.
|
||||
:param split_units: The unit of the split_length parameter. It can be either "word", "char", or "token".
|
||||
:returns:
|
||||
A list of text chunks.
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
if split_units == "word":
|
||||
words = re.findall(r"\S+|\s+", text)
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
for word in words:
|
||||
if word != " ":
|
||||
current_chunk.append(word)
|
||||
current_length += 1
|
||||
if current_length == self.split_length and current_chunk:
|
||||
chunks.append("".join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
else:
|
||||
current_chunk.append(word)
|
||||
|
||||
if current_chunk:
|
||||
chunks.append("".join(current_chunk))
|
||||
elif split_units == "char":
|
||||
for i in range(0, self._chunk_length(text), self.split_length):
|
||||
chunks.append(text[i : i + self.split_length])
|
||||
else: # token
|
||||
# at this point we know that the tokenizer is already initialized
|
||||
tokens = self.tiktoken_tokenizer.encode(text) # type: ignore
|
||||
for i in range(0, len(tokens), self.split_length):
|
||||
chunk_tokens = tokens[i : i + self.split_length]
|
||||
chunks.append(self.tiktoken_tokenizer.decode(chunk_tokens)) # type: ignore
|
||||
return chunks
|
||||
|
||||
def _add_overlap_info(self, curr_pos: int, new_doc: Document, new_docs: list[Document]) -> None:
|
||||
prev_doc = new_docs[-1]
|
||||
# curr_pos and split_idx_start are character offsets, so measure the
|
||||
# overlap and range in characters too (not via _chunk_length, which returns a word/token count).
|
||||
prev_doc_length = len(prev_doc.content) # type: ignore
|
||||
overlap_length = prev_doc_length - (curr_pos - prev_doc.meta["split_idx_start"])
|
||||
if overlap_length > 0:
|
||||
prev_doc.meta["_split_overlap"].append({"doc_id": new_doc.id, "range": (0, overlap_length)})
|
||||
new_doc.meta["_split_overlap"].append(
|
||||
{"doc_id": prev_doc.id, "range": (prev_doc_length - overlap_length, prev_doc_length)}
|
||||
)
|
||||
|
||||
def _run_one(self, doc: Document) -> list[Document]:
|
||||
chunks = self._chunk_text(doc.content) # type: ignore # the caller already check for a non-empty doc.content
|
||||
chunks = chunks[:-1] if len(chunks[-1]) == 0 else chunks # remove last empty chunk if it exists
|
||||
current_position = 0
|
||||
current_page = 1
|
||||
|
||||
new_docs: list[Document] = []
|
||||
|
||||
for split_nr, chunk in enumerate(chunks):
|
||||
meta = deepcopy(doc.meta)
|
||||
meta["parent_id"] = doc.id
|
||||
meta["split_id"] = split_nr
|
||||
meta["split_idx_start"] = current_position
|
||||
meta["_split_overlap"] = [] if self.split_overlap > 0 else None
|
||||
new_doc = Document(content=chunk, meta=meta)
|
||||
|
||||
# add overlap information to the previous and current doc
|
||||
if split_nr > 0 and self.split_overlap > 0:
|
||||
self._add_overlap_info(current_position, new_doc, new_docs)
|
||||
|
||||
# count page breaks in the chunk
|
||||
current_page += chunk.count("\f")
|
||||
|
||||
# if there are consecutive page breaks at the end with no more text, adjust the page number
|
||||
# e.g: "text\f\f\f" -> 3 page breaks, but current_page should be 1
|
||||
consecutive_page_breaks = len(chunk) - len(chunk.rstrip("\f"))
|
||||
|
||||
if consecutive_page_breaks > 0:
|
||||
new_doc.meta["page_number"] = current_page - consecutive_page_breaks
|
||||
else:
|
||||
new_doc.meta["page_number"] = current_page
|
||||
|
||||
# keep the new chunk doc and update the current position
|
||||
new_docs.append(new_doc)
|
||||
# Advance current_position by chunk length minus overlap.
|
||||
# split_overlap is in split_units, not chars, so get the actual
|
||||
# overlap string from _get_overlap() and use its char length.
|
||||
if self.split_overlap > 0 and split_nr < len(chunks) - 1:
|
||||
overlap_str, _ = self._get_overlap([doc.content for doc in new_docs]) # type: ignore[misc]
|
||||
overlap_char_len = len(overlap_str)
|
||||
else:
|
||||
overlap_char_len = 0
|
||||
current_position += len(chunk) - overlap_char_len
|
||||
|
||||
return new_docs
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Split a list of documents into documents with smaller chunks of text.
|
||||
|
||||
:param documents: List of Documents to split.
|
||||
:returns:
|
||||
A dictionary containing a key "documents" with a List of Documents with smaller chunks of text corresponding
|
||||
to the input documents.
|
||||
"""
|
||||
if not self._is_warmed_up and ("sentence" in self.separators or self.split_units == "token"):
|
||||
self.warm_up()
|
||||
|
||||
docs = []
|
||||
for doc in documents:
|
||||
if not doc.content or doc.content == "":
|
||||
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
|
||||
continue
|
||||
docs.extend(self._run_one(doc))
|
||||
|
||||
return {"documents": docs}
|
||||
@@ -0,0 +1,237 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import logging
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install nltk>=3.9.1'") as nltk_imports:
|
||||
import nltk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Language = Literal[
|
||||
"ru", "sl", "es", "sv", "tr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "it", "no", "pl", "pt", "ml"
|
||||
]
|
||||
|
||||
ISO639_TO_NLTK = {
|
||||
"ru": "russian",
|
||||
"sl": "slovene",
|
||||
"es": "spanish",
|
||||
"sv": "swedish",
|
||||
"tr": "turkish",
|
||||
"cs": "czech",
|
||||
"da": "danish",
|
||||
"nl": "dutch",
|
||||
"en": "english",
|
||||
"et": "estonian",
|
||||
"fi": "finnish",
|
||||
"fr": "french",
|
||||
"de": "german",
|
||||
"el": "greek",
|
||||
"it": "italian",
|
||||
"no": "norwegian",
|
||||
"pl": "polish",
|
||||
"pt": "portuguese",
|
||||
"ml": "malayalam",
|
||||
}
|
||||
|
||||
QUOTE_SPANS_RE = re.compile(r'"[^"]*"|\'[^\']*\'')
|
||||
|
||||
if nltk_imports.is_successful():
|
||||
|
||||
def load_sentence_tokenizer(
|
||||
language: Language, keep_white_spaces: bool = False
|
||||
) -> nltk.tokenize.punkt.PunktSentenceTokenizer:
|
||||
"""
|
||||
Utility function to load the nltk sentence tokenizer.
|
||||
|
||||
:param language: The language for the tokenizer.
|
||||
:param keep_white_spaces: If True, the tokenizer will keep white spaces between sentences.
|
||||
:returns: nltk sentence tokenizer.
|
||||
"""
|
||||
try:
|
||||
nltk.data.find("tokenizers/punkt_tab")
|
||||
except LookupError:
|
||||
try:
|
||||
nltk.download("punkt_tab")
|
||||
except FileExistsError as error:
|
||||
logger.debug("NLTK punkt tokenizer seems to be already downloaded. Error message: {error}", error=error)
|
||||
|
||||
language_name = ISO639_TO_NLTK.get(language)
|
||||
|
||||
if language_name is not None:
|
||||
sentence_tokenizer = nltk.data.load(f"tokenizers/punkt_tab/{language_name}.pickle")
|
||||
else:
|
||||
logger.warning(
|
||||
"PreProcessor couldn't find the default sentence tokenizer model for {language}. "
|
||||
" Using English instead. You may train your own model and use the 'tokenizer_model_folder' parameter.",
|
||||
language=language,
|
||||
)
|
||||
sentence_tokenizer = nltk.data.load("tokenizers/punkt_tab/english.pickle")
|
||||
|
||||
if keep_white_spaces:
|
||||
sentence_tokenizer._lang_vars = CustomPunktLanguageVars()
|
||||
|
||||
return sentence_tokenizer
|
||||
|
||||
class CustomPunktLanguageVars(nltk.tokenize.punkt.PunktLanguageVars):
|
||||
# The following adjustment of PunktSentenceTokenizer is inspired by:
|
||||
# https://stackoverflow.com/questions/33139531/preserve-empty-lines-with-nltks-punkt-tokenizer
|
||||
# It is needed for preserving whitespace while splitting text into sentences.
|
||||
_period_context_fmt = r"""
|
||||
%(SentEndChars)s # a potential sentence ending
|
||||
\s* # match potential whitespace [ \t\n\x0B\f\r]
|
||||
(?=(?P<after_tok>
|
||||
%(NonWord)s # either other punctuation
|
||||
|
|
||||
(?P<next_tok>\S+) # or some other token - original version: \s+(?P<next_tok>\S+)
|
||||
))"""
|
||||
|
||||
def period_context_re(self) -> re.Pattern:
|
||||
"""
|
||||
Compiles and returns a regular expression to find contexts including possible sentence boundaries.
|
||||
|
||||
:returns: A compiled regular expression pattern.
|
||||
"""
|
||||
try:
|
||||
return self._re_period_context # type: ignore
|
||||
except: # noqa: E722
|
||||
self._re_period_context = re.compile(
|
||||
self._period_context_fmt
|
||||
% {
|
||||
"NonWord": self._re_non_word_chars,
|
||||
# SentEndChars might be followed by closing brackets, so we match them here.
|
||||
"SentEndChars": self._re_sent_end_chars + r"[\)\]}]*",
|
||||
},
|
||||
re.UNICODE | re.VERBOSE,
|
||||
)
|
||||
return self._re_period_context
|
||||
|
||||
|
||||
class SentenceSplitter:
|
||||
"""
|
||||
SentenceSplitter splits a text into sentences using the nltk sentence tokenizer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language: Language = "en",
|
||||
use_split_rules: bool = True,
|
||||
extend_abbreviations: bool = True,
|
||||
keep_white_spaces: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the SentenceSplitter with the specified language, split rules, and abbreviation handling.
|
||||
|
||||
:param language: The language for the tokenizer. Default is "en".
|
||||
:param use_split_rules: If True, the additional split rules are used. If False, the rules are not used.
|
||||
:param extend_abbreviations: If True, the abbreviations used by NLTK's PunktTokenizer are extended by a list
|
||||
of curated abbreviations if available. If False, the default abbreviations are used.
|
||||
Currently supported languages are: en, de.
|
||||
:param keep_white_spaces: If True, the tokenizer will keep white spaces between sentences.
|
||||
"""
|
||||
nltk_imports.check()
|
||||
self.language = language
|
||||
# after checking nltk_imports, we are sure that load_sentence_tokenizer is defined
|
||||
self.sentence_tokenizer = load_sentence_tokenizer(language, keep_white_spaces=keep_white_spaces)
|
||||
self.use_split_rules = use_split_rules
|
||||
if extend_abbreviations:
|
||||
abbreviations = SentenceSplitter._read_abbreviations(language)
|
||||
self.sentence_tokenizer._params.abbrev_types.update(abbreviations)
|
||||
self.keep_white_spaces = keep_white_spaces
|
||||
|
||||
def split_sentences(self, text: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Splits a text into sentences including references to original char positions for each split.
|
||||
|
||||
:param text: The text to split.
|
||||
:returns: list of sentences with positions.
|
||||
"""
|
||||
sentence_spans = list(self.sentence_tokenizer.span_tokenize(text))
|
||||
if self.use_split_rules:
|
||||
sentence_spans = SentenceSplitter._apply_split_rules(text, sentence_spans)
|
||||
|
||||
return [{"sentence": text[start:end], "start": start, "end": end} for start, end in sentence_spans]
|
||||
|
||||
@staticmethod
|
||||
def _apply_split_rules(text: str, sentence_spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Applies additional split rules to the sentence spans.
|
||||
|
||||
:param text: The text to split.
|
||||
:param sentence_spans: The list of sentence spans to split.
|
||||
:returns: The list of sentence spans after applying the split rules.
|
||||
"""
|
||||
new_sentence_spans = []
|
||||
quote_spans = [match.span() for match in QUOTE_SPANS_RE.finditer(text)]
|
||||
while sentence_spans:
|
||||
span = sentence_spans.pop(0)
|
||||
next_span = sentence_spans[0] if len(sentence_spans) > 0 else None
|
||||
while next_span and SentenceSplitter._needs_join(text, span, next_span, quote_spans):
|
||||
sentence_spans.pop(0)
|
||||
span = (span[0], next_span[1])
|
||||
next_span = sentence_spans[0] if len(sentence_spans) > 0 else None
|
||||
start, end = span
|
||||
new_sentence_spans.append((start, end))
|
||||
return new_sentence_spans
|
||||
|
||||
@staticmethod
|
||||
def _needs_join(
|
||||
text: str, span: tuple[int, int], next_span: tuple[int, int], quote_spans: list[tuple[int, int]]
|
||||
) -> bool:
|
||||
"""
|
||||
Checks if the spans need to be joined as parts of one sentence.
|
||||
|
||||
This method determines whether two adjacent sentence spans should be joined back together as a single sentence.
|
||||
It's used to prevent incorrect sentence splitting in specific cases like quotations, numbered lists,
|
||||
and parenthetical expressions.
|
||||
|
||||
:param text: The text containing the spans.
|
||||
:param span: Tuple of (start, end) positions for the current sentence span.
|
||||
:param next_span: Tuple of (start, end) positions for the next sentence span.
|
||||
:param quote_spans: All quoted spans within text.
|
||||
:returns:
|
||||
True if the spans needs to be joined.
|
||||
"""
|
||||
start, end = span
|
||||
next_start, next_end = next_span
|
||||
|
||||
# sentence. sentence"\nsentence -> no split (end << quote_end)
|
||||
# sentence.", sentence -> no split (end < quote_end)
|
||||
# sentence?", sentence -> no split (end < quote_end)
|
||||
if any(quote_start < end < quote_end for quote_start, quote_end in quote_spans):
|
||||
# sentence boundary is inside a quote
|
||||
return True
|
||||
|
||||
# sentence." sentence -> split (end == quote_end)
|
||||
# sentence?" sentence -> no split (end == quote_end)
|
||||
if any(quote_start < end == quote_end and text[quote_end - 2] == "?" for quote_start, quote_end in quote_spans):
|
||||
# question is cited
|
||||
return True
|
||||
|
||||
if re.search(r"(^|\n)\s*\d{1,2}\.$", text[start:end]) is not None:
|
||||
# sentence ends with a numeration
|
||||
return True
|
||||
|
||||
# next sentence starts with a bracket or we return False
|
||||
return re.search(r"^\s*[\(\[]", text[next_start:next_end]) is not None
|
||||
|
||||
@staticmethod
|
||||
def _read_abbreviations(lang: Language) -> list[str]:
|
||||
"""
|
||||
Reads the abbreviations for a given language from the abbreviations file.
|
||||
|
||||
:param lang: The language to read the abbreviations for.
|
||||
:returns: List of abbreviations.
|
||||
"""
|
||||
abbreviations_file = Path(__file__).parent.parent.parent / f"data/abbreviations/{lang}.txt"
|
||||
if not abbreviations_file.exists():
|
||||
logger.warning("No abbreviations file found for {language}. Using default abbreviations.", language=lang)
|
||||
return []
|
||||
|
||||
return abbreviations_file.read_text().split("\n")
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
from haystack import component
|
||||
|
||||
|
||||
@component
|
||||
class TextCleaner:
|
||||
"""
|
||||
Cleans text strings.
|
||||
|
||||
It can remove substrings matching a list of regular expressions, convert text to lowercase,
|
||||
remove punctuation, and remove numbers.
|
||||
Use it to clean up text data before evaluation.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.preprocessors import TextCleaner
|
||||
|
||||
text_to_clean = "1Moonlight shimmered softly, 300 Wolves howled nearby, Night enveloped everything."
|
||||
|
||||
cleaner = TextCleaner(convert_to_lowercase=True, remove_punctuation=False, remove_numbers=True)
|
||||
result = cleaner.run(texts=[text_to_clean])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remove_regexps: list[str] | None = None,
|
||||
convert_to_lowercase: bool = False,
|
||||
remove_punctuation: bool = False,
|
||||
remove_numbers: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the TextCleaner component.
|
||||
|
||||
:param remove_regexps: A list of regex patterns to remove matching substrings from the text.
|
||||
:param convert_to_lowercase: If `True`, converts all characters to lowercase.
|
||||
:param remove_punctuation: If `True`, removes punctuation from the text.
|
||||
:param remove_numbers: If `True`, removes numerical digits from the text.
|
||||
"""
|
||||
self._remove_regexps = remove_regexps
|
||||
self._convert_to_lowercase = convert_to_lowercase
|
||||
self._remove_punctuation = remove_punctuation
|
||||
self._remove_numbers = remove_numbers
|
||||
|
||||
self._regex = None
|
||||
if remove_regexps:
|
||||
self._regex = re.compile("|".join(remove_regexps), flags=re.IGNORECASE)
|
||||
to_remove = ""
|
||||
if remove_punctuation:
|
||||
to_remove = string.punctuation
|
||||
if remove_numbers:
|
||||
to_remove += string.digits
|
||||
|
||||
self._translator = str.maketrans("", "", to_remove) if to_remove else None
|
||||
|
||||
@component.output_types(texts=list[str])
|
||||
def run(self, texts: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
Cleans up the given list of strings.
|
||||
|
||||
:param texts: List of strings to clean.
|
||||
:returns: A dictionary with the following key:
|
||||
- `texts`: the cleaned list of strings.
|
||||
"""
|
||||
|
||||
if self._regex:
|
||||
texts = [self._regex.sub("", text) for text in texts]
|
||||
|
||||
if self._convert_to_lowercase:
|
||||
texts = [text.lower() for text in texts]
|
||||
|
||||
if self._translator:
|
||||
texts = [text.translate(self._translator) for text in texts]
|
||||
|
||||
return {"texts": texts}
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"query_expander": ["QueryExpander"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .query_expander import QueryExpander as QueryExpander
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,373 @@
|
||||
# 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
|
||||
@@ -0,0 +1,24 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"llm_ranker": ["LLMRanker"],
|
||||
"lost_in_the_middle": ["LostInTheMiddleRanker"],
|
||||
"meta_field": ["MetaFieldRanker"],
|
||||
"meta_field_grouping_ranker": ["MetaFieldGroupingRanker"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .llm_ranker import LLMRanker as LLMRanker
|
||||
from .lost_in_the_middle import LostInTheMiddleRanker as LostInTheMiddleRanker
|
||||
from .meta_field import MetaFieldRanker as MetaFieldRanker
|
||||
from .meta_field_grouping_ranker import MetaFieldGroupingRanker as MetaFieldGroupingRanker
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,403 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.components.generators.chat.openai import OpenAIChatGenerator
|
||||
from haystack.components.generators.chat.types import ChatGenerator
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.utils import deserialize_chatgenerator_inplace
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.misc import _deduplicate_documents, _parse_dict_from_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_openai_chat_generator() -> ChatGenerator:
|
||||
return OpenAIChatGenerator(
|
||||
model="gpt-4.1-mini",
|
||||
generation_kwargs={
|
||||
"temperature": 0.0,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "document_ranking",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"index": {"type": "integer"}},
|
||||
"required": ["index"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["documents"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_PROMPT_TEMPLATE = """
|
||||
You are ranking retrieved documents for relevance to a query.
|
||||
|
||||
Return valid JSON only, with this structure:
|
||||
{
|
||||
"documents": [
|
||||
{"index": 1}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Rank documents from most relevant to least relevant for answering the query.
|
||||
- Only include documents that are relevant to the query.
|
||||
- Do not return or rank documents that are not relevant.
|
||||
- If none are relevant, return {"documents": []}.
|
||||
- Use only document indices from the provided documents.
|
||||
- Do not repeat document indices.
|
||||
- Do not include explanations or any text outside the JSON object.
|
||||
|
||||
Query:
|
||||
{{ query }}
|
||||
|
||||
Documents:
|
||||
{% for document in documents %}
|
||||
Document {{ loop.index }}:
|
||||
content: {{ document.content or "" }}
|
||||
|
||||
{% endfor %}
|
||||
""".strip()
|
||||
|
||||
|
||||
@component
|
||||
class LLMRanker:
|
||||
"""
|
||||
Ranks documents for a query using a Large Language Model.
|
||||
|
||||
The LLM is expected to return a JSON object containing ranked document indices.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.rankers import LLMRanker
|
||||
|
||||
chat_generator = OpenAIChatGenerator(
|
||||
model="gpt-4.1-mini",
|
||||
generation_kwargs={
|
||||
"temperature": 0.0,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "document_ranking",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"index": {"type": "integer"}},
|
||||
"required": ["index"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["documents"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
ranker = LLMRanker(chat_generator=chat_generator)
|
||||
|
||||
documents = [
|
||||
Document(id="paris", content="Paris is the capital of France."),
|
||||
Document(id="berlin", content="Berlin is the capital of Germany."),
|
||||
]
|
||||
|
||||
result = ranker.run(query="capital of Germany", documents=documents)
|
||||
print(result["documents"][0].id)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chat_generator: ChatGenerator | None = None,
|
||||
prompt: str = DEFAULT_PROMPT_TEMPLATE,
|
||||
top_k: int = 10,
|
||||
raise_on_failure: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the LLMRanker component.
|
||||
|
||||
:param chat_generator:
|
||||
The chat generator to use for reranking. If `None`, a default `OpenAIChatGenerator` configured for JSON
|
||||
output is used.
|
||||
:param prompt:
|
||||
Custom prompt template for reranking. The prompt must include exactly the variables `query` and
|
||||
`documents` and instruct the LLM to return ranked 1-based document indices as JSON.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param raise_on_failure:
|
||||
If `True`, raise when generation or response parsing fails. If `False`, log the failure and return the
|
||||
input documents in fallback order.
|
||||
"""
|
||||
if top_k <= 0:
|
||||
raise ValueError(f"top_k must be > 0, but got {top_k}")
|
||||
|
||||
self.top_k = top_k
|
||||
self.raise_on_failure = raise_on_failure
|
||||
self.prompt = prompt
|
||||
self._prompt_builder = PromptBuilder(template=self.prompt, required_variables=["documents", "query"])
|
||||
if set(self._prompt_builder.variables) != {"documents", "query"}:
|
||||
raise ValueError("prompt must include exactly the variables 'documents' and 'query'.")
|
||||
|
||||
if chat_generator is None:
|
||||
self._chat_generator = _default_openai_chat_generator()
|
||||
else:
|
||||
self._chat_generator = chat_generator
|
||||
|
||||
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()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize this component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
|
||||
prompt=self.prompt,
|
||||
top_k=self.top_k,
|
||||
raise_on_failure=self.raise_on_failure,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "LLMRanker":
|
||||
"""
|
||||
Deserialize this component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary representation of the component.
|
||||
:returns:
|
||||
The deserialized component instance.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
if init_params.get("chat_generator"):
|
||||
deserialize_chatgenerator_inplace(init_params, key="chat_generator")
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, query: str, documents: list[Document], top_k: int | None = None) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Rank documents for a query using an LLM.
|
||||
|
||||
Before ranking, duplicate documents are removed.
|
||||
|
||||
:param query:
|
||||
The query used for reranking.
|
||||
:param documents:
|
||||
Candidate documents to rerank.
|
||||
:param top_k:
|
||||
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
|
||||
:returns:
|
||||
A dictionary with the ranked documents under the `documents` key.
|
||||
"""
|
||||
if top_k is not None and top_k <= 0:
|
||||
raise ValueError(f"top_k must be > 0, but got {top_k}")
|
||||
|
||||
if not documents:
|
||||
return {"documents": []}
|
||||
|
||||
top_k = self.top_k if top_k is None else top_k
|
||||
deduplicated_documents = _deduplicate_documents(documents)
|
||||
fallback_documents = deduplicated_documents
|
||||
|
||||
if not query.strip():
|
||||
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
self.warm_up()
|
||||
|
||||
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
|
||||
|
||||
try:
|
||||
result = self._chat_generator.run(messages=[ChatMessage.from_user(prompt["prompt"])])
|
||||
except Exception as exc:
|
||||
if self.raise_on_failure:
|
||||
raise
|
||||
logger.warning(
|
||||
"LLMRanker failed during chat generation. Returning fallback order. Error: {error}", error=exc
|
||||
)
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
try:
|
||||
reply_text = self._get_reply_text(result)
|
||||
ranked_documents = self._rank_documents_from_reply(reply_text=reply_text, documents=deduplicated_documents)
|
||||
except (TypeError, ValueError) as exc:
|
||||
if self.raise_on_failure:
|
||||
raise
|
||||
logger.warning(
|
||||
"LLMRanker failed while processing the chat response. Returning fallback order. Error: {error}",
|
||||
error=exc,
|
||||
)
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
return {"documents": ranked_documents[:top_k]}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self, query: str, documents: list[Document], top_k: int | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Asynchronously rank documents for a query using an LLM.
|
||||
|
||||
Before ranking, duplicate documents are removed.
|
||||
|
||||
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 query used for reranking.
|
||||
:param documents:
|
||||
Candidate documents to rerank.
|
||||
:param top_k:
|
||||
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
|
||||
:returns:
|
||||
A dictionary with the ranked documents under the `documents` key.
|
||||
"""
|
||||
if top_k is not None and top_k <= 0:
|
||||
raise ValueError(f"top_k must be > 0, but got {top_k}")
|
||||
|
||||
if not documents:
|
||||
return {"documents": []}
|
||||
|
||||
top_k = self.top_k if top_k is None else top_k
|
||||
deduplicated_documents = _deduplicate_documents(documents)
|
||||
fallback_documents = deduplicated_documents
|
||||
|
||||
if not query.strip():
|
||||
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
|
||||
|
||||
try:
|
||||
result = await _execute_component_async(
|
||||
self._chat_generator, messages=[ChatMessage.from_user(prompt["prompt"])]
|
||||
)
|
||||
except Exception as exc:
|
||||
if self.raise_on_failure:
|
||||
raise
|
||||
logger.warning(
|
||||
"LLMRanker failed during chat generation. Returning fallback order. Error: {error}", error=exc
|
||||
)
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
try:
|
||||
reply_text = self._get_reply_text(result)
|
||||
ranked_documents = self._rank_documents_from_reply(reply_text=reply_text, documents=deduplicated_documents)
|
||||
except (TypeError, ValueError) as exc:
|
||||
if self.raise_on_failure:
|
||||
raise
|
||||
logger.warning(
|
||||
"LLMRanker failed while processing the chat response. Returning fallback order. Error: {error}",
|
||||
error=exc,
|
||||
)
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
return {"documents": ranked_documents[:top_k]}
|
||||
|
||||
@staticmethod
|
||||
def _get_reply_text(result: dict[str, Any]) -> str:
|
||||
replies = result.get("replies") or []
|
||||
if not replies:
|
||||
raise ValueError("ChatGenerator returned no replies.")
|
||||
|
||||
reply_text = replies[0].text
|
||||
if reply_text is None:
|
||||
raise ValueError("ChatGenerator returned a reply without text.")
|
||||
|
||||
return reply_text
|
||||
|
||||
@staticmethod
|
||||
def _rank_documents_from_reply(reply_text: str, documents: list[Document]) -> list[Document]:
|
||||
parsed_response = _parse_dict_from_json(reply_text, expected_keys=["documents"], raise_on_failure=True)
|
||||
ranked_entries = parsed_response["documents"]
|
||||
|
||||
if not isinstance(ranked_entries, list):
|
||||
raise TypeError("Expected 'documents' in ranking response to be a list.")
|
||||
|
||||
if not ranked_entries:
|
||||
return []
|
||||
|
||||
ranked_documents: list[Document] = []
|
||||
|
||||
for entry in ranked_entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise TypeError("Expected each ranked document entry to be a JSON object.")
|
||||
|
||||
document_index = entry.get("index")
|
||||
if document_index is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
# LLMs can return numeric indices as strings even when asked for integers.
|
||||
document_index = int(document_index)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
# Jinja's `loop.index` is 1-based:
|
||||
# https://jinja.palletsprojects.com/en/stable/templates/#for
|
||||
if document_index < 1 or document_index > len(documents):
|
||||
continue
|
||||
|
||||
document = documents[document_index - 1]
|
||||
ranked_documents.append(document)
|
||||
|
||||
if not ranked_documents:
|
||||
raise ValueError("Ranking response did not contain any valid document indices.")
|
||||
|
||||
return ranked_documents
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
from haystack import Document, component
|
||||
from haystack.utils.misc import _deduplicate_documents
|
||||
|
||||
|
||||
@component
|
||||
class LostInTheMiddleRanker:
|
||||
"""
|
||||
A LostInTheMiddle Ranker.
|
||||
|
||||
Ranks documents based on the 'lost in the middle' order so that the most relevant documents are either at the
|
||||
beginning or end, while the least relevant are in the middle.
|
||||
|
||||
LostInTheMiddleRanker assumes that some prior component in the pipeline has already ranked documents by relevance
|
||||
and requires no query as input but only documents. It is typically used as the last component before building a
|
||||
prompt for an LLM to prepare the input context for the LLM.
|
||||
|
||||
Lost in the Middle ranking lays out document contents into LLM context so that the most relevant contents are at
|
||||
the beginning or end of the input context, while the least relevant is in the middle of the context. See the
|
||||
paper ["Lost in the Middle: How Language Models Use Long Contexts"](https://arxiv.org/abs/2307.03172) for more
|
||||
details.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.rankers import LostInTheMiddleRanker
|
||||
from haystack import Document
|
||||
|
||||
ranker = LostInTheMiddleRanker()
|
||||
docs = [Document(content="Paris"), Document(content="Berlin"), Document(content="Madrid")]
|
||||
result = ranker.run(documents=docs)
|
||||
for doc in result["documents"]:
|
||||
print(doc.content)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, word_count_threshold: int | None = None, top_k: int | None = None) -> None:
|
||||
"""
|
||||
Initialize the LostInTheMiddleRanker.
|
||||
|
||||
If 'word_count_threshold' is specified, this ranker includes all documents up until the point where adding
|
||||
another document would exceed the 'word_count_threshold'. The last document that causes the threshold to
|
||||
be breached will be included in the resulting list of documents, but all subsequent documents will be
|
||||
discarded.
|
||||
|
||||
:param word_count_threshold: The maximum total number of words across all documents selected by the ranker.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
"""
|
||||
if isinstance(word_count_threshold, int) and word_count_threshold <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid value for word_count_threshold: {word_count_threshold}. word_count_threshold must be > 0."
|
||||
)
|
||||
if isinstance(top_k, int) and top_k <= 0:
|
||||
raise ValueError(f"top_k must be > 0, but got {top_k}")
|
||||
|
||||
self.word_count_threshold = word_count_threshold
|
||||
self.top_k = top_k
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, documents: list[Document], top_k: int | None = None, word_count_threshold: int | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Reranks documents based on the "lost in the middle" order.
|
||||
|
||||
Before ranking, documents are deduplicated by their id, retaining only the document with the highest score
|
||||
if a score is present.
|
||||
|
||||
:param documents: List of Documents to reorder.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
:param word_count_threshold: The maximum total number of words across all documents selected by the ranker.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Reranked list of Documents
|
||||
|
||||
:raises ValueError:
|
||||
If any of the documents is not textual.
|
||||
"""
|
||||
if isinstance(word_count_threshold, int) and word_count_threshold <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid value for word_count_threshold: {word_count_threshold}. word_count_threshold must be > 0."
|
||||
)
|
||||
if isinstance(top_k, int) and top_k <= 0:
|
||||
raise ValueError(f"top_k must be > 0, but got {top_k}")
|
||||
|
||||
if not documents:
|
||||
return {"documents": []}
|
||||
|
||||
top_k = top_k or self.top_k
|
||||
word_count_threshold = word_count_threshold or self.word_count_threshold
|
||||
|
||||
deduplicated_documents = _deduplicate_documents(documents)
|
||||
documents_to_reorder = deduplicated_documents[:top_k] if top_k else deduplicated_documents
|
||||
|
||||
# If there's only one document, return it as is
|
||||
if len(documents_to_reorder) == 1:
|
||||
return {"documents": documents_to_reorder}
|
||||
|
||||
# Raise an error if any document is not textual
|
||||
if any(not doc.content_type == "text" for doc in documents_to_reorder):
|
||||
raise ValueError("Some provided documents are not textual; LostInTheMiddleRanker can process only text.")
|
||||
|
||||
# Initialize word count and indices for the "lost in the middle" order
|
||||
word_count = 0
|
||||
document_index = list(range(len(documents_to_reorder)))
|
||||
lost_in_the_middle_indices = [0]
|
||||
|
||||
# If word count threshold is set and the first document has content, calculate word count for the first document
|
||||
if word_count_threshold and documents_to_reorder[0].content:
|
||||
word_count = len(documents_to_reorder[0].content.split())
|
||||
|
||||
# If the first document already meets the word count threshold, return it
|
||||
if word_count >= word_count_threshold:
|
||||
return {"documents": [documents_to_reorder[0]]}
|
||||
|
||||
# Start from the second document and create "lost in the middle" order
|
||||
for doc_idx in document_index[1:]:
|
||||
# Calculate the index at which the current document should be inserted
|
||||
insertion_index = len(lost_in_the_middle_indices) // 2 + len(lost_in_the_middle_indices) % 2
|
||||
|
||||
# Insert the document index at the calculated position
|
||||
lost_in_the_middle_indices.insert(insertion_index, doc_idx)
|
||||
|
||||
# If word count threshold is set and the document has content, calculate the total word count
|
||||
if word_count_threshold and documents_to_reorder[doc_idx].content:
|
||||
word_count += len(documents_to_reorder[doc_idx].content.split()) # type: ignore[union-attr]
|
||||
|
||||
# If the total word count meets the threshold, stop processing further documents
|
||||
if word_count >= word_count_threshold:
|
||||
break
|
||||
|
||||
# Documents in the "lost in the middle" order
|
||||
ranked_docs = [documents_to_reorder[idx] for idx in lost_in_the_middle_indices]
|
||||
return {"documents": ranked_docs}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user