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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. hyperforge_nucliadb_agentic/__init__.py +3 -0
  2. hyperforge_nucliadb_agentic/agent.py +1642 -0
  3. hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
  4. hyperforge_nucliadb_agentic/ask/audit.py +439 -0
  5. hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
  6. hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
  7. hyperforge_nucliadb_agentic/ask/model.py +1299 -0
  8. hyperforge_nucliadb_agentic/ask/predict.py +431 -0
  9. hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
  10. hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
  11. hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
  12. hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
  13. hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
  14. hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
  15. hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
  16. hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
  17. hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
  18. hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
  19. hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
  20. hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
  21. hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
  22. hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
  23. hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
  24. hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
  25. hyperforge_nucliadb_agentic/ask/settings.py +9 -0
  26. hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
  27. hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
  28. hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
  29. hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
  30. hyperforge_nucliadb_agentic/config.py +47 -0
  31. hyperforge_nucliadb_agentic/internal_driver.py +87 -0
  32. hyperforge_nucliadb_agentic/py.typed +0 -0
  33. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
  34. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
  35. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/WHEEL +4 -0
@@ -0,0 +1,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)