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.
- hyperforge_nucliadb_agentic/__init__.py +3 -0
- hyperforge_nucliadb_agentic/agent.py +1642 -0
- hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
- hyperforge_nucliadb_agentic/ask/audit.py +439 -0
- hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
- hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
- hyperforge_nucliadb_agentic/ask/model.py +1299 -0
- hyperforge_nucliadb_agentic/ask/predict.py +431 -0
- hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
- hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
- hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
- hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
- hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
- hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
- hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
- hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
- hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
- hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
- hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
- hyperforge_nucliadb_agentic/ask/settings.py +9 -0
- hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
- hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
- hyperforge_nucliadb_agentic/config.py +47 -0
- hyperforge_nucliadb_agentic/internal_driver.py +87 -0
- hyperforge_nucliadb_agentic/py.typed +0 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
- 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
|
+
)
|