hyperforge-nucliadb-agentic 1.0.0.post64__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. hyperforge_nucliadb_agentic/__init__.py +3 -0
  2. hyperforge_nucliadb_agentic/agent.py +1642 -0
  3. hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
  4. hyperforge_nucliadb_agentic/ask/audit.py +439 -0
  5. hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
  6. hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
  7. hyperforge_nucliadb_agentic/ask/model.py +1299 -0
  8. hyperforge_nucliadb_agentic/ask/predict.py +431 -0
  9. hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
  10. hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
  11. hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
  12. hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
  13. hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
  14. hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
  15. hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
  16. hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
  17. hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
  18. hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
  19. hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
  20. hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
  21. hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
  22. hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
  23. hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
  24. hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
  25. hyperforge_nucliadb_agentic/ask/settings.py +9 -0
  26. hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
  27. hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
  28. hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
  29. hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
  30. hyperforge_nucliadb_agentic/config.py +47 -0
  31. hyperforge_nucliadb_agentic/internal_driver.py +87 -0
  32. hyperforge_nucliadb_agentic/py.typed +0 -0
  33. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
  34. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
  35. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/WHEEL +4 -0
@@ -0,0 +1,750 @@
1
+ import asyncio
2
+ from collections.abc import AsyncGenerator, Iterable
3
+
4
+ from nuclia_models.predict.generative_responses import (
5
+ GenerativeChunk,
6
+ )
7
+ from nucliadb_models import filters
8
+ from nucliadb_models.augment import (
9
+ AugmentedResource,
10
+ AugmentParagraph,
11
+ AugmentParagraphs,
12
+ AugmentRequest,
13
+ AugmentResources,
14
+ ParagraphMetadata,
15
+ )
16
+ from nucliadb_models.retrieval import (
17
+ RerankerScore,
18
+ RetrievalMatch,
19
+ ScoreType,
20
+ )
21
+ from nucliadb_models.search import (
22
+ SCORE_TYPE,
23
+ FindField,
24
+ FindOptions,
25
+ FindParagraph,
26
+ FindResource,
27
+ NucliaDBClientType,
28
+ )
29
+ from nucliadb_sdk.v2 import NucliaDBAsync
30
+ from nucliadb_telemetry.errors import capture_exception
31
+
32
+ from hyperforge_nucliadb_agentic.ask import logger
33
+ from hyperforge_nucliadb_agentic.ask.audit import get_audit
34
+ from hyperforge_nucliadb_agentic.ask.exceptions import (
35
+ IncompleteFindResultsError,
36
+ NoRetrievalResultsError,
37
+ )
38
+ from hyperforge_nucliadb_agentic.ask.model import (
39
+ AskRequest,
40
+ ChatContextMessage,
41
+ ChatModel,
42
+ ChatOptions,
43
+ FindRequest,
44
+ KnowledgeboxFindResults,
45
+ PreQueriesStrategy,
46
+ PreQuery,
47
+ PreQueryResult,
48
+ PromptContext,
49
+ PromptContextOrder,
50
+ Relations,
51
+ RephraseModel,
52
+ TextPosition,
53
+ parse_rephrase_prompt,
54
+ )
55
+ from hyperforge_nucliadb_agentic.ask.predict import (
56
+ RephraseResponse,
57
+ SendToPredictError,
58
+ get_predict,
59
+ )
60
+ from hyperforge_nucliadb_agentic.ask.search import rpc
61
+ from hyperforge_nucliadb_agentic.ask.search.highlight import (
62
+ highlight_paragraph,
63
+ )
64
+ from hyperforge_nucliadb_agentic.ask.search.hydrator import (
65
+ ResourceHydrationOptions,
66
+ TextBlockHydrationOptions,
67
+ )
68
+ from hyperforge_nucliadb_agentic.ask.search.metrics import Metrics
69
+ from hyperforge_nucliadb_agentic.ask.search.parsers.fetcher import (
70
+ Fetcher,
71
+ )
72
+ from hyperforge_nucliadb_agentic.ask.search.parsers.find import (
73
+ parse_find,
74
+ )
75
+ from hyperforge_nucliadb_agentic.ask.search.rerankers import (
76
+ RerankableItem,
77
+ Reranker,
78
+ RerankingOptions,
79
+ )
80
+ from hyperforge_nucliadb_agentic.ask.utils.ids import ParagraphId
81
+ from hyperforge_nucliadb_agentic.ask.utils.text_blocks import (
82
+ TextBlockMatch,
83
+ )
84
+
85
+ NOT_ENOUGH_CONTEXT_ANSWER = "Not enough data to answer this."
86
+
87
+ # Maximum number of prequeries to run concurrently per /ask request
88
+ MAX_CONCURRENT_PREQUERIES = 2
89
+
90
+
91
+ async def rephrase_query(
92
+ kbid: str,
93
+ chat_history: list[ChatContextMessage],
94
+ query: str,
95
+ user_id: str,
96
+ user_context: list[str],
97
+ generative_model: str | None = None,
98
+ chat_history_relevance_threshold: float | None = None,
99
+ ) -> RephraseResponse:
100
+ # NOTE: When moving /ask to RAO, this will need to change to whatever client/utility is used
101
+ # to call NUA predict (internally or externally in the case of onprem).
102
+ predict = get_predict()
103
+ req = RephraseModel(
104
+ question=query,
105
+ chat_history=chat_history,
106
+ user_id=user_id,
107
+ user_context=user_context,
108
+ generative_model=generative_model,
109
+ chat_history_relevance_threshold=chat_history_relevance_threshold,
110
+ )
111
+ return await predict.rephrase_query(kbid, req)
112
+
113
+
114
+ async def get_find_results(
115
+ *,
116
+ kbid: str,
117
+ query: str,
118
+ item: AskRequest,
119
+ ndb_client: NucliaDBClientType,
120
+ user: str,
121
+ origin: str,
122
+ metrics: Metrics,
123
+ reader_sdk: NucliaDBAsync,
124
+ search_sdk: NucliaDBAsync,
125
+ prequeries_strategy: PreQueriesStrategy | None = None,
126
+ ) -> tuple[KnowledgeboxFindResults, list[PreQueryResult] | None, Fetcher, Reranker]:
127
+ prequeries_results = None
128
+ prefilter_queries_results = None
129
+ queries_results = None
130
+ if prequeries_strategy is not None:
131
+ prefilters = [
132
+ prequery for prequery in prequeries_strategy.queries if prequery.prefilter
133
+ ]
134
+ prequeries = [
135
+ prequery
136
+ for prequery in prequeries_strategy.queries
137
+ if not prequery.prefilter
138
+ ]
139
+ if len(prefilters) > 0:
140
+ with metrics.time("prefilters"):
141
+ prefilter_queries_results = await run_prequeries(
142
+ kbid,
143
+ prefilters,
144
+ x_ndb_client=ndb_client,
145
+ x_nucliadb_user=user,
146
+ x_forwarded_for=origin,
147
+ metrics=metrics.child_span("prefilters"),
148
+ reader_sdk=reader_sdk,
149
+ search_sdk=search_sdk,
150
+ )
151
+ prefilter_matching_resources = {
152
+ resource
153
+ for _, find_results in prefilter_queries_results
154
+ for resource in find_results.resources.keys()
155
+ }
156
+ if len(prefilter_matching_resources) == 0:
157
+ raise NoRetrievalResultsError()
158
+ # Make sure the main query and prequeries use the same resource filters.
159
+ # This is important to avoid returning results that don't match the prefilter.
160
+ matching_resources = list(prefilter_matching_resources)
161
+ add_resource_filter(item, matching_resources)
162
+ for prequery in prequeries:
163
+ add_resource_filter(prequery.request, matching_resources)
164
+ prequery.request.show_hidden = item.show_hidden
165
+
166
+ if prequeries:
167
+ with metrics.time("prequeries"):
168
+ queries_results = await run_prequeries(
169
+ kbid,
170
+ prequeries,
171
+ x_ndb_client=ndb_client,
172
+ x_nucliadb_user=user,
173
+ x_forwarded_for=origin,
174
+ metrics=metrics.child_span("prequeries"),
175
+ reader_sdk=reader_sdk,
176
+ search_sdk=search_sdk,
177
+ )
178
+
179
+ prequeries_results = (prefilter_queries_results or []) + (queries_results or [])
180
+
181
+ with metrics.time("main_query"):
182
+ main_results, fetcher, reranker = await run_main_query(
183
+ kbid,
184
+ query,
185
+ item,
186
+ ndb_client,
187
+ user,
188
+ origin,
189
+ metrics=metrics.child_span("main_query"),
190
+ reader_sdk=reader_sdk,
191
+ search_sdk=search_sdk,
192
+ )
193
+ return main_results, prequeries_results, fetcher, reranker
194
+
195
+
196
+ def add_resource_filter(request: FindRequest | AskRequest, resources: list[str]):
197
+ if len(resources) == 0:
198
+ return
199
+
200
+ if request.filter_expression is not None:
201
+ if len(resources) > 1:
202
+ resource_filter: filters.FieldFilterExpression = filters.Or.model_validate(
203
+ {"or": [filters.Resource(prop="resource", id=rid) for rid in resources]}
204
+ )
205
+ else:
206
+ resource_filter = filters.Resource(prop="resource", id=resources[0])
207
+
208
+ # Add to filter expression if set
209
+ if request.filter_expression.field is None:
210
+ request.filter_expression.field = resource_filter
211
+ else:
212
+ request.filter_expression.field = filters.And.model_validate(
213
+ {"and": [request.filter_expression.field, resource_filter]}
214
+ )
215
+ else:
216
+ # Add to old key filters instead
217
+ request.resource_filters = resources
218
+
219
+
220
+ def find_request_from_ask_request(item: AskRequest, query: str) -> FindRequest:
221
+ find_request = FindRequest()
222
+ find_request.filter_expression = item.filter_expression
223
+ find_request.resource_filters = item.resource_filters
224
+ find_request.features = []
225
+ if ChatOptions.SEMANTIC in item.features:
226
+ find_request.features.append(FindOptions.SEMANTIC)
227
+ if ChatOptions.KEYWORD in item.features:
228
+ find_request.features.append(FindOptions.KEYWORD)
229
+ if ChatOptions.RELATIONS in item.features:
230
+ find_request.features.append(FindOptions.RELATIONS)
231
+ find_request.query = query
232
+ find_request.fields = item.fields
233
+ find_request.filters = item.filters
234
+ find_request.field_type_filter = item.field_type_filter
235
+ find_request.min_score = item.min_score
236
+ find_request.vectorset = item.vectorset
237
+ find_request.range_creation_start = item.range_creation_start
238
+ find_request.range_creation_end = item.range_creation_end
239
+ find_request.range_modification_start = item.range_modification_start
240
+ find_request.range_modification_end = item.range_modification_end
241
+ find_request.show = item.show
242
+ find_request.extracted = item.extracted
243
+ find_request.highlight = item.highlight
244
+ find_request.security = item.security
245
+ find_request.debug = item.debug
246
+ find_request.rephrase = item.rephrase
247
+ find_request.rephrase_prompt = parse_rephrase_prompt(item)
248
+ find_request.query_image = item.query_image
249
+ find_request.rank_fusion = item.rank_fusion
250
+ find_request.reranker = item.reranker
251
+ # We don't support pagination, we always get the top_k results.
252
+ find_request.top_k = item.top_k
253
+ find_request.show_hidden = item.show_hidden
254
+ find_request.generative_model = item.generative_model
255
+
256
+ # this executes the model validators, that can tweak some fields
257
+ return FindRequest.model_validate(find_request)
258
+
259
+
260
+ async def run_main_query(
261
+ kbid: str,
262
+ query: str,
263
+ item: AskRequest,
264
+ ndb_client: NucliaDBClientType,
265
+ user: str,
266
+ origin: str,
267
+ metrics: Metrics,
268
+ *,
269
+ reader_sdk: NucliaDBAsync,
270
+ search_sdk: NucliaDBAsync,
271
+ ) -> tuple[KnowledgeboxFindResults, Fetcher, Reranker]:
272
+ find_request = find_request_from_ask_request(item, query)
273
+
274
+ find_results, fetcher, reranker = await find_retrieval(
275
+ kbid,
276
+ find_request,
277
+ ndb_client,
278
+ user,
279
+ origin,
280
+ metrics=metrics,
281
+ search_sdk=search_sdk,
282
+ reader_sdk=reader_sdk,
283
+ )
284
+ return find_results, fetcher, reranker
285
+
286
+
287
+ async def get_relations_results(
288
+ *,
289
+ search_sdk: NucliaDBAsync,
290
+ kbid: str,
291
+ text_answer: str,
292
+ timeout: float | None = None,
293
+ ) -> Relations:
294
+ try:
295
+ results, _ = await rpc.find(
296
+ search_sdk,
297
+ kbid,
298
+ FindRequest(features=[FindOptions.RELATIONS], query=text_answer),
299
+ # TODO: forward nucliadb client, user...?
300
+ x_ndb_client=NucliaDBClientType.API,
301
+ x_nucliadb_user="",
302
+ x_forwarded_for="",
303
+ )
304
+ except Exception as exc:
305
+ capture_exception(exc)
306
+ logger.exception("Error getting relations results")
307
+ return Relations(entities={})
308
+ else:
309
+ if results.relations is None:
310
+ return Relations(entities={})
311
+ return results.relations
312
+
313
+
314
+ def sorted_prompt_context_list(
315
+ context: PromptContext, order: PromptContextOrder
316
+ ) -> list[str]:
317
+ """
318
+ context = {"x": "foo", "y": "bar"}
319
+ order = {"y": 1, "x": 0}
320
+ sorted_prompt_context_list(context, order) == ["foo", "bar"]
321
+ """
322
+ sorted_items = sorted(
323
+ context.items(),
324
+ key=lambda item: order.get(item[0], float("inf")),
325
+ )
326
+ return list(map(lambda item: item[1], sorted_items))
327
+
328
+
329
+ async def run_prequeries(
330
+ kbid: str,
331
+ prequeries: list[PreQuery],
332
+ x_ndb_client: NucliaDBClientType,
333
+ x_nucliadb_user: str,
334
+ x_forwarded_for: str,
335
+ metrics: Metrics,
336
+ *,
337
+ reader_sdk: NucliaDBAsync,
338
+ search_sdk: NucliaDBAsync,
339
+ ) -> list[PreQueryResult]:
340
+ """
341
+ Runs simultaneous find requests for each prequery and returns the merged results according to the normalized weights.
342
+ """
343
+ results: list[PreQueryResult] = []
344
+ max_parallel_prequeries = asyncio.Semaphore(MAX_CONCURRENT_PREQUERIES)
345
+
346
+ async def _prequery_find(prequery: PreQuery, index: int):
347
+ async with max_parallel_prequeries:
348
+ prequery_id = prequery.id or f"prequery-{index}"
349
+ find_results, _, _ = await find_retrieval(
350
+ kbid,
351
+ prequery.request,
352
+ x_ndb_client,
353
+ x_nucliadb_user,
354
+ x_forwarded_for,
355
+ metrics=metrics.child_span(prequery_id),
356
+ search_sdk=search_sdk,
357
+ reader_sdk=reader_sdk,
358
+ )
359
+
360
+ return prequery, find_results
361
+
362
+ ops = []
363
+ for idx, prequery in enumerate(prequeries):
364
+ ops.append(asyncio.create_task(_prequery_find(prequery, idx)))
365
+ ops_results = await asyncio.gather(*ops)
366
+ for prequery, find_results in ops_results:
367
+ results.append((prequery, find_results))
368
+ return results
369
+
370
+
371
+ async def get_answer_stream(
372
+ kbid: str,
373
+ item: ChatModel,
374
+ extra_headers: dict[str, str] | None = None,
375
+ ) -> tuple[str, str, AsyncGenerator[GenerativeChunk, None]]:
376
+ # NOTE: When moving /ask to RAO, this will need to change to whatever client/utility is used
377
+ # to call NUA predict (internally or externally in the case of onprem).
378
+ predict = get_predict()
379
+ return await predict.chat_query_ndjson(
380
+ kbid=kbid,
381
+ item=item,
382
+ extra_headers=extra_headers,
383
+ )
384
+
385
+
386
+ async def find_retrieval(
387
+ kbid: str,
388
+ find_request: FindRequest,
389
+ x_ndb_client: NucliaDBClientType,
390
+ x_nucliadb_user: str,
391
+ x_forwarded_for: str,
392
+ metrics: Metrics,
393
+ *,
394
+ search_sdk: NucliaDBAsync,
395
+ reader_sdk: NucliaDBAsync,
396
+ ) -> tuple[KnowledgeboxFindResults, Fetcher, Reranker]:
397
+ """This is an equivalent implementation of /find but uses the new /retrieve
398
+ and /augment endpoints under the hood while providing bw/c for the /find
399
+ response model.
400
+
401
+ This implementation is provided to comply with the existing /find interface
402
+ to which /ask is tighly coupled with.
403
+
404
+ Note there's an edge case, when users ask for features=relations, in which
405
+ we fallback to /find, as it's the simplest way to provide bw/c.
406
+
407
+ """
408
+ with metrics.time("query_parse"):
409
+ try:
410
+ fetcher, retrieval_request, reranker = await parse_find(
411
+ kbid, find_request, reader_sdk=reader_sdk
412
+ )
413
+ except SendToPredictError as exc:
414
+ raise IncompleteFindResultsError("Predict API error") from exc
415
+
416
+ query = find_request.query
417
+ rephrased_query = fetcher.get_cached_rephrased_query()
418
+
419
+ with metrics.time("retrieval"):
420
+ retrieval_response = await rpc.retrieve(search_sdk, kbid, retrieval_request)
421
+ matches = retrieval_response.matches
422
+
423
+ relations = None
424
+ if FindOptions.RELATIONS in find_request.features:
425
+ # the user asked for a legacy relations search, as we don't support it
426
+ # in the /retrieve endpoint but we must maintain bw/c with /find
427
+ # responses, we call it with to get just this part of the response
428
+ find_response, _ = await rpc.find(
429
+ search_sdk,
430
+ kbid,
431
+ FindRequest(
432
+ features=[FindOptions.RELATIONS],
433
+ # needed for automatic entity detection
434
+ query=query,
435
+ # used for "hardcoded" graph queries
436
+ query_entities=find_request.query_entities,
437
+ ),
438
+ x_ndb_client,
439
+ x_nucliadb_user,
440
+ x_forwarded_for,
441
+ )
442
+ relations = find_response.relations
443
+
444
+ with metrics.time("results_merge"):
445
+ text_blocks, resources, best_matches = await augment_and_rerank(
446
+ search_sdk,
447
+ kbid,
448
+ matches,
449
+ # here we use the original top_k, so we end up with the number of
450
+ # results requested by the user
451
+ top_k=find_request.top_k,
452
+ resource_hydration_options=ResourceHydrationOptions(
453
+ show=find_request.show,
454
+ extracted=find_request.extracted,
455
+ field_type_filter=find_request.field_type_filter,
456
+ ),
457
+ text_block_hydration_options=TextBlockHydrationOptions(),
458
+ reranker=reranker,
459
+ reranking_options=RerankingOptions(
460
+ kbid=kbid, query=rephrased_query or query
461
+ ),
462
+ )
463
+ find_resources = compose_find_resources(text_blocks, resources)
464
+ find_results = KnowledgeboxFindResults(
465
+ query=query,
466
+ rephrased_query=rephrased_query,
467
+ resources=find_resources,
468
+ best_matches=best_matches,
469
+ relations=relations,
470
+ # legacy fields
471
+ total=len(text_blocks),
472
+ page_number=0,
473
+ page_size=find_request.top_k,
474
+ next_page=False,
475
+ )
476
+
477
+ audit = get_audit()
478
+ if audit is not None:
479
+ audit.retrieve(
480
+ retrieval_time=metrics.get("retrieval"), # type: ignore
481
+ resources=len(resources),
482
+ retrieval_request=retrieval_request,
483
+ )
484
+
485
+ return find_results, fetcher, reranker
486
+
487
+
488
+ async def augment_and_rerank(
489
+ search_sdk: NucliaDBAsync,
490
+ kbid: str,
491
+ matches: list[RetrievalMatch],
492
+ top_k: int,
493
+ resource_hydration_options: ResourceHydrationOptions,
494
+ text_block_hydration_options: TextBlockHydrationOptions,
495
+ reranker: Reranker,
496
+ reranking_options: RerankingOptions,
497
+ ) -> tuple[list[TextBlockMatch], list[AugmentedResource], list[str]]:
498
+ score_type_map = {
499
+ ScoreType.SEMANTIC: SCORE_TYPE.VECTOR,
500
+ ScoreType.KEYWORD: SCORE_TYPE.BM25,
501
+ ScoreType.RRF: SCORE_TYPE.BOTH,
502
+ ScoreType.RERANKER: SCORE_TYPE.RERANKER,
503
+ ScoreType.GRAPH: SCORE_TYPE.RELATION_RELEVANCE,
504
+ }
505
+ text_blocks = []
506
+ for match in matches:
507
+ paragraph_id = ParagraphId.from_string(match.id)
508
+ score_type = score_type_map[match.score.type]
509
+ text_block = TextBlockMatch(
510
+ paragraph_id=paragraph_id,
511
+ scores=match.score.history,
512
+ score_type=score_type,
513
+ position=TextPosition(
514
+ page_number=match.metadata.page,
515
+ index=0,
516
+ start=paragraph_id.paragraph_start,
517
+ end=paragraph_id.paragraph_end,
518
+ start_seconds=[],
519
+ end_seconds=[],
520
+ ),
521
+ order=-1, # will be populated later
522
+ page_with_visual=match.metadata.in_page_with_visual or False,
523
+ fuzzy_search=False, # we don't have this info anymore
524
+ is_a_table=match.metadata.is_a_table,
525
+ representation_file=match.metadata.source_file,
526
+ field_labels=match.metadata.field_labels,
527
+ paragraph_labels=match.metadata.paragraph_labels,
528
+ )
529
+ text_blocks.append(text_block)
530
+
531
+ return await hydrate_and_rerank(
532
+ search_sdk,
533
+ text_blocks,
534
+ kbid,
535
+ resource_hydration_options=resource_hydration_options,
536
+ text_block_hydration_options=text_block_hydration_options,
537
+ reranker=reranker,
538
+ reranking_options=reranking_options,
539
+ top_k=top_k,
540
+ )
541
+
542
+
543
+ async def hydrate_and_rerank(
544
+ search_sdk: NucliaDBAsync,
545
+ text_blocks: Iterable[TextBlockMatch],
546
+ kbid: str,
547
+ *,
548
+ resource_hydration_options: ResourceHydrationOptions,
549
+ text_block_hydration_options: TextBlockHydrationOptions,
550
+ reranker: Reranker,
551
+ reranking_options: RerankingOptions,
552
+ top_k: int,
553
+ ) -> tuple[list[TextBlockMatch], list[AugmentedResource], list[str]]:
554
+ """Given a list of text blocks from a retrieval operation, hydrate and
555
+ rerank the results.
556
+
557
+ This function returns either the entire list or a subset of updated
558
+ (hydrated and reranked) text blocks and their corresponding resource
559
+ metadata. It also returns an ordered list of best matches.
560
+
561
+ """
562
+ # Iterate text blocks to create an "index" for faster access by id and get a
563
+ # list of text block ids and resource ids to hydrate
564
+ text_blocks_by_id: dict[
565
+ str, TextBlockMatch
566
+ ] = {} # useful for faster access to text blocks later
567
+ resources_to_hydrate = set()
568
+ text_block_id_to_hydrate = set()
569
+
570
+ for text_block in text_blocks:
571
+ rid = text_block.paragraph_id.rid
572
+ paragraph_id = text_block.paragraph_id.full()
573
+
574
+ # If we find multiple results (from different indexes) with different
575
+ # metadata, this statement will only get the metadata from the first on
576
+ # the list. We assume metadata is the same on all indexes, otherwise
577
+ # this would be a BUG
578
+ text_blocks_by_id.setdefault(paragraph_id, text_block)
579
+
580
+ # rerankers that need extra results may end with less resources than the
581
+ # ones we see now, so we'll skip this step and recompute the resources
582
+ # later
583
+ if not reranker.needs_extra_results:
584
+ resources_to_hydrate.add(rid)
585
+
586
+ if text_block_hydration_options.only_hydrate_empty and text_block.text:
587
+ pass
588
+ else:
589
+ text_block_id_to_hydrate.add(paragraph_id)
590
+
591
+ resource_augment = AugmentResources(
592
+ given=list(resources_to_hydrate),
593
+ field_type_filter=resource_hydration_options.field_type_filter,
594
+ )
595
+ resource_augment.apply_show_and_extracted(
596
+ resource_hydration_options.show,
597
+ resource_hydration_options.extracted,
598
+ )
599
+
600
+ # hydrate only the strictly needed before rerank
601
+ augment_request = AugmentRequest(
602
+ resources=[resource_augment],
603
+ paragraphs=[
604
+ AugmentParagraphs(
605
+ given=[
606
+ AugmentParagraph(
607
+ id=paragraph_id,
608
+ metadata=ParagraphMetadata(
609
+ is_an_image=text_blocks_by_id[paragraph_id].is_an_image,
610
+ is_a_table=text_blocks_by_id[paragraph_id].is_a_table,
611
+ source_file=text_blocks_by_id[
612
+ paragraph_id
613
+ ].representation_file,
614
+ page=text_blocks_by_id[paragraph_id].position.page_number,
615
+ in_page_with_visual=text_blocks_by_id[
616
+ paragraph_id
617
+ ].page_with_visual,
618
+ ),
619
+ )
620
+ for paragraph_id in text_block_id_to_hydrate
621
+ ],
622
+ text=True,
623
+ )
624
+ ],
625
+ )
626
+ augment_response = await rpc.augment(search_sdk, kbid, augment_request)
627
+ augmented_paragraphs = augment_response.paragraphs
628
+ augmented_resources = augment_response.resources
629
+
630
+ # add hydrated text to our text blocks
631
+ for text_block in text_blocks:
632
+ augmented_paragraph = augmented_paragraphs.get(
633
+ text_block.paragraph_id.full(), None
634
+ )
635
+ if augmented_paragraph is not None and augmented_paragraph.text is not None:
636
+ if text_block_hydration_options.highlight:
637
+ text = highlight_paragraph(
638
+ augmented_paragraph.text,
639
+ words=[],
640
+ ematches=text_block_hydration_options.ematches,
641
+ )
642
+ else:
643
+ text = augmented_paragraph.text
644
+ text_block.text = text
645
+
646
+ # with the hydrated text, rerank and apply new scores to the text blocks
647
+ to_rerank = [
648
+ RerankableItem(
649
+ id=text_block.paragraph_id.full(),
650
+ score=text_block.score,
651
+ score_type=text_block.score_type,
652
+ content=text_block.text
653
+ or "", # TODO: add a warning, this shouldn't usually happen
654
+ )
655
+ for text_block in text_blocks
656
+ ]
657
+ reranked = await reranker.rerank(to_rerank, reranking_options)
658
+
659
+ # after reranking, we can cut to the number of results the user wants, so we
660
+ # don't hydrate unnecessary stuff
661
+ reranked = reranked[:top_k]
662
+
663
+ matches = []
664
+ for item in reranked:
665
+ paragraph_id = item.id
666
+ score = item.score
667
+ score_type = item.score_type
668
+
669
+ text_block = text_blocks_by_id[paragraph_id]
670
+ text_block.scores.append(RerankerScore(score=score))
671
+ text_block.score_type = score_type
672
+
673
+ matches.append((paragraph_id, score))
674
+
675
+ matches.sort(key=lambda x: x[1], reverse=True)
676
+
677
+ best_matches = []
678
+ best_text_blocks = []
679
+ resources_to_hydrate.clear()
680
+ for order, (paragraph_id, _) in enumerate(matches):
681
+ text_block = text_blocks_by_id[paragraph_id]
682
+ text_block.order = order
683
+ best_matches.append(paragraph_id)
684
+ best_text_blocks.append(text_block)
685
+
686
+ # now we have removed the text block surplus, fetch resource metadata
687
+ if reranker.needs_extra_results:
688
+ rid = ParagraphId.from_string(paragraph_id).rid
689
+ resources_to_hydrate.add(rid)
690
+
691
+ # Finally, fetch resource metadata if we haven't already done it
692
+ if reranker.needs_extra_results:
693
+ resource_augment.given = list(resources_to_hydrate)
694
+ augmented = await rpc.augment(
695
+ search_sdk,
696
+ kbid,
697
+ AugmentRequest(resources=[resource_augment]),
698
+ )
699
+ augmented_resources = augmented.resources
700
+
701
+ resources = [resource for resource in augmented_resources.values()]
702
+
703
+ return best_text_blocks, resources, best_matches
704
+
705
+
706
+ def compose_find_resources(
707
+ text_blocks: list[TextBlockMatch],
708
+ resources: list[AugmentedResource],
709
+ ) -> dict[str, FindResource]:
710
+ find_resources: dict[str, FindResource] = {}
711
+
712
+ for resource in resources:
713
+ rid = resource.id
714
+ if rid not in find_resources:
715
+ find_resources[rid] = FindResource(id=rid, fields={})
716
+ find_resources[rid].updated_from(resource)
717
+
718
+ for text_block in text_blocks:
719
+ rid = text_block.paragraph_id.rid
720
+ if rid not in find_resources:
721
+ # resource not found in db, skipping
722
+ continue
723
+
724
+ find_resource = find_resources[rid]
725
+ field_id = text_block.paragraph_id.field_id.short_without_subfield()
726
+ find_field = find_resource.fields.setdefault(field_id, FindField(paragraphs={}))
727
+
728
+ paragraph_id = text_block.paragraph_id.full()
729
+ find_paragraph = text_block_to_find_paragraph(text_block)
730
+
731
+ find_field.paragraphs[paragraph_id] = find_paragraph
732
+
733
+ return find_resources
734
+
735
+
736
+ def text_block_to_find_paragraph(text_block: TextBlockMatch) -> FindParagraph:
737
+ return FindParagraph(
738
+ id=text_block.paragraph_id.full(),
739
+ text=text_block.text or "",
740
+ score=text_block.score,
741
+ score_type=text_block.score_type,
742
+ order=text_block.order,
743
+ labels=text_block.paragraph_labels,
744
+ fuzzy_result=text_block.fuzzy_search,
745
+ is_a_table=text_block.is_a_table,
746
+ reference=text_block.representation_file,
747
+ page_with_visual=text_block.page_with_visual,
748
+ position=text_block.position,
749
+ relevant_relations=text_block.relevant_relations,
750
+ )