c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
475 lines
22 KiB
Python
475 lines
22 KiB
Python
# 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}
|