Coverage for haystack/components/extractors/llm_metadata_extractor.py: 93%

166 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-09 14:34 +0000

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

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import copy 

6import json 

7from asyncio import Semaphore, gather 

8from concurrent.futures import ThreadPoolExecutor 

9from dataclasses import replace 

10from typing import Any 

11 

12from jinja2 import meta 

13from jinja2.sandbox import SandboxedEnvironment 

14 

15from haystack import Document, component, default_from_dict, default_to_dict, logging, tracing 

16from haystack.components.builders import PromptBuilder 

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

18from haystack.components.generators.utils import _trace_chat_generator_run 

19from haystack.components.preprocessors import DocumentSplitter 

20from haystack.core.serialization import component_to_dict 

21from haystack.dataclasses import ChatMessage 

22from haystack.utils import deserialize_chatgenerator_inplace, expand_page_range 

23from haystack.utils.async_utils import _execute_component_async 

24from haystack.utils.misc import _parse_dict_from_json 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29@component 

30class LLMMetadataExtractor: 

31 """ 

32 Extracts metadata from documents using a Large Language Model (LLM). 

33 

34 The metadata is extracted by providing a prompt to an LLM that generates the metadata. 

35 

36 This component expects as input a list of documents and a prompt. The prompt must have exactly one variable, called 

37 `document`, that points to a single document in the list of documents. So to access the content of the document, 

38 you can use `{{ document.content }}` in the prompt. 

39 

40 The component will run the LLM on each document in the list and extract metadata from the document. The metadata 

41 will be added to the document's metadata field. If the LLM fails to extract metadata from a document, the document 

42 will be added to the `failed_documents` list. The failed documents will have the keys `metadata_extraction_error` and 

43 `metadata_extraction_response` in their metadata. These documents can be re-run with another extractor to 

44 extract metadata by using the `metadata_extraction_response` and `metadata_extraction_error` in the prompt. 

45 

46 ```python 

47 from haystack import Document 

48 from haystack.components.extractors.llm_metadata_extractor import LLMMetadataExtractor 

49 from haystack.components.generators.chat import OpenAIResponsesChatGenerator 

50 

51 NER_PROMPT = ''' 

52 -Goal- 

53 Given text and a list of entity types, identify all entities of those types from the text. 

54 

55 -Steps- 

56 1. Identify all entities. For each identified entity, extract the following information: 

57 - entity: Name of the entity 

58 - entity_type: One of the following types: [organization, product, service, industry] 

59 Format each entity as a JSON like: {"entity": <entity_name>, "entity_type": <entity_type>} 

60 

61 2. Return output in a single list with all the entities identified in steps 1. 

62 

63 -Examples- 

64 ###################### 

65 Example 1: 

66 entity_types: [organization, person, partnership, financial metric, product, service, industry, investment strategy, market trend] 

67 text: Another area of strength is our co-brand issuance. Visa is the primary network partner for eight of the top 

68 10 co-brand partnerships in the US today and we are pleased that Visa has finalized a multi-year extension of 

69 our successful credit co-branded partnership with Alaska Airlines, a portfolio that benefits from a loyal customer 

70 base and high cross-border usage. 

71 We have also had significant co-brand momentum in CEMEA. First, we launched a new co-brand card in partnership 

72 with Qatar Airways, British Airways and the National Bank of Kuwait. Second, we expanded our strong global 

73 Marriott relationship to launch Qatar's first hospitality co-branded card with Qatar Islamic Bank. Across the 

74 United Arab Emirates, we now have exclusive agreements with all the leading airlines marked by a recent 

75 agreement with Emirates Skywards. 

76 And we also signed an inaugural Airline co-brand agreement in Morocco with Royal Air Maroc. Now newer digital 

77 issuers are equally 

78 ------------------------ 

79 output: 

80 {"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"}]} 

81 ############################# 

82 -Real Data- 

83 ###################### 

84 entity_types: [company, organization, person, country, product, service] 

85 text: {{ document.content }} 

86 ###################### 

87 output: 

88 ''' 

89 

90 docs = [ 

91 Document(content="deepset was founded in 2018 in Berlin, and is known for its Haystack framework"), 

92 Document(content="Hugging Face is a company that was founded in New York, USA and is known for its Transformers library") 

93 ] 

94 

95 chat_generator = OpenAIResponsesChatGenerator( 

96 generation_kwargs={ 

97 "text": { 

98 "format": { 

99 "type": "json_schema", 

100 "name": "entity_extraction", 

101 "strict": True, 

102 "schema": { 

103 "type": "object", 

104 "properties": { 

105 "entities": { 

106 "type": "array", 

107 "items": { 

108 "type": "object", 

109 "properties": { 

110 "entity": {"type": "string"}, 

111 "entity_type": {"type": "string"} 

112 }, 

113 "required": ["entity", "entity_type"], 

114 "additionalProperties": False 

115 } 

116 } 

117 }, 

118 "required": ["entities"], 

119 "additionalProperties": False 

120 } 

121 } 

122 }, 

123 }, 

124 max_retries=1, 

125 timeout=60.0, 

126 ) 

127 

128 extractor = LLMMetadataExtractor( 

129 prompt=NER_PROMPT, 

130 chat_generator=chat_generator, 

131 expected_keys=["entities"], 

132 raise_on_failure=False, 

133 ) 

134 

135 extractor.run(documents=docs) 

136 # >> {'documents': [ 

137 # Document(id=.., content: 'deepset was founded in 2018 in Berlin, and is known for its Haystack framework', 

138 # meta: {'entities': [{'entity': 'deepset', 'entity_type': 'company'}, {'entity': 'Berlin', 'entity_type': 'city'}, 

139 # {'entity': 'Haystack', 'entity_type': 'product'}]}), 

140 # Document(id=.., content: 'Hugging Face is a company that was founded in New York, USA and is known for its Transformers library', 

141 # meta: {'entities': [ 

142 # {'entity': 'Hugging Face', 'entity_type': 'company'}, {'entity': 'New York', 'entity_type': 'city'}, 

143 # {'entity': 'USA', 'entity_type': 'country'}, {'entity': 'Transformers', 'entity_type': 'product'} 

144 # ]}) 

145 # ] 

146 # 'failed_documents': [] 

147 # } 

148 # >> 

149 ``` 

150 """ # noqa: E501 

151 

152 def __init__( 

153 self, 

154 prompt: str, 

155 chat_generator: ChatGenerator, 

156 expected_keys: list[str] | None = None, 

157 page_range: list[str | int] | None = None, 

158 raise_on_failure: bool = False, 

159 max_workers: int = 3, 

160 ) -> None: 

161 """ 

162 Initializes the LLMMetadataExtractor. 

163 

164 :param prompt: The prompt to be used for the LLM. It must contain exactly one variable, called `document`, 

165 which points to a single document in the list of documents. For example, to access the content of the 

166 document, use `{{ document.content }}` in the prompt. 

167 :param chat_generator: a ChatGenerator instance which represents the LLM. In order for the component to work, 

168 the LLM should be configured to return a JSON object. For example, when using the 

169 OpenAIResponsesChatGenerator, you should pass `{"text": {"format": {"type": "json_schema", ...}}}` in the 

170 `generation_kwargs`. 

171 :param expected_keys: The keys expected in the JSON output from the LLM. 

172 :param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract 

173 metadata from the first and third pages of each document. It also accepts printable range strings, e.g.: 

174 ['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10,11, 12. 

175 If None, metadata will be extracted from the entire document for each document in the documents list. 

176 This parameter is optional and can be overridden in the `run` method. 

177 :param raise_on_failure: Whether to raise an error on failure during the execution of the Generator or 

178 validation of the JSON output. 

179 :param max_workers: The maximum number of workers to use in the thread pool executor. 

180 This parameter is used limit the maximum number of requests that should be allowed to run concurrently 

181 when using the `run_async` method. 

182 """ 

183 self.prompt = prompt 

184 ast = SandboxedEnvironment().parse(prompt) 

185 template_variables = meta.find_undeclared_variables(ast) 

186 variables = list(template_variables) 

187 if variables != ["document"]: 

188 raise ValueError( 

189 f"Prompt must have exactly one variable called 'document'. " 

190 f"Found {','.join(variables) or 'no variables'} in the prompt." 

191 ) 

192 self.builder = PromptBuilder(prompt, required_variables=variables) 

193 self.raise_on_failure = raise_on_failure 

194 self.expected_keys = expected_keys or [] 

195 self.splitter = DocumentSplitter(split_by="page", split_length=1) 

196 self.expanded_range = expand_page_range(page_range) if page_range else None 

197 self.max_workers = max_workers 

198 self._chat_generator = chat_generator 

199 

200 def warm_up(self) -> None: 

201 """ 

202 Warm up the underlying chat generator and splitter. 

203 """ 

204 for inner in (self._chat_generator, self.splitter): 

205 if hasattr(inner, "warm_up"): 

206 inner.warm_up() 

207 

208 async def warm_up_async(self) -> None: 

209 """ 

210 Warm up the underlying chat generator and splitter on the serving event loop. 

211 """ 

212 for inner in (self._chat_generator, self.splitter): 

213 if hasattr(inner, "warm_up_async"): 

214 await inner.warm_up_async() 

215 elif hasattr(inner, "warm_up"): 

216 inner.warm_up() 

217 

218 def close(self) -> None: 

219 """ 

220 Release the underlying chat generator's and splitter's resources. 

221 """ 

222 for inner in (self._chat_generator, self.splitter): 

223 if hasattr(inner, "close"): 

224 inner.close() 

225 

226 async def close_async(self) -> None: 

227 """ 

228 Release the underlying chat generator's and splitter's async resources. 

229 """ 

230 for inner in (self._chat_generator, self.splitter): 

231 if hasattr(inner, "close_async"): 

232 await inner.close_async() 

233 elif hasattr(inner, "close"): 

234 inner.close() 

235 

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

237 """ 

238 Serializes the component to a dictionary. 

239 

240 :returns: 

241 Dictionary with serialized data. 

242 """ 

243 

244 return default_to_dict( 

245 self, 

246 prompt=self.prompt, 

247 chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"), 

248 expected_keys=self.expected_keys, 

249 page_range=self.expanded_range, 

250 raise_on_failure=self.raise_on_failure, 

251 max_workers=self.max_workers, 

252 ) 

253 

254 @classmethod 

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

256 """ 

257 Deserializes the component from a dictionary. 

258 

259 :param data: 

260 Dictionary with serialized data. 

261 :returns: 

262 An instance of the component. 

263 """ 

264 

265 deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") 

266 return default_from_dict(cls, data) 

267 

268 def _prepare_prompts( 

269 self, documents: list[Document], expanded_range: list[int] | None = None 

270 ) -> list[ChatMessage | None]: 

271 all_prompts: list[ChatMessage | None] = [] 

272 for document in documents: 

273 if not document.content: 

274 logger.warning("Document {doc_id} has no content. Skipping metadata extraction.", doc_id=document.id) 

275 all_prompts.append(None) 

276 continue 

277 

278 if expanded_range: 

279 doc_copy = copy.deepcopy(document) 

280 pages = self.splitter.run(documents=[doc_copy]) 

281 content = "" 

282 for idx, page in enumerate(pages["documents"]): 

283 if idx + 1 in expanded_range and page.content is not None: 

284 content += page.content 

285 doc_copy = replace(doc_copy, content=content) 

286 else: 

287 doc_copy = document 

288 

289 prompt_with_doc = self.builder.run(template=self.prompt, template_variables={"document": doc_copy}) 

290 

291 # build a ChatMessage with the prompt 

292 message = ChatMessage.from_user(prompt_with_doc["prompt"]) 

293 all_prompts.append(message) 

294 

295 return all_prompts 

296 

297 @staticmethod 

298 def _fail(document: Document, error: str, response: ChatMessage | None = None) -> tuple[Document, bool]: 

299 """Return a copy of ``document`` with the failure metadata set, flagged as failed.""" 

300 new_meta = {**document.meta, "metadata_extraction_error": error, "metadata_extraction_response": response} 

301 return replace(document, meta=new_meta), False 

302 

303 def _run_on_thread( 

304 self, document: Document, prompt: ChatMessage | None, parent_span: tracing.Span | None = None 

305 ) -> tuple[Document, bool]: 

306 if prompt is None: 

307 return self._fail(document, "Document has no content, skipping LLM call.") 

308 

309 try: 

310 with _trace_chat_generator_run( 

311 self._chat_generator, {"messages": [prompt]}, parent_span=parent_span 

312 ) as span: 

313 result = self._chat_generator.run(messages=[prompt]) 

314 span.set_content_tag("haystack.component.output", result) 

315 except Exception as e: 

316 if self.raise_on_failure: 

317 raise e 

318 logger.exception( 

319 "LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.", 

320 class_name=self._chat_generator.__class__.__name__, 

321 error=e, 

322 ) 

323 return self._fail(document, "LLM failed with exception: " + str(e)) 

324 return self._process_reply(document, result["replies"][0]) 

325 

326 async def _run_async( 

327 self, document: Document, prompt: ChatMessage | None, parent_span: tracing.Span | None = None 

328 ) -> tuple[Document, bool]: 

329 if prompt is None: 

330 return self._fail(document, "Document has no content, skipping LLM call.") 

331 

332 try: 

333 with _trace_chat_generator_run( 

334 self._chat_generator, {"messages": [prompt]}, parent_span=parent_span 

335 ) as span: 

336 result = await _execute_component_async(self._chat_generator, messages=[prompt]) 

337 span.set_content_tag("haystack.component.output", result) 

338 except Exception as e: 

339 if self.raise_on_failure: 

340 raise e 

341 logger.exception( 

342 "LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.", 

343 class_name=self._chat_generator.__class__.__name__, 

344 error=e, 

345 ) 

346 return self._fail(document, "LLM failed with exception: " + str(e)) 

347 return self._process_reply(document, result["replies"][0]) 

348 

349 def _process_reply(self, document: Document, reply: ChatMessage) -> tuple[Document, bool]: 

350 """ 

351 Parse one document's LLM reply into metadata. 

352 

353 Returns (updated_document, True if success else False). 

354 """ 

355 try: 

356 parsed_metadata = _parse_dict_from_json( 

357 reply.text or "", expected_keys=self.expected_keys, raise_on_failure=True 

358 ) 

359 except (ValueError, json.JSONDecodeError) as e: 

360 logger.warning( 

361 "Response from the LLM is not valid JSON or missing expected keys. Received output: {response}", 

362 response=reply.text, 

363 ) 

364 if self.raise_on_failure: 

365 raise 

366 return self._fail(document, "Response is not valid JSON or missing keys. Error: " + str(e), response=reply) 

367 

368 new_meta = {**document.meta, **parsed_metadata} 

369 # Remove metadata_extraction_error and metadata_extraction_response if present from previous runs. 

370 new_meta.pop("metadata_extraction_error", None) 

371 new_meta.pop("metadata_extraction_response", None) 

372 return replace(document, meta=new_meta), True 

373 

374 @component.output_types(documents=list[Document], failed_documents=list[Document]) 

375 def run(self, documents: list[Document], page_range: list[str | int] | None = None) -> dict[str, Any]: 

376 """ 

377 Extract metadata from documents using a Large Language Model. 

378 

379 If `page_range` is provided, the metadata will be extracted from the specified range of pages. This component 

380 will split the documents into pages and extract metadata from the specified range of pages. The metadata will be 

381 extracted from the entire document if `page_range` is not provided. 

382 

383 The original documents will be returned updated with the extracted metadata. 

384 

385 :param documents: List of documents to extract metadata from. 

386 :param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract 

387 metadata from the first and third pages of each document. It also accepts printable range 

388 strings, e.g.: ['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10, 

389 11, 12. 

390 If None, metadata will be extracted from the entire document for each document in the 

391 documents list. 

392 :returns: 

393 A dictionary with the keys: 

394 - "documents": A list of documents that were successfully updated with the extracted metadata. 

395 - "failed_documents": A list of documents that failed to extract metadata. These documents will have 

396 "metadata_extraction_error" and "metadata_extraction_response" in their metadata. These documents can be 

397 re-run with the extractor to extract metadata. 

398 """ 

399 if len(documents) == 0: 

400 logger.warning("No documents provided. Skipping metadata extraction.") 

401 return {"documents": [], "failed_documents": []} 

402 

403 self.warm_up() 

404 

405 expanded_range = self.expanded_range 

406 if page_range: 

407 expanded_range = expand_page_range(page_range) 

408 

409 # Create ChatMessage prompts for each document 

410 all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range) 

411 

412 # Capture the current span here so worker threads nest their generator spans under the component span. 

413 parent_span = tracing.tracer.current_span() 

414 

415 # Run the LLM on each prompt 

416 with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 

417 results = executor.map( 

418 lambda document, prompt: self._run_on_thread(document, prompt, parent_span=parent_span), 

419 documents, 

420 all_prompts, 

421 ) 

422 

423 successful_documents = [] 

424 failed_documents = [] 

425 for doc, success in results: 

426 if success: 

427 successful_documents.append(doc) 

428 else: 

429 failed_documents.append(doc) 

430 

431 return {"documents": successful_documents, "failed_documents": failed_documents} 

432 

433 @component.output_types(documents=list[Document], failed_documents=list[Document]) 

434 async def run_async(self, documents: list[Document], page_range: list[str | int] | None = None) -> dict[str, Any]: 

435 """ 

436 Asynchronously extract metadata from documents using a Large Language Model. 

437 

438 If `page_range` is provided, the metadata will be extracted from the specified range of pages. This component 

439 will split the documents into pages and extract metadata from the specified range of pages. The metadata will be 

440 extracted from the entire document if `page_range` is not provided. 

441 

442 The original documents will be returned updated with the extracted metadata. 

443 

444 This is the asynchronous version of the `run` method. It has the same parameters 

445 and return values but can be used with `await` in an async code. 

446 

447 :param documents: List of documents to extract metadata from. 

448 :param page_range: A range of pages to extract metadata from. For example, page_range=['1', '3'] will extract 

449 metadata from the first and third pages of each document. It also accepts printable range 

450 strings, e.g.: ['1-3', '5', '8', '10-12'] will extract metadata from pages 1, 2, 3, 5, 8, 10, 

451 11, 12. 

452 If None, metadata will be extracted from the entire document for each document in the 

453 documents list. 

454 :returns: 

455 A dictionary with the keys: 

456 - "documents": A list of documents that were successfully updated with the extracted metadata. 

457 - "failed_documents": A list of documents that failed to extract metadata. These documents will have 

458 "metadata_extraction_error" and "metadata_extraction_response" in their metadata. These documents can be 

459 re-run with the extractor to extract metadata. 

460 """ 

461 if len(documents) == 0: 

462 logger.warning("No documents provided. Skipping metadata extraction.") 

463 return {"documents": [], "failed_documents": []} 

464 

465 await self.warm_up_async() 

466 

467 expanded_range = self.expanded_range 

468 if page_range: 

469 expanded_range = expand_page_range(page_range) 

470 

471 # Create ChatMessage prompts for each document 

472 all_prompts = self._prepare_prompts(documents=documents, expanded_range=expanded_range) 

473 

474 # Capture the current span here so concurrent tasks nest their generator spans under the component span. 

475 parent_span = tracing.tracer.current_span() 

476 

477 # Run the LLM on each prompt, bounding concurrency per task so max_workers is enforced. 

478 sem = Semaphore(max(1, self.max_workers)) 

479 

480 async def _bounded_run(document: Document, prompt: ChatMessage | None) -> tuple[Document, bool]: 

481 async with sem: 

482 return await self._run_async(document, prompt, parent_span=parent_span) 

483 

484 results = await gather( 

485 *[_bounded_run(document, prompt) for document, prompt in zip(documents, all_prompts, strict=True)] 

486 ) 

487 

488 successful_documents = [] 

489 failed_documents = [] 

490 for doc, success in results: 

491 if success: 

492 successful_documents.append(doc) 

493 else: 

494 failed_documents.append(doc) 

495 

496 return {"documents": successful_documents, "failed_documents": failed_documents}