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,1182 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import functools
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from nuclia_models.common.consumption import Consumption
|
|
8
|
+
from nuclia_models.predict.generative_responses import (
|
|
9
|
+
CitationsGenerativeResponse,
|
|
10
|
+
FootnoteCitationsGenerativeResponse,
|
|
11
|
+
GenerativeChunk,
|
|
12
|
+
JSONGenerativeResponse,
|
|
13
|
+
MetaGenerativeResponse,
|
|
14
|
+
ReasoningGenerativeResponse,
|
|
15
|
+
StatusGenerativeResponse,
|
|
16
|
+
TextGenerativeResponse,
|
|
17
|
+
)
|
|
18
|
+
from nucliadb_models.retrieval import (
|
|
19
|
+
GraphScore,
|
|
20
|
+
KeywordScore,
|
|
21
|
+
RerankerScore,
|
|
22
|
+
RrfScore,
|
|
23
|
+
SemanticScore,
|
|
24
|
+
)
|
|
25
|
+
from nucliadb_models.search import (
|
|
26
|
+
SCORE_TYPE,
|
|
27
|
+
FindOptions,
|
|
28
|
+
FindParagraph,
|
|
29
|
+
NucliaDBClientType,
|
|
30
|
+
)
|
|
31
|
+
from nucliadb_sdk.v2 import NucliaDBAsync
|
|
32
|
+
from nucliadb_telemetry import errors
|
|
33
|
+
from nucliadb_utils.exceptions import LimitsExceededError
|
|
34
|
+
from pydantic_core import ValidationError
|
|
35
|
+
from typing_extensions import assert_never
|
|
36
|
+
|
|
37
|
+
from hyperforge_nucliadb_agentic.ask import logger, predict
|
|
38
|
+
from hyperforge_nucliadb_agentic.ask.audit import ChatAuditor
|
|
39
|
+
from hyperforge_nucliadb_agentic.ask.exceptions import (
|
|
40
|
+
AnswerJsonSchemaTooLong,
|
|
41
|
+
IncompleteFindResultsError,
|
|
42
|
+
InvalidQueryError,
|
|
43
|
+
KnowledgeBoxNotFound,
|
|
44
|
+
NoRetrievalResultsError,
|
|
45
|
+
NucliaDBError,
|
|
46
|
+
)
|
|
47
|
+
from hyperforge_nucliadb_agentic.ask.model import (
|
|
48
|
+
AnswerAskResponseItem,
|
|
49
|
+
AskRequest,
|
|
50
|
+
AskResponseItem,
|
|
51
|
+
AskResponseItemType,
|
|
52
|
+
AskRetrievalMatch,
|
|
53
|
+
AskTimings,
|
|
54
|
+
AskTokens,
|
|
55
|
+
AugmentedContext,
|
|
56
|
+
AugmentedContextResponseItem,
|
|
57
|
+
ChatContextMessage,
|
|
58
|
+
ChatModel,
|
|
59
|
+
ChatOptions,
|
|
60
|
+
CitationsAskResponseItem,
|
|
61
|
+
ConsumptionResponseItem,
|
|
62
|
+
DebugAskResponseItem,
|
|
63
|
+
ErrorAskResponseItem,
|
|
64
|
+
FindRequest,
|
|
65
|
+
FootnoteCitationsAskResponseItem,
|
|
66
|
+
GraphStrategy,
|
|
67
|
+
JSONAskResponseItem,
|
|
68
|
+
KnowledgeboxFindResults,
|
|
69
|
+
MetadataAskResponseItem,
|
|
70
|
+
PrequeriesAskResponseItem,
|
|
71
|
+
PreQueriesStrategy,
|
|
72
|
+
PreQuery,
|
|
73
|
+
PreQueryResult,
|
|
74
|
+
PromptContext,
|
|
75
|
+
PromptContextOrder,
|
|
76
|
+
RagStrategyName,
|
|
77
|
+
ReasoningAskResponseItem,
|
|
78
|
+
Relations,
|
|
79
|
+
RelationsAskResponseItem,
|
|
80
|
+
RephraseModel,
|
|
81
|
+
RetrievalAskResponseItem,
|
|
82
|
+
StatusAskResponseItem,
|
|
83
|
+
SyncAskMetadata,
|
|
84
|
+
SyncAskResponse,
|
|
85
|
+
TokensDetail,
|
|
86
|
+
UserPrompt,
|
|
87
|
+
parse_custom_prompt,
|
|
88
|
+
parse_rephrase_prompt,
|
|
89
|
+
)
|
|
90
|
+
from hyperforge_nucliadb_agentic.ask.predict import (
|
|
91
|
+
AnswerStatusCode,
|
|
92
|
+
RephraseMissingContextError,
|
|
93
|
+
RephraseResponse,
|
|
94
|
+
get_predict,
|
|
95
|
+
)
|
|
96
|
+
from hyperforge_nucliadb_agentic.ask.search.graph_strategy import (
|
|
97
|
+
get_graph_results,
|
|
98
|
+
)
|
|
99
|
+
from hyperforge_nucliadb_agentic.ask.search.metrics import (
|
|
100
|
+
AskMetrics,
|
|
101
|
+
Metrics,
|
|
102
|
+
)
|
|
103
|
+
from hyperforge_nucliadb_agentic.ask.search.parsers.ask import (
|
|
104
|
+
fetcher_for_ask,
|
|
105
|
+
parse_ask,
|
|
106
|
+
)
|
|
107
|
+
from hyperforge_nucliadb_agentic.ask.search.parsers.fetcher import (
|
|
108
|
+
Fetcher,
|
|
109
|
+
)
|
|
110
|
+
from hyperforge_nucliadb_agentic.ask.search.prompt import (
|
|
111
|
+
PromptContextBuilder,
|
|
112
|
+
)
|
|
113
|
+
from hyperforge_nucliadb_agentic.ask.search.rank_fusion import (
|
|
114
|
+
WeightedCombSum,
|
|
115
|
+
)
|
|
116
|
+
from hyperforge_nucliadb_agentic.ask.search.retrieval import (
|
|
117
|
+
NOT_ENOUGH_CONTEXT_ANSWER,
|
|
118
|
+
add_resource_filter,
|
|
119
|
+
get_answer_stream,
|
|
120
|
+
get_find_results,
|
|
121
|
+
get_relations_results,
|
|
122
|
+
sorted_prompt_context_list,
|
|
123
|
+
)
|
|
124
|
+
from hyperforge_nucliadb_agentic.ask.utils.ids import ParagraphId
|
|
125
|
+
from hyperforge_nucliadb_agentic.ask.utils.responses import (
|
|
126
|
+
HTTPClientError,
|
|
127
|
+
)
|
|
128
|
+
from hyperforge_nucliadb_agentic.ask.utils.text_blocks import (
|
|
129
|
+
ScoredTextBlock,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclasses.dataclass
|
|
134
|
+
class RetrievalMatch:
|
|
135
|
+
paragraph: FindParagraph
|
|
136
|
+
weighted_score: float
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclasses.dataclass
|
|
140
|
+
class RetrievalResults:
|
|
141
|
+
main_query: KnowledgeboxFindResults
|
|
142
|
+
fetcher: Fetcher
|
|
143
|
+
main_query_weight: float
|
|
144
|
+
prequeries: list[PreQueryResult] | None = None
|
|
145
|
+
best_matches: list[RetrievalMatch] = dataclasses.field(default_factory=list)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AskResult:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
kbid: str,
|
|
153
|
+
ask_request: AskRequest,
|
|
154
|
+
main_results: KnowledgeboxFindResults,
|
|
155
|
+
prequeries_results: list[PreQueryResult] | None,
|
|
156
|
+
nuclia_learning_id: str | None,
|
|
157
|
+
predict_answer_stream: AsyncGenerator[GenerativeChunk, None] | None,
|
|
158
|
+
prompt_context: PromptContext,
|
|
159
|
+
prompt_context_order: PromptContextOrder,
|
|
160
|
+
auditor: ChatAuditor,
|
|
161
|
+
metrics: AskMetrics,
|
|
162
|
+
best_matches: list[RetrievalMatch],
|
|
163
|
+
debug_chat_model: ChatModel | None,
|
|
164
|
+
augmented_context: AugmentedContext,
|
|
165
|
+
search_sdk: NucliaDBAsync,
|
|
166
|
+
):
|
|
167
|
+
# Initial attributes
|
|
168
|
+
self.kbid = kbid
|
|
169
|
+
self.ask_request = ask_request
|
|
170
|
+
self.main_results = main_results
|
|
171
|
+
self.prequeries_results = prequeries_results or []
|
|
172
|
+
self.nuclia_learning_id = nuclia_learning_id
|
|
173
|
+
self.predict_answer_stream = predict_answer_stream
|
|
174
|
+
self.prompt_context = prompt_context
|
|
175
|
+
self.debug_chat_model = debug_chat_model
|
|
176
|
+
self.prompt_context_order = prompt_context_order
|
|
177
|
+
self.auditor: ChatAuditor = auditor
|
|
178
|
+
self.metrics: AskMetrics = metrics
|
|
179
|
+
self.best_matches: list[RetrievalMatch] = best_matches
|
|
180
|
+
self.augmented_context = augmented_context
|
|
181
|
+
self.search_sdk = search_sdk
|
|
182
|
+
|
|
183
|
+
# Computed from the predict chat answer stream
|
|
184
|
+
self._answer_text = ""
|
|
185
|
+
self._reasoning_text: str | None = None
|
|
186
|
+
self._object: JSONGenerativeResponse | None = None
|
|
187
|
+
self._status: StatusGenerativeResponse | None = None
|
|
188
|
+
self._citations: CitationsGenerativeResponse | None = None
|
|
189
|
+
self._footnote_citations: FootnoteCitationsGenerativeResponse | None = None
|
|
190
|
+
self._metadata: MetaGenerativeResponse | None = None
|
|
191
|
+
self._relations: Relations | None = None
|
|
192
|
+
self._consumption: Consumption | None = None
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def status_code(self) -> AnswerStatusCode:
|
|
196
|
+
if self._status is None:
|
|
197
|
+
return AnswerStatusCode.SUCCESS
|
|
198
|
+
return AnswerStatusCode(self._status.code)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def status_error_details(self) -> str | None:
|
|
202
|
+
if self._status is None: # pragma: no cover
|
|
203
|
+
return None
|
|
204
|
+
return self._status.details
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def ask_request_with_relations(self) -> bool:
|
|
208
|
+
return ChatOptions.RELATIONS in self.ask_request.features
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def ask_request_with_debug_flag(self) -> bool:
|
|
212
|
+
return self.ask_request.debug
|
|
213
|
+
|
|
214
|
+
async def ndjson_stream(self) -> AsyncGenerator[str, None]:
|
|
215
|
+
try:
|
|
216
|
+
async for item in self._stream():
|
|
217
|
+
yield self._ndjson_encode(item)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
# Handle any unexpected error that might happen
|
|
220
|
+
# during the streaming and halt the stream
|
|
221
|
+
errors.capture_exception(exc)
|
|
222
|
+
logger.error(
|
|
223
|
+
f"Unexpected error while generating the answer: {exc}",
|
|
224
|
+
extra={"kbid": self.kbid},
|
|
225
|
+
)
|
|
226
|
+
error_message = (
|
|
227
|
+
"Unexpected error while generating the answer. Please try again later."
|
|
228
|
+
)
|
|
229
|
+
if self.ask_request_with_debug_flag:
|
|
230
|
+
error_message += f" Error: {exc}"
|
|
231
|
+
item = ErrorAskResponseItem(error=error_message)
|
|
232
|
+
yield self._ndjson_encode(item)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
def _ndjson_encode(self, item: AskResponseItemType) -> str:
|
|
236
|
+
result_item = AskResponseItem(item=item)
|
|
237
|
+
return result_item.model_dump_json(exclude_none=True, by_alias=True) + "\n"
|
|
238
|
+
|
|
239
|
+
async def _stream(self) -> AsyncGenerator[AskResponseItemType, None]:
|
|
240
|
+
# First, stream out the predict answer
|
|
241
|
+
first_chunk_yielded = False
|
|
242
|
+
first_reasoning_chunk_yielded = False
|
|
243
|
+
with self.metrics.time("stream_predict_answer"):
|
|
244
|
+
async for answer_chunk in self._stream_predict_answer_text():
|
|
245
|
+
if isinstance(answer_chunk, TextGenerativeResponse):
|
|
246
|
+
yield AnswerAskResponseItem(text=answer_chunk.text)
|
|
247
|
+
if not first_chunk_yielded:
|
|
248
|
+
self.metrics.record_first_chunk_yielded()
|
|
249
|
+
first_chunk_yielded = True
|
|
250
|
+
elif isinstance(answer_chunk, ReasoningGenerativeResponse):
|
|
251
|
+
yield ReasoningAskResponseItem(text=answer_chunk.text)
|
|
252
|
+
if not first_reasoning_chunk_yielded:
|
|
253
|
+
self.metrics.record_first_reasoning_chunk_yielded()
|
|
254
|
+
first_reasoning_chunk_yielded = True
|
|
255
|
+
else:
|
|
256
|
+
assert_never(answer_chunk)
|
|
257
|
+
|
|
258
|
+
if self._object is not None:
|
|
259
|
+
yield JSONAskResponseItem(object=self._object.object)
|
|
260
|
+
if not first_chunk_yielded:
|
|
261
|
+
# When there is a JSON generative response, we consider the first chunk yielded
|
|
262
|
+
# to be the moment when the JSON object is yielded, not the text
|
|
263
|
+
self.metrics.record_first_chunk_yielded()
|
|
264
|
+
first_chunk_yielded = True
|
|
265
|
+
|
|
266
|
+
yield RetrievalAskResponseItem(
|
|
267
|
+
results=self.main_results,
|
|
268
|
+
best_matches=[
|
|
269
|
+
AskRetrievalMatch(
|
|
270
|
+
id=match.paragraph.id,
|
|
271
|
+
)
|
|
272
|
+
for match in self.best_matches
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if len(self.prequeries_results) > 0:
|
|
277
|
+
item = PrequeriesAskResponseItem()
|
|
278
|
+
for index, (prequery, result) in enumerate(self.prequeries_results):
|
|
279
|
+
prequery_id = prequery.id or f"prequery_{index}"
|
|
280
|
+
item.results[prequery_id] = result
|
|
281
|
+
yield item
|
|
282
|
+
|
|
283
|
+
# Then the status
|
|
284
|
+
if self.status_code == AnswerStatusCode.ERROR:
|
|
285
|
+
# If predict yielded an error status, we yield it too and halt the stream immediately
|
|
286
|
+
yield StatusAskResponseItem(
|
|
287
|
+
code=self.status_code.value,
|
|
288
|
+
status=self.status_code.prettify(),
|
|
289
|
+
details=self.status_error_details or "Unknown error",
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
yield StatusAskResponseItem(
|
|
294
|
+
code=self.status_code.value,
|
|
295
|
+
status=self.status_code.prettify(),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Audit the answer
|
|
299
|
+
if self._object is None:
|
|
300
|
+
audit_answer = self._answer_text.encode("utf-8")
|
|
301
|
+
else:
|
|
302
|
+
audit_answer = json.dumps(self._object.object).encode("utf-8")
|
|
303
|
+
self.auditor.audit(
|
|
304
|
+
text_answer=audit_answer,
|
|
305
|
+
text_reasoning=self._reasoning_text,
|
|
306
|
+
generative_answer_time=self.metrics["stream_predict_answer"],
|
|
307
|
+
generative_answer_first_chunk_time=self.metrics.get_first_chunk_time() or 0,
|
|
308
|
+
generative_reasoning_first_chunk_time=self.metrics.get_first_reasoning_chunk_time(),
|
|
309
|
+
rephrase_time=self.metrics.get("rephrase"),
|
|
310
|
+
status_code=self.status_code,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
yield AugmentedContextResponseItem(augmented=self.augmented_context)
|
|
314
|
+
|
|
315
|
+
# Stream out the citations
|
|
316
|
+
if self._citations is not None:
|
|
317
|
+
yield CitationsAskResponseItem(
|
|
318
|
+
citations=self._citations.citations,
|
|
319
|
+
)
|
|
320
|
+
# Stream out the footnote citations mapping
|
|
321
|
+
if self._footnote_citations is not None:
|
|
322
|
+
yield FootnoteCitationsAskResponseItem(
|
|
323
|
+
footnote_to_context=self._footnote_citations.footnote_to_context,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Stream out generic metadata about the answer
|
|
327
|
+
if self._metadata is not None:
|
|
328
|
+
yield MetadataAskResponseItem(
|
|
329
|
+
tokens=AskTokens(
|
|
330
|
+
input=self._metadata.input_tokens,
|
|
331
|
+
output=self._metadata.output_tokens,
|
|
332
|
+
input_nuclia=self._metadata.input_nuclia_tokens,
|
|
333
|
+
output_nuclia=self._metadata.output_nuclia_tokens,
|
|
334
|
+
),
|
|
335
|
+
timings=AskTimings(
|
|
336
|
+
generative_first_chunk=self._metadata.timings.get(
|
|
337
|
+
"generative_first_chunk"
|
|
338
|
+
),
|
|
339
|
+
generative_total=self._metadata.timings.get("generative"),
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if self._consumption is not None:
|
|
344
|
+
yield ConsumptionResponseItem(
|
|
345
|
+
normalized_tokens=TokensDetail(
|
|
346
|
+
input=self._consumption.normalized_tokens.input,
|
|
347
|
+
output=self._consumption.normalized_tokens.output,
|
|
348
|
+
image=self._consumption.normalized_tokens.image,
|
|
349
|
+
),
|
|
350
|
+
customer_key_tokens=TokensDetail(
|
|
351
|
+
input=self._consumption.customer_key_tokens.input,
|
|
352
|
+
output=self._consumption.customer_key_tokens.output,
|
|
353
|
+
image=self._consumption.customer_key_tokens.image,
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Stream out the relations results
|
|
358
|
+
should_query_relations = (
|
|
359
|
+
self.ask_request_with_relations
|
|
360
|
+
and self.status_code == AnswerStatusCode.SUCCESS
|
|
361
|
+
)
|
|
362
|
+
if should_query_relations:
|
|
363
|
+
relations = await self.get_relations_results()
|
|
364
|
+
yield RelationsAskResponseItem(relations=relations)
|
|
365
|
+
|
|
366
|
+
# Stream out debug information
|
|
367
|
+
if self.ask_request_with_debug_flag:
|
|
368
|
+
predict_request = None
|
|
369
|
+
if self.debug_chat_model:
|
|
370
|
+
predict_request = self.debug_chat_model.model_dump(mode="json")
|
|
371
|
+
yield DebugAskResponseItem(
|
|
372
|
+
metadata={
|
|
373
|
+
"prompt_context": sorted_prompt_context_list(
|
|
374
|
+
self.prompt_context, self.prompt_context_order
|
|
375
|
+
),
|
|
376
|
+
"predict_request": predict_request,
|
|
377
|
+
},
|
|
378
|
+
metrics=self.metrics.dump(),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
async def model_dump(self) -> SyncAskResponse:
|
|
382
|
+
# Run the stream to completion to ensure all data is available in memory
|
|
383
|
+
# First, run the stream in memory to get all the data in memory
|
|
384
|
+
async for _ in self._stream():
|
|
385
|
+
...
|
|
386
|
+
|
|
387
|
+
metadata = None
|
|
388
|
+
if self._metadata is not None:
|
|
389
|
+
metadata = SyncAskMetadata(
|
|
390
|
+
tokens=AskTokens(
|
|
391
|
+
input=self._metadata.input_tokens,
|
|
392
|
+
output=self._metadata.output_tokens,
|
|
393
|
+
input_nuclia=self._metadata.input_nuclia_tokens,
|
|
394
|
+
output_nuclia=self._metadata.output_nuclia_tokens,
|
|
395
|
+
),
|
|
396
|
+
timings=AskTimings(
|
|
397
|
+
generative_first_chunk=self._metadata.timings.get(
|
|
398
|
+
"generative_first_chunk"
|
|
399
|
+
),
|
|
400
|
+
generative_total=self._metadata.timings.get("generative"),
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
citations = {}
|
|
405
|
+
if self._citations is not None:
|
|
406
|
+
citations = self._citations.citations
|
|
407
|
+
|
|
408
|
+
footnote_citations = {}
|
|
409
|
+
if self._footnote_citations is not None:
|
|
410
|
+
footnote_citations = self._footnote_citations.footnote_to_context
|
|
411
|
+
|
|
412
|
+
answer_json = None
|
|
413
|
+
if self._object is not None:
|
|
414
|
+
answer_json = self._object.object
|
|
415
|
+
|
|
416
|
+
prequeries_results: dict[str, KnowledgeboxFindResults] | None = None
|
|
417
|
+
if self.prequeries_results:
|
|
418
|
+
prequeries_results = {}
|
|
419
|
+
for index, (prequery, result) in enumerate(self.prequeries_results):
|
|
420
|
+
prequery_id = prequery.id or f"prequery_{index}"
|
|
421
|
+
prequeries_results[prequery_id] = result
|
|
422
|
+
|
|
423
|
+
best_matches = [
|
|
424
|
+
AskRetrievalMatch(
|
|
425
|
+
id=match.paragraph.id,
|
|
426
|
+
)
|
|
427
|
+
for match in self.best_matches
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
response = SyncAskResponse(
|
|
431
|
+
answer=self._answer_text,
|
|
432
|
+
reasoning=self._reasoning_text,
|
|
433
|
+
answer_json=answer_json,
|
|
434
|
+
status=self.status_code.prettify(),
|
|
435
|
+
relations=self._relations,
|
|
436
|
+
retrieval_results=self.main_results,
|
|
437
|
+
retrieval_best_matches=best_matches,
|
|
438
|
+
prequeries=prequeries_results,
|
|
439
|
+
citations=citations,
|
|
440
|
+
citation_footnote_to_context=footnote_citations,
|
|
441
|
+
metadata=metadata,
|
|
442
|
+
consumption=self._consumption,
|
|
443
|
+
learning_id=self.nuclia_learning_id or "",
|
|
444
|
+
augmented_context=self.augmented_context,
|
|
445
|
+
)
|
|
446
|
+
if self.status_code == AnswerStatusCode.ERROR and self.status_error_details:
|
|
447
|
+
response.error_details = self.status_error_details
|
|
448
|
+
if self.ask_request_with_debug_flag:
|
|
449
|
+
sorted_prompt_context = sorted_prompt_context_list(
|
|
450
|
+
self.prompt_context, self.prompt_context_order
|
|
451
|
+
)
|
|
452
|
+
response.prompt_context = sorted_prompt_context
|
|
453
|
+
if self.debug_chat_model:
|
|
454
|
+
response.predict_request = self.debug_chat_model.model_dump(mode="json")
|
|
455
|
+
response.debug = {
|
|
456
|
+
"metrics": self.metrics.dump(),
|
|
457
|
+
}
|
|
458
|
+
return response
|
|
459
|
+
|
|
460
|
+
async def json(self) -> str:
|
|
461
|
+
response = await self.model_dump()
|
|
462
|
+
return response.model_dump_json(exclude_none=True, by_alias=True)
|
|
463
|
+
|
|
464
|
+
async def get_relations_results(self) -> Relations:
|
|
465
|
+
if self._relations is None:
|
|
466
|
+
with self.metrics.time("relations"):
|
|
467
|
+
self._relations = await get_relations_results(
|
|
468
|
+
search_sdk=self.search_sdk,
|
|
469
|
+
kbid=self.kbid,
|
|
470
|
+
text_answer=self._answer_text,
|
|
471
|
+
timeout=5.0,
|
|
472
|
+
)
|
|
473
|
+
return self._relations
|
|
474
|
+
|
|
475
|
+
async def _stream_predict_answer_text(
|
|
476
|
+
self,
|
|
477
|
+
) -> AsyncGenerator[TextGenerativeResponse | ReasoningGenerativeResponse, None]:
|
|
478
|
+
"""
|
|
479
|
+
Reads the stream of the generative model, yielding the answer text but also parsing
|
|
480
|
+
other items like status codes, citations and miscellaneous metadata.
|
|
481
|
+
|
|
482
|
+
This method does not assume any order in the stream of items, but it assumes that at least
|
|
483
|
+
the answer text is streamed in order.
|
|
484
|
+
"""
|
|
485
|
+
if self.predict_answer_stream is None:
|
|
486
|
+
# In some cases, clients may want to skip the answer generation step
|
|
487
|
+
return
|
|
488
|
+
async for generative_chunk in self.predict_answer_stream:
|
|
489
|
+
item = generative_chunk.chunk
|
|
490
|
+
if isinstance(item, TextGenerativeResponse):
|
|
491
|
+
self._answer_text += item.text
|
|
492
|
+
yield item
|
|
493
|
+
elif isinstance(item, ReasoningGenerativeResponse):
|
|
494
|
+
if self._reasoning_text is None:
|
|
495
|
+
self._reasoning_text = item.text
|
|
496
|
+
else:
|
|
497
|
+
self._reasoning_text += item.text
|
|
498
|
+
yield item
|
|
499
|
+
elif isinstance(item, JSONGenerativeResponse):
|
|
500
|
+
self._object = item
|
|
501
|
+
elif isinstance(item, StatusGenerativeResponse):
|
|
502
|
+
self._status = item
|
|
503
|
+
elif isinstance(item, CitationsGenerativeResponse):
|
|
504
|
+
self._citations = item
|
|
505
|
+
elif isinstance(item, FootnoteCitationsGenerativeResponse):
|
|
506
|
+
self._footnote_citations = item
|
|
507
|
+
elif isinstance(item, MetaGenerativeResponse):
|
|
508
|
+
self._metadata = item
|
|
509
|
+
elif isinstance(item, Consumption):
|
|
510
|
+
self._consumption = item
|
|
511
|
+
else:
|
|
512
|
+
logger.warning(
|
|
513
|
+
f"Unexpected item in predict answer stream: {item}",
|
|
514
|
+
extra={"kbid": self.kbid},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class NotEnoughContextAskResult(AskResult):
|
|
519
|
+
def __init__(
|
|
520
|
+
self,
|
|
521
|
+
main_results: KnowledgeboxFindResults | None = None,
|
|
522
|
+
prequeries_results: list[PreQueryResult] | None = None,
|
|
523
|
+
):
|
|
524
|
+
self.main_results = main_results or KnowledgeboxFindResults(
|
|
525
|
+
resources={}, min_score=None
|
|
526
|
+
)
|
|
527
|
+
self.prequeries_results = prequeries_results or []
|
|
528
|
+
self.nuclia_learning_id = None
|
|
529
|
+
|
|
530
|
+
async def ndjson_stream(self) -> AsyncGenerator[str, None]:
|
|
531
|
+
"""
|
|
532
|
+
In the case where there are no results in the retrieval phase, we simply
|
|
533
|
+
return the find results and the messages indicating that there is not enough
|
|
534
|
+
context in the corpus to answer.
|
|
535
|
+
"""
|
|
536
|
+
status = AnswerStatusCode.NO_RETRIEVAL_DATA
|
|
537
|
+
yield self._ndjson_encode(
|
|
538
|
+
StatusAskResponseItem(code=status.value, status=status.prettify())
|
|
539
|
+
)
|
|
540
|
+
yield self._ndjson_encode(AnswerAskResponseItem(text=NOT_ENOUGH_CONTEXT_ANSWER))
|
|
541
|
+
yield self._ndjson_encode(RetrievalAskResponseItem(results=self.main_results))
|
|
542
|
+
if self.prequeries_results:
|
|
543
|
+
yield self._ndjson_encode(
|
|
544
|
+
PrequeriesAskResponseItem(
|
|
545
|
+
results={
|
|
546
|
+
prequery.id or f"prequery_{index}": prequery_result
|
|
547
|
+
for index, (prequery, prequery_result) in enumerate(
|
|
548
|
+
self.prequeries_results
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
async def json(self) -> str:
|
|
555
|
+
prequeries = (
|
|
556
|
+
{
|
|
557
|
+
prequery.id or f"prequery_{index}": prequery_result
|
|
558
|
+
for index, (prequery, prequery_result) in enumerate(
|
|
559
|
+
self.prequeries_results
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
if self.prequeries_results
|
|
563
|
+
else None
|
|
564
|
+
)
|
|
565
|
+
return SyncAskResponse(
|
|
566
|
+
answer=NOT_ENOUGH_CONTEXT_ANSWER,
|
|
567
|
+
retrieval_results=self.main_results,
|
|
568
|
+
prequeries=prequeries,
|
|
569
|
+
status=AnswerStatusCode.NO_RETRIEVAL_DATA.prettify(),
|
|
570
|
+
).model_dump_json()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def rephrase_query(
|
|
574
|
+
kbid: str,
|
|
575
|
+
chat_history: list[ChatContextMessage],
|
|
576
|
+
query: str,
|
|
577
|
+
user_id: str,
|
|
578
|
+
user_context: list[str],
|
|
579
|
+
generative_model: str | None = None,
|
|
580
|
+
chat_history_relevance_threshold: float | None = None,
|
|
581
|
+
) -> RephraseResponse:
|
|
582
|
+
# NOTE: When moving /ask to RAO, this will need to change to whatever client/utility is used
|
|
583
|
+
# to call NUA predict (internally or externally in the case of onprem).
|
|
584
|
+
predict = get_predict()
|
|
585
|
+
req = RephraseModel(
|
|
586
|
+
question=query,
|
|
587
|
+
chat_history=chat_history,
|
|
588
|
+
user_id=user_id,
|
|
589
|
+
user_context=user_context,
|
|
590
|
+
generative_model=generative_model,
|
|
591
|
+
chat_history_relevance_threshold=chat_history_relevance_threshold,
|
|
592
|
+
)
|
|
593
|
+
return await predict.rephrase_query(kbid, req)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def ask(
|
|
597
|
+
*,
|
|
598
|
+
search_sdk: NucliaDBAsync,
|
|
599
|
+
reader_sdk: NucliaDBAsync,
|
|
600
|
+
kbid: str,
|
|
601
|
+
ask_request: AskRequest,
|
|
602
|
+
user_id: str,
|
|
603
|
+
client_type: NucliaDBClientType,
|
|
604
|
+
origin: str,
|
|
605
|
+
resource: str | None = None,
|
|
606
|
+
extra_predict_headers: dict[str, str] | None = None,
|
|
607
|
+
) -> AskResult:
|
|
608
|
+
metrics = AskMetrics()
|
|
609
|
+
chat_history = ask_request.chat_history or []
|
|
610
|
+
user_context = ask_request.extra_context or []
|
|
611
|
+
user_query = ask_request.query
|
|
612
|
+
|
|
613
|
+
# Maybe rephrase the query
|
|
614
|
+
rephrased_query = None
|
|
615
|
+
if len(chat_history) > 0 or len(user_context) > 0:
|
|
616
|
+
try:
|
|
617
|
+
with metrics.time("rephrase"):
|
|
618
|
+
rephrase_response = await rephrase_query(
|
|
619
|
+
kbid,
|
|
620
|
+
chat_history=chat_history,
|
|
621
|
+
query=user_query,
|
|
622
|
+
user_id=user_id,
|
|
623
|
+
user_context=user_context,
|
|
624
|
+
generative_model=ask_request.generative_model,
|
|
625
|
+
chat_history_relevance_threshold=ask_request.chat_history_relevance_threshold,
|
|
626
|
+
)
|
|
627
|
+
rephrased_query = rephrase_response.rephrased_query
|
|
628
|
+
if rephrase_response.use_chat_history is False:
|
|
629
|
+
# Ignored if the question is not relevant enough with the chat history
|
|
630
|
+
logger.info("Chat history was ignored for this request")
|
|
631
|
+
chat_history = []
|
|
632
|
+
|
|
633
|
+
except RephraseMissingContextError:
|
|
634
|
+
logger.info("Failed to rephrase ask query, using original")
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
with metrics.time("retrieval"):
|
|
638
|
+
retrieval_results = await retrieval_step(
|
|
639
|
+
reader_sdk=reader_sdk,
|
|
640
|
+
search_sdk=search_sdk,
|
|
641
|
+
kbid=kbid,
|
|
642
|
+
# Prefer the rephrased query for retrieval if available
|
|
643
|
+
main_query=rephrased_query or user_query,
|
|
644
|
+
ask_request=ask_request,
|
|
645
|
+
client_type=client_type,
|
|
646
|
+
user_id=user_id,
|
|
647
|
+
origin=origin,
|
|
648
|
+
metrics=metrics,
|
|
649
|
+
resource=resource,
|
|
650
|
+
)
|
|
651
|
+
except NoRetrievalResultsError as err:
|
|
652
|
+
# TODO: maybe audit chat (see nucliadb for more context)
|
|
653
|
+
|
|
654
|
+
# If a retrieval was attempted but no results were found,
|
|
655
|
+
# early return the ask endpoint without querying the generative model
|
|
656
|
+
return NotEnoughContextAskResult(
|
|
657
|
+
main_results=err.main_query,
|
|
658
|
+
prequeries_results=err.prequeries, # type: ignore
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# parse ask request generation parameters reusing the same fetcher as
|
|
662
|
+
# retrieval, to avoid multiple round trips to Predict API
|
|
663
|
+
generation = await parse_ask(kbid, ask_request, fetcher=retrieval_results.fetcher)
|
|
664
|
+
|
|
665
|
+
# Now we build the prompt context
|
|
666
|
+
with metrics.time("context_building"):
|
|
667
|
+
prompt_context_builder = PromptContextBuilder(
|
|
668
|
+
search_sdk=search_sdk,
|
|
669
|
+
reader_sdk=reader_sdk,
|
|
670
|
+
kbid=kbid,
|
|
671
|
+
ordered_paragraphs=[
|
|
672
|
+
match.paragraph for match in retrieval_results.best_matches
|
|
673
|
+
],
|
|
674
|
+
resource=resource,
|
|
675
|
+
user_context=user_context,
|
|
676
|
+
user_image_context=ask_request.extra_context_images,
|
|
677
|
+
strategies=ask_request.rag_strategies,
|
|
678
|
+
image_strategies=ask_request.rag_images_strategies,
|
|
679
|
+
max_context_characters=tokens_to_chars(generation.max_context_tokens),
|
|
680
|
+
visual_llm=generation.use_visual_llm,
|
|
681
|
+
query_image=ask_request.query_image,
|
|
682
|
+
metrics=metrics.child_span("context_building"),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
(
|
|
686
|
+
prompt_context,
|
|
687
|
+
prompt_context_order,
|
|
688
|
+
prompt_context_images,
|
|
689
|
+
augmented_context,
|
|
690
|
+
) = await prompt_context_builder.build()
|
|
691
|
+
|
|
692
|
+
# Make the chat request to the predict API
|
|
693
|
+
custom_prompt = parse_custom_prompt(ask_request)
|
|
694
|
+
chat_model = ChatModel(
|
|
695
|
+
user_id=user_id,
|
|
696
|
+
system=custom_prompt.system,
|
|
697
|
+
user_prompt=UserPrompt(prompt=custom_prompt.user)
|
|
698
|
+
if custom_prompt.user
|
|
699
|
+
else None,
|
|
700
|
+
query_context=prompt_context,
|
|
701
|
+
query_context_order=prompt_context_order,
|
|
702
|
+
chat_history=chat_history,
|
|
703
|
+
question=user_query,
|
|
704
|
+
truncate=True,
|
|
705
|
+
citations=ask_request.citations,
|
|
706
|
+
citation_threshold=ask_request.citation_threshold,
|
|
707
|
+
generative_model=ask_request.generative_model,
|
|
708
|
+
seed=ask_request.generative_model_seed,
|
|
709
|
+
max_tokens=generation.max_answer_tokens,
|
|
710
|
+
query_context_images=prompt_context_images,
|
|
711
|
+
json_schema=ask_request.answer_json_schema,
|
|
712
|
+
rerank_context=False,
|
|
713
|
+
top_k=ask_request.top_k,
|
|
714
|
+
reasoning=ask_request.reasoning,
|
|
715
|
+
)
|
|
716
|
+
nuclia_learning_id = None
|
|
717
|
+
nuclia_learning_model = None
|
|
718
|
+
predict_answer_stream = None
|
|
719
|
+
if ask_request.generate_answer:
|
|
720
|
+
with metrics.time("stream_start"):
|
|
721
|
+
(
|
|
722
|
+
nuclia_learning_id,
|
|
723
|
+
nuclia_learning_model,
|
|
724
|
+
predict_answer_stream,
|
|
725
|
+
) = await get_answer_stream(
|
|
726
|
+
kbid=kbid, item=chat_model, extra_headers=extra_predict_headers
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
auditor = ChatAuditor(
|
|
730
|
+
kbid=kbid,
|
|
731
|
+
user_id=user_id,
|
|
732
|
+
client_type=client_type,
|
|
733
|
+
origin=origin,
|
|
734
|
+
ask_request=ask_request,
|
|
735
|
+
user_query=user_query,
|
|
736
|
+
rephrased_query=rephrased_query,
|
|
737
|
+
retrieval_rephrased_query=retrieval_results.main_query.rephrased_query,
|
|
738
|
+
chat_history=chat_history,
|
|
739
|
+
learning_id=nuclia_learning_id,
|
|
740
|
+
query_context=prompt_context,
|
|
741
|
+
query_context_order=prompt_context_order,
|
|
742
|
+
model=nuclia_learning_model,
|
|
743
|
+
)
|
|
744
|
+
return AskResult(
|
|
745
|
+
kbid=kbid,
|
|
746
|
+
ask_request=ask_request,
|
|
747
|
+
main_results=retrieval_results.main_query,
|
|
748
|
+
prequeries_results=retrieval_results.prequeries,
|
|
749
|
+
nuclia_learning_id=nuclia_learning_id,
|
|
750
|
+
predict_answer_stream=predict_answer_stream,
|
|
751
|
+
prompt_context=prompt_context,
|
|
752
|
+
prompt_context_order=prompt_context_order,
|
|
753
|
+
auditor=auditor,
|
|
754
|
+
metrics=metrics,
|
|
755
|
+
best_matches=retrieval_results.best_matches,
|
|
756
|
+
debug_chat_model=chat_model,
|
|
757
|
+
augmented_context=augmented_context,
|
|
758
|
+
search_sdk=search_sdk,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def handled_ask_exceptions(func):
|
|
763
|
+
@functools.wraps(func)
|
|
764
|
+
async def wrapper(*args, **kwargs):
|
|
765
|
+
try:
|
|
766
|
+
return await func(*args, **kwargs)
|
|
767
|
+
except KnowledgeBoxNotFound:
|
|
768
|
+
return HTTPClientError(
|
|
769
|
+
status_code=404,
|
|
770
|
+
detail="Knowledge Box not found.",
|
|
771
|
+
)
|
|
772
|
+
except LimitsExceededError as exc:
|
|
773
|
+
return HTTPClientError(status_code=exc.status_code, detail=exc.detail)
|
|
774
|
+
except predict.ProxiedPredictAPIError as err:
|
|
775
|
+
return HTTPClientError(
|
|
776
|
+
status_code=err.status,
|
|
777
|
+
detail=err.detail,
|
|
778
|
+
)
|
|
779
|
+
except (IncompleteFindResultsError, NucliaDBError):
|
|
780
|
+
msg = "Temporary error on information retrieval. Please try again."
|
|
781
|
+
logger.exception(msg)
|
|
782
|
+
return HTTPClientError(
|
|
783
|
+
status_code=530,
|
|
784
|
+
detail=msg,
|
|
785
|
+
)
|
|
786
|
+
except predict.RephraseMissingContextError:
|
|
787
|
+
return HTTPClientError(
|
|
788
|
+
status_code=412,
|
|
789
|
+
detail="Unable to rephrase the query with the provided context.",
|
|
790
|
+
)
|
|
791
|
+
except predict.RephraseError as err:
|
|
792
|
+
msg = f"Temporary error while rephrasing the query. Please try again later. Error: {err}"
|
|
793
|
+
logger.info(msg)
|
|
794
|
+
return HTTPClientError(
|
|
795
|
+
status_code=529,
|
|
796
|
+
detail=msg,
|
|
797
|
+
)
|
|
798
|
+
except InvalidQueryError as exc:
|
|
799
|
+
return HTTPClientError(status_code=412, detail=str(exc))
|
|
800
|
+
|
|
801
|
+
return wrapper
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def parse_prequeries(ask_request: AskRequest) -> PreQueriesStrategy | None:
|
|
805
|
+
query_ids = []
|
|
806
|
+
for rag_strategy in ask_request.rag_strategies:
|
|
807
|
+
if rag_strategy.name == RagStrategyName.PREQUERIES:
|
|
808
|
+
prequeries = cast(PreQueriesStrategy, rag_strategy)
|
|
809
|
+
# Give each query a unique id if they don't have one
|
|
810
|
+
for index, query in enumerate(prequeries.queries):
|
|
811
|
+
if query.id is None:
|
|
812
|
+
query.id = f"prequery_{index}"
|
|
813
|
+
if query.id in query_ids:
|
|
814
|
+
raise InvalidQueryError(
|
|
815
|
+
"rag_strategies",
|
|
816
|
+
"Prequeries must have unique ids",
|
|
817
|
+
)
|
|
818
|
+
query_ids.append(query.id)
|
|
819
|
+
return prequeries
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def parse_graph_strategy(ask_request: AskRequest) -> GraphStrategy | None:
|
|
824
|
+
for rag_strategy in ask_request.rag_strategies:
|
|
825
|
+
if rag_strategy.name == RagStrategyName.GRAPH:
|
|
826
|
+
return cast(GraphStrategy, rag_strategy)
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
async def retrieval_step(
|
|
831
|
+
*,
|
|
832
|
+
reader_sdk: NucliaDBAsync,
|
|
833
|
+
search_sdk: NucliaDBAsync,
|
|
834
|
+
kbid: str,
|
|
835
|
+
main_query: str,
|
|
836
|
+
ask_request: AskRequest,
|
|
837
|
+
client_type: NucliaDBClientType,
|
|
838
|
+
user_id: str,
|
|
839
|
+
origin: str,
|
|
840
|
+
metrics: Metrics,
|
|
841
|
+
resource: str | None = None,
|
|
842
|
+
) -> RetrievalResults:
|
|
843
|
+
"""
|
|
844
|
+
This function encapsulates all the logic related to retrieval in the ask endpoint.
|
|
845
|
+
"""
|
|
846
|
+
if resource is None:
|
|
847
|
+
return await retrieval_in_kb(
|
|
848
|
+
kbid,
|
|
849
|
+
main_query,
|
|
850
|
+
ask_request,
|
|
851
|
+
client_type,
|
|
852
|
+
user_id,
|
|
853
|
+
origin,
|
|
854
|
+
metrics,
|
|
855
|
+
reader_sdk=reader_sdk,
|
|
856
|
+
search_sdk=search_sdk,
|
|
857
|
+
)
|
|
858
|
+
else:
|
|
859
|
+
return await retrieval_in_resource(
|
|
860
|
+
kbid,
|
|
861
|
+
resource,
|
|
862
|
+
main_query,
|
|
863
|
+
ask_request,
|
|
864
|
+
client_type,
|
|
865
|
+
user_id,
|
|
866
|
+
origin,
|
|
867
|
+
metrics,
|
|
868
|
+
reader_sdk=reader_sdk,
|
|
869
|
+
search_sdk=search_sdk,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
async def retrieval_in_kb(
|
|
874
|
+
kbid: str,
|
|
875
|
+
main_query: str,
|
|
876
|
+
ask_request: AskRequest,
|
|
877
|
+
client_type: NucliaDBClientType,
|
|
878
|
+
user_id: str,
|
|
879
|
+
origin: str,
|
|
880
|
+
metrics: Metrics,
|
|
881
|
+
*,
|
|
882
|
+
reader_sdk: NucliaDBAsync,
|
|
883
|
+
search_sdk: NucliaDBAsync,
|
|
884
|
+
) -> RetrievalResults:
|
|
885
|
+
prequeries = parse_prequeries(ask_request)
|
|
886
|
+
graph_strategy = parse_graph_strategy(ask_request)
|
|
887
|
+
main_results, prequeries_results, fetcher, reranker = await get_find_results(
|
|
888
|
+
kbid=kbid,
|
|
889
|
+
query=main_query,
|
|
890
|
+
item=ask_request,
|
|
891
|
+
ndb_client=client_type,
|
|
892
|
+
user=user_id,
|
|
893
|
+
origin=origin,
|
|
894
|
+
metrics=metrics.child_span("hybrid_retrieval"),
|
|
895
|
+
reader_sdk=reader_sdk,
|
|
896
|
+
search_sdk=search_sdk,
|
|
897
|
+
prequeries_strategy=prequeries,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
if graph_strategy is not None:
|
|
901
|
+
graph_results, graph_request = await get_graph_results(
|
|
902
|
+
search_sdk=search_sdk,
|
|
903
|
+
kbid=kbid,
|
|
904
|
+
query=main_query,
|
|
905
|
+
item=ask_request,
|
|
906
|
+
ndb_client=client_type,
|
|
907
|
+
user=user_id,
|
|
908
|
+
origin=origin,
|
|
909
|
+
graph_strategy=graph_strategy,
|
|
910
|
+
metrics=metrics.child_span("graph_retrieval"),
|
|
911
|
+
text_block_reranker=reranker,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if prequeries_results is None:
|
|
915
|
+
prequeries_results = []
|
|
916
|
+
|
|
917
|
+
prequery = PreQuery(
|
|
918
|
+
id="graph", request=graph_request, weight=graph_strategy.weight
|
|
919
|
+
)
|
|
920
|
+
prequeries_results.append((prequery, graph_results))
|
|
921
|
+
|
|
922
|
+
if len(main_results.resources) == 0 and all(
|
|
923
|
+
len(prequery_result.resources) == 0
|
|
924
|
+
for (_, prequery_result) in prequeries_results or []
|
|
925
|
+
):
|
|
926
|
+
raise NoRetrievalResultsError(main_results, prequeries_results) # type: ignore
|
|
927
|
+
|
|
928
|
+
main_query_weight = prequeries.main_query_weight if prequeries is not None else 1.0
|
|
929
|
+
best_matches = compute_best_matches(
|
|
930
|
+
main_results=main_results,
|
|
931
|
+
prequeries_results=prequeries_results,
|
|
932
|
+
main_query_weight=main_query_weight,
|
|
933
|
+
)
|
|
934
|
+
return RetrievalResults(
|
|
935
|
+
main_query=main_results,
|
|
936
|
+
prequeries=prequeries_results,
|
|
937
|
+
fetcher=fetcher,
|
|
938
|
+
main_query_weight=main_query_weight,
|
|
939
|
+
best_matches=best_matches,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
async def retrieval_in_resource(
|
|
944
|
+
kbid: str,
|
|
945
|
+
resource: str,
|
|
946
|
+
main_query: str,
|
|
947
|
+
ask_request: AskRequest,
|
|
948
|
+
client_type: NucliaDBClientType,
|
|
949
|
+
user_id: str,
|
|
950
|
+
origin: str,
|
|
951
|
+
metrics: Metrics,
|
|
952
|
+
*,
|
|
953
|
+
reader_sdk: NucliaDBAsync,
|
|
954
|
+
search_sdk: NucliaDBAsync,
|
|
955
|
+
) -> RetrievalResults:
|
|
956
|
+
if any(strategy.name == "full_resource" for strategy in ask_request.rag_strategies):
|
|
957
|
+
# Retrieval is not needed if we are chatting on a specific resource and the full_resource strategy is enabled
|
|
958
|
+
return RetrievalResults(
|
|
959
|
+
main_query=KnowledgeboxFindResults(resources={}, min_score=None),
|
|
960
|
+
prequeries=None,
|
|
961
|
+
fetcher=fetcher_for_ask(kbid, ask_request),
|
|
962
|
+
main_query_weight=1.0,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
prequeries = parse_prequeries(ask_request)
|
|
966
|
+
if (
|
|
967
|
+
prequeries is None
|
|
968
|
+
and ask_request.answer_json_schema is not None
|
|
969
|
+
and main_query == ""
|
|
970
|
+
):
|
|
971
|
+
prequeries = calculate_prequeries_for_json_schema(ask_request)
|
|
972
|
+
|
|
973
|
+
# Make sure the retrieval is scoped to the resource if provided
|
|
974
|
+
add_resource_filter(ask_request, [resource])
|
|
975
|
+
if prequeries is not None:
|
|
976
|
+
for prequery in prequeries.queries:
|
|
977
|
+
if prequery.prefilter is True:
|
|
978
|
+
raise InvalidQueryError(
|
|
979
|
+
"rag_strategies",
|
|
980
|
+
"Prequeries with prefilter are not supported when asking on a resource",
|
|
981
|
+
)
|
|
982
|
+
add_resource_filter(prequery.request, [resource])
|
|
983
|
+
|
|
984
|
+
main_results, prequeries_results, fetcher, _ = await get_find_results(
|
|
985
|
+
kbid=kbid,
|
|
986
|
+
query=main_query,
|
|
987
|
+
item=ask_request,
|
|
988
|
+
ndb_client=client_type,
|
|
989
|
+
user=user_id,
|
|
990
|
+
origin=origin,
|
|
991
|
+
metrics=metrics.child_span("hybrid_retrieval"),
|
|
992
|
+
reader_sdk=reader_sdk,
|
|
993
|
+
search_sdk=search_sdk,
|
|
994
|
+
prequeries_strategy=prequeries,
|
|
995
|
+
)
|
|
996
|
+
if len(main_results.resources) == 0 and all(
|
|
997
|
+
len(prequery_result.resources) == 0
|
|
998
|
+
for (_, prequery_result) in prequeries_results or []
|
|
999
|
+
):
|
|
1000
|
+
raise NoRetrievalResultsError(main_results, prequeries_results) # type: ignore
|
|
1001
|
+
main_query_weight = prequeries.main_query_weight if prequeries is not None else 1.0
|
|
1002
|
+
best_matches = compute_best_matches(
|
|
1003
|
+
main_results=main_results,
|
|
1004
|
+
prequeries_results=prequeries_results,
|
|
1005
|
+
main_query_weight=main_query_weight,
|
|
1006
|
+
)
|
|
1007
|
+
return RetrievalResults(
|
|
1008
|
+
main_query=main_results,
|
|
1009
|
+
prequeries=prequeries_results,
|
|
1010
|
+
fetcher=fetcher,
|
|
1011
|
+
main_query_weight=main_query_weight,
|
|
1012
|
+
best_matches=best_matches,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
class _FindParagraph(ScoredTextBlock):
|
|
1017
|
+
original: FindParagraph
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def compute_best_matches(
|
|
1021
|
+
main_results: KnowledgeboxFindResults,
|
|
1022
|
+
prequeries_results: list[PreQueryResult] | None = None,
|
|
1023
|
+
main_query_weight: float = 1.0,
|
|
1024
|
+
) -> list[RetrievalMatch]:
|
|
1025
|
+
"""
|
|
1026
|
+
Returns the list of matches of the retrieval results, ordered by relevance (descending weighted score).
|
|
1027
|
+
|
|
1028
|
+
If prequeries_results is provided, the paragraphs of the prequeries are weighted according to the
|
|
1029
|
+
normalized weight of the prequery. The paragraph score is not modified, but it is used to determine the order in which they
|
|
1030
|
+
are presented in the LLM prompt context.
|
|
1031
|
+
|
|
1032
|
+
If a paragraph is matched in various prequeries, the final weighted score is the sum of the weighted scores for each prequery.
|
|
1033
|
+
|
|
1034
|
+
`main_query_weight` is the weight given to the paragraphs matching the main query when calculating the final score.
|
|
1035
|
+
"""
|
|
1036
|
+
|
|
1037
|
+
score_type_map = {
|
|
1038
|
+
SCORE_TYPE.VECTOR: SemanticScore,
|
|
1039
|
+
SCORE_TYPE.BM25: KeywordScore,
|
|
1040
|
+
SCORE_TYPE.BOTH: RrfScore, # /find only exposes RRF as rank fusion algorithm
|
|
1041
|
+
SCORE_TYPE.RERANKER: RerankerScore,
|
|
1042
|
+
SCORE_TYPE.RELATION_RELEVANCE: GraphScore,
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
def extract_paragraphs(results: KnowledgeboxFindResults) -> list[_FindParagraph]:
|
|
1046
|
+
paragraphs = []
|
|
1047
|
+
for resource in results.resources.values():
|
|
1048
|
+
for field in resource.fields.values():
|
|
1049
|
+
for paragraph in field.paragraphs.values():
|
|
1050
|
+
# TODO(decoupled-ask): we don't know the score history, as
|
|
1051
|
+
# we are using find results. Once we move boolean queries
|
|
1052
|
+
# inside the new retrieval flow we'll move this and have the
|
|
1053
|
+
# proper information to do this rank fusion
|
|
1054
|
+
paragraphs.append(
|
|
1055
|
+
_FindParagraph(
|
|
1056
|
+
paragraph_id=ParagraphId.from_string(paragraph.id),
|
|
1057
|
+
scores=[
|
|
1058
|
+
score_type_map[paragraph.score_type](
|
|
1059
|
+
score=paragraph.score
|
|
1060
|
+
)
|
|
1061
|
+
],
|
|
1062
|
+
score_type=paragraph.score_type,
|
|
1063
|
+
original=paragraph,
|
|
1064
|
+
)
|
|
1065
|
+
)
|
|
1066
|
+
return paragraphs
|
|
1067
|
+
|
|
1068
|
+
weights = {
|
|
1069
|
+
"main": main_query_weight,
|
|
1070
|
+
}
|
|
1071
|
+
total_weight = main_query_weight
|
|
1072
|
+
find_results = {
|
|
1073
|
+
"main": extract_paragraphs(main_results),
|
|
1074
|
+
}
|
|
1075
|
+
for i, (prequery, prequery_results) in enumerate(prequeries_results or []):
|
|
1076
|
+
weights[f"prequery-{i}"] = prequery.weight
|
|
1077
|
+
total_weight += prequery.weight
|
|
1078
|
+
find_results[f"prequery-{i}"] = extract_paragraphs(prequery_results)
|
|
1079
|
+
|
|
1080
|
+
normalized_weights = {key: value / total_weight for key, value in weights.items()}
|
|
1081
|
+
|
|
1082
|
+
# window does nothing here
|
|
1083
|
+
rank_fusion = WeightedCombSum(window=0, weights=normalized_weights)
|
|
1084
|
+
|
|
1085
|
+
merged = []
|
|
1086
|
+
for item in rank_fusion.fuse(find_results):
|
|
1087
|
+
match = RetrievalMatch(
|
|
1088
|
+
paragraph=item.original,
|
|
1089
|
+
weighted_score=item.score,
|
|
1090
|
+
)
|
|
1091
|
+
merged.append(match)
|
|
1092
|
+
return merged
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def calculate_prequeries_for_json_schema(
|
|
1096
|
+
ask_request: AskRequest,
|
|
1097
|
+
) -> PreQueriesStrategy | None:
|
|
1098
|
+
"""
|
|
1099
|
+
This function generates a PreQueriesStrategy with a query for each property in the JSON schema
|
|
1100
|
+
found in ask_request.answer_json_schema.
|
|
1101
|
+
|
|
1102
|
+
This is useful for the use-case where the user is asking for a structured answer on a corpus
|
|
1103
|
+
that is too big to send to the generative model.
|
|
1104
|
+
|
|
1105
|
+
For instance, a JSON schema like this:
|
|
1106
|
+
{
|
|
1107
|
+
"name": "book_ordering",
|
|
1108
|
+
"description": "Structured answer for a book to order",
|
|
1109
|
+
"parameters": {
|
|
1110
|
+
"type": "object",
|
|
1111
|
+
"properties": {
|
|
1112
|
+
"title": {
|
|
1113
|
+
"type": "string",
|
|
1114
|
+
"description": "The title of the book"
|
|
1115
|
+
},
|
|
1116
|
+
"author": {
|
|
1117
|
+
"type": "string",
|
|
1118
|
+
"description": "The author of the book"
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
"required": ["title", "author"]
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
Will generate a PreQueriesStrategy with 2 queries, one for each property in the JSON schema, with equal weights
|
|
1125
|
+
[
|
|
1126
|
+
PreQuery(request=FindRequest(query="The title of the book", ...), weight=1.0),
|
|
1127
|
+
PreQuery(request=FindRequest(query="The author of the book", ...), weight=1.0),
|
|
1128
|
+
]
|
|
1129
|
+
"""
|
|
1130
|
+
prequeries: list[PreQuery] = []
|
|
1131
|
+
json_schema = ask_request.answer_json_schema or {}
|
|
1132
|
+
features = []
|
|
1133
|
+
if ChatOptions.SEMANTIC in ask_request.features:
|
|
1134
|
+
features.append(FindOptions.SEMANTIC)
|
|
1135
|
+
if ChatOptions.KEYWORD in ask_request.features:
|
|
1136
|
+
features.append(FindOptions.KEYWORD)
|
|
1137
|
+
|
|
1138
|
+
properties = json_schema.get("parameters", {}).get("properties", {})
|
|
1139
|
+
if len(properties) == 0: # pragma: no cover
|
|
1140
|
+
return None
|
|
1141
|
+
for prop_name, prop_def in properties.items():
|
|
1142
|
+
query = prop_name
|
|
1143
|
+
if prop_def.get("description"):
|
|
1144
|
+
query += f": {prop_def['description']}"
|
|
1145
|
+
req = FindRequest(
|
|
1146
|
+
query=query,
|
|
1147
|
+
features=features,
|
|
1148
|
+
filters=[],
|
|
1149
|
+
keyword_filters=[],
|
|
1150
|
+
top_k=10,
|
|
1151
|
+
min_score=ask_request.min_score,
|
|
1152
|
+
vectorset=ask_request.vectorset,
|
|
1153
|
+
highlight=False,
|
|
1154
|
+
debug=False,
|
|
1155
|
+
show=[],
|
|
1156
|
+
with_duplicates=False,
|
|
1157
|
+
with_synonyms=False,
|
|
1158
|
+
resource_filters=[], # to be filled with the resource filter
|
|
1159
|
+
rephrase=ask_request.rephrase,
|
|
1160
|
+
rephrase_prompt=parse_rephrase_prompt(ask_request),
|
|
1161
|
+
security=ask_request.security,
|
|
1162
|
+
)
|
|
1163
|
+
prequery = PreQuery(
|
|
1164
|
+
request=req,
|
|
1165
|
+
weight=1.0,
|
|
1166
|
+
)
|
|
1167
|
+
prequeries.append(prequery)
|
|
1168
|
+
try:
|
|
1169
|
+
strategy = PreQueriesStrategy(queries=prequeries)
|
|
1170
|
+
except ValidationError:
|
|
1171
|
+
raise AnswerJsonSchemaTooLong(
|
|
1172
|
+
"Answer JSON schema with too many properties generated too many prequeries"
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
ask_request.rag_strategies = [strategy]
|
|
1176
|
+
return strategy
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def tokens_to_chars(n_tokens: int) -> int:
|
|
1180
|
+
# Multiply by 3 to have a good margin and guess between characters and tokens.
|
|
1181
|
+
# This will be properly cut at the NUA predict API.
|
|
1182
|
+
return n_tokens * 3
|