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,157 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from enum import Enum, auto
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from nucliadb_models.retrieval import Score, WeightedCombSumScore
|
|
6
|
+
from nucliadb_models.search import SCORE_TYPE
|
|
7
|
+
from nucliadb_telemetry.metrics import Observer
|
|
8
|
+
|
|
9
|
+
from hyperforge_nucliadb_agentic.ask.utils.ids import ParagraphId
|
|
10
|
+
from hyperforge_nucliadb_agentic.ask.utils.text_blocks import (
|
|
11
|
+
ScoredTextBlock,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
rank_fusion_observer = Observer(
|
|
15
|
+
"rank_fusion",
|
|
16
|
+
labels={"type": ""},
|
|
17
|
+
buckets=[
|
|
18
|
+
0.001,
|
|
19
|
+
0.0025,
|
|
20
|
+
0.005,
|
|
21
|
+
0.01,
|
|
22
|
+
0.025,
|
|
23
|
+
0.05,
|
|
24
|
+
0.1,
|
|
25
|
+
0.25,
|
|
26
|
+
0.5,
|
|
27
|
+
1.0,
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ScoredItem = TypeVar("ScoredItem", bound=ScoredTextBlock)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IndexSource(str, Enum):
|
|
35
|
+
KEYWORD = auto()
|
|
36
|
+
SEMANTIC = auto()
|
|
37
|
+
GRAPH = auto()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RankFusionAlgorithm(ABC):
|
|
41
|
+
def __init__(self, window: int):
|
|
42
|
+
self._window = window
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def window(self) -> int:
|
|
46
|
+
"""Phony number used to compute the number of elements to retrieve and
|
|
47
|
+
feed the rank fusion algorithm.
|
|
48
|
+
|
|
49
|
+
This is here for convinience, but a query plan should be the way to go.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
return self._window
|
|
53
|
+
|
|
54
|
+
def fuse(self, sources: dict[str, list[ScoredItem]]) -> list[ScoredItem]:
|
|
55
|
+
"""Fuse elements from multiple sources and return a list of merged
|
|
56
|
+
results.
|
|
57
|
+
|
|
58
|
+
If only one source is provided, rank fusion will be skipped.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
sources_with_results = [x for x in sources.values() if len(x) > 0]
|
|
62
|
+
if len(sources_with_results) == 1:
|
|
63
|
+
# skip rank fusion, we only have a source
|
|
64
|
+
merged = sources_with_results[0]
|
|
65
|
+
else:
|
|
66
|
+
merged = self._fuse(sources)
|
|
67
|
+
|
|
68
|
+
# sort and return the unordered results from the implementation
|
|
69
|
+
merged.sort(key=lambda r: r.score, reverse=True)
|
|
70
|
+
return merged
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def _fuse(self, sources: dict[str, list[ScoredItem]]) -> list[ScoredItem]:
|
|
74
|
+
"""Rank fusion implementation.
|
|
75
|
+
|
|
76
|
+
Each concrete subclass must provide an implementation that merges
|
|
77
|
+
`sources`, a group of unordered matches, into a list of unordered
|
|
78
|
+
results with the new rank fusion score.
|
|
79
|
+
|
|
80
|
+
Results can be deduplicated or changed by the rank fusion algorithm.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class WeightedCombSum(RankFusionAlgorithm):
|
|
87
|
+
"""Score-based rank fusion algorithm. Multiply each score by a list-specific
|
|
88
|
+
weight (boost). Then adds the retrieval score of documents contained in more
|
|
89
|
+
than one list and sort by score.
|
|
90
|
+
|
|
91
|
+
wCombSUM = Σ(r ∈ R) (w(r) · S(r, d))
|
|
92
|
+
|
|
93
|
+
where:
|
|
94
|
+
- d is a document
|
|
95
|
+
- R is the set of retrievers
|
|
96
|
+
- w(r) weight (boost) for retriever r
|
|
97
|
+
- S(r, d) is the score of document d given by retriever r
|
|
98
|
+
|
|
99
|
+
wCombSUM boosts matches from multiple retrievers and deduplicate them. As a
|
|
100
|
+
score ranking algorithm, comparison of different scores may lead to bad
|
|
101
|
+
results.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
window: int,
|
|
109
|
+
weights: dict[str, float] | None = None,
|
|
110
|
+
default_weight: float = 1.0,
|
|
111
|
+
):
|
|
112
|
+
super().__init__(window)
|
|
113
|
+
self._weights = weights or {}
|
|
114
|
+
self._default_weight = default_weight
|
|
115
|
+
|
|
116
|
+
@rank_fusion_observer.wrap({"type": "weighted_comb_sum"})
|
|
117
|
+
def _fuse(self, sources: dict[str, list[ScoredItem]]) -> list[ScoredItem]:
|
|
118
|
+
# accumulated scores per paragraph
|
|
119
|
+
scores: dict[ParagraphId, tuple[float, SCORE_TYPE, list[Score]]] = {}
|
|
120
|
+
# pointers from paragraph to the original source
|
|
121
|
+
match_positions: dict[ParagraphId, list[tuple[int, int]]] = {}
|
|
122
|
+
|
|
123
|
+
rankings = [
|
|
124
|
+
(values, self._weights.get(source, self._default_weight))
|
|
125
|
+
for source, values in sources.items()
|
|
126
|
+
]
|
|
127
|
+
for i, (ranking, weight) in enumerate(rankings):
|
|
128
|
+
for j, item in enumerate(ranking):
|
|
129
|
+
id = item.paragraph_id
|
|
130
|
+
score, score_type, history = scores.setdefault(
|
|
131
|
+
id, (0, item.score_type, [])
|
|
132
|
+
)
|
|
133
|
+
score += item.score * weight
|
|
134
|
+
history.append(item.current_score)
|
|
135
|
+
if {score_type, item.score_type} == {
|
|
136
|
+
SCORE_TYPE.BM25,
|
|
137
|
+
SCORE_TYPE.VECTOR,
|
|
138
|
+
}:
|
|
139
|
+
score_type = SCORE_TYPE.BOTH
|
|
140
|
+
scores[id] = (score, score_type, history)
|
|
141
|
+
|
|
142
|
+
position = (i, j)
|
|
143
|
+
match_positions.setdefault(item.paragraph_id, []).append(position)
|
|
144
|
+
|
|
145
|
+
merged = []
|
|
146
|
+
for paragraph_id, positions in match_positions.items():
|
|
147
|
+
# we are getting only one position, effectively deduplicating
|
|
148
|
+
# multiple matches for the same text block
|
|
149
|
+
i, j = match_positions[paragraph_id][0]
|
|
150
|
+
score, score_type, history = scores[paragraph_id]
|
|
151
|
+
item = rankings[i][0][j]
|
|
152
|
+
history.append(WeightedCombSumScore(score=score))
|
|
153
|
+
item.scores = history
|
|
154
|
+
item.score_type = score_type
|
|
155
|
+
merged.append(item)
|
|
156
|
+
|
|
157
|
+
return merged
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod, abstractproperty # type: ignore
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from nucliadb_models.internal.predict import RerankModel
|
|
5
|
+
from nucliadb_models.search import (
|
|
6
|
+
SCORE_TYPE,
|
|
7
|
+
)
|
|
8
|
+
from nucliadb_telemetry.metrics import Observer
|
|
9
|
+
|
|
10
|
+
from hyperforge_nucliadb_agentic.ask.predict import (
|
|
11
|
+
ProxiedPredictAPIError,
|
|
12
|
+
SendToPredictError,
|
|
13
|
+
get_predict,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
reranker_observer = Observer("reranker", labels={"type": ""})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RerankableItem:
|
|
21
|
+
id: str
|
|
22
|
+
score: float
|
|
23
|
+
score_type: SCORE_TYPE
|
|
24
|
+
content: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RankedItem:
|
|
29
|
+
id: str
|
|
30
|
+
score: float
|
|
31
|
+
score_type: SCORE_TYPE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RerankingOptions:
|
|
36
|
+
kbid: str
|
|
37
|
+
|
|
38
|
+
# Query used to retrieve the results to be reranked. Smart rerankers will use it
|
|
39
|
+
query: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Reranker(ABC):
|
|
43
|
+
@abstractproperty # type: ignore
|
|
44
|
+
def window(self) -> int | None:
|
|
45
|
+
"""Number of elements the reranker requests. `None` means no specific
|
|
46
|
+
window is enforced."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def needs_extra_results(self) -> bool:
|
|
51
|
+
return self.window is not None
|
|
52
|
+
|
|
53
|
+
async def rerank(
|
|
54
|
+
self, items: list[RerankableItem], options: RerankingOptions
|
|
55
|
+
) -> list[RankedItem]:
|
|
56
|
+
"""Given a query and a set of resources, rerank elements and return the
|
|
57
|
+
list of reranked items sorted by decreasing score. The list will contain
|
|
58
|
+
at most, `window` elements.
|
|
59
|
+
|
|
60
|
+
NOTE: Other search engines allow a mix of reranked and not reranked
|
|
61
|
+
results, there's no technical reason we can't do it
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
# Enforce reranker window and drop the rest
|
|
65
|
+
items = items[: self.window]
|
|
66
|
+
if len(items) == 0:
|
|
67
|
+
return []
|
|
68
|
+
reranked = await self._rerank(items, options)
|
|
69
|
+
return reranked
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def _rerank(
|
|
73
|
+
self, items: list[RerankableItem], options: RerankingOptions
|
|
74
|
+
) -> list[RankedItem]: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NoopReranker(Reranker):
|
|
78
|
+
"""No-operation reranker. Given a list of items to rerank, it does nothing
|
|
79
|
+
with them and return the items in the same order. It can be use to not alter
|
|
80
|
+
the previous ordering.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def window(self) -> int | None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@reranker_observer.wrap({"type": "noop"})
|
|
89
|
+
async def _rerank(
|
|
90
|
+
self, items: list[RerankableItem], options: RerankingOptions
|
|
91
|
+
) -> list[RankedItem]:
|
|
92
|
+
return [
|
|
93
|
+
RankedItem(
|
|
94
|
+
id=item.id,
|
|
95
|
+
score=item.score,
|
|
96
|
+
score_type=item.score_type,
|
|
97
|
+
)
|
|
98
|
+
for item in items
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PredictReranker(Reranker):
|
|
103
|
+
"""Rerank using a reranking model.
|
|
104
|
+
|
|
105
|
+
It uses Predict API to rerank elements using a model trained for this
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, window: int):
|
|
110
|
+
self._window = window
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def window(self) -> int:
|
|
114
|
+
return self._window
|
|
115
|
+
|
|
116
|
+
@reranker_observer.wrap({"type": "predict"})
|
|
117
|
+
async def _rerank(
|
|
118
|
+
self, items: list[RerankableItem], options: RerankingOptions
|
|
119
|
+
) -> list[RankedItem]:
|
|
120
|
+
if len(items) == 0:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
predict = get_predict()
|
|
124
|
+
|
|
125
|
+
# Conversion to format expected by predict. At the same time,
|
|
126
|
+
# deduplicates paragraphs found in different indices
|
|
127
|
+
context = {item.id: item.content for item in items}
|
|
128
|
+
request = RerankModel(
|
|
129
|
+
question=options.query,
|
|
130
|
+
user_id="", # TODO
|
|
131
|
+
context=context,
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
response = await predict.rerank(options.kbid, request)
|
|
135
|
+
except (SendToPredictError, ProxiedPredictAPIError, TimeoutError):
|
|
136
|
+
# predict failed, we can't rerank
|
|
137
|
+
reranked = [
|
|
138
|
+
RankedItem(
|
|
139
|
+
id=item.id,
|
|
140
|
+
score=item.score,
|
|
141
|
+
score_type=item.score_type,
|
|
142
|
+
)
|
|
143
|
+
for item in items
|
|
144
|
+
]
|
|
145
|
+
else:
|
|
146
|
+
reranked = [
|
|
147
|
+
RankedItem(
|
|
148
|
+
id=id,
|
|
149
|
+
score=score,
|
|
150
|
+
score_type=SCORE_TYPE.RERANKER,
|
|
151
|
+
)
|
|
152
|
+
for id, score in response.context_scores.items()
|
|
153
|
+
]
|
|
154
|
+
sort_by_score(reranked)
|
|
155
|
+
best = reranked
|
|
156
|
+
return best
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sort_by_score(items: list[RankedItem]):
|
|
160
|
+
"""Sort `items` in place by decreasing score"""
|
|
161
|
+
items.sort(key=lambda item: item.score, reverse=True)
|