forked from aurelio-labs/semantic-router
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlocal.py
More file actions
334 lines (297 loc) · 12.9 KB
/
Copy pathlocal.py
File metadata and controls
334 lines (297 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
from typing import Any, ClassVar, Dict, List, Optional, Tuple
import numpy as np
from pydantic import ConfigDict, Field
from semantic_router.index.base import BaseIndex, IndexConfig
from semantic_router.linear import similarity_matrix, top_scores
from semantic_router.schema import ConfigParameter, SparseEmbedding, Utterance
from semantic_router.utils.logger import logger
class LocalIndex(BaseIndex):
type: str = "local"
metadata: Optional[np.ndarray] = Field(default=None, exclude=True)
def __init__(self, **data):
super().__init__(**data)
if self.metadata is None:
self.metadata = None
# Stop pydantic from complaining about Optional[np.ndarray]type hints.
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True)
def add(
self,
embeddings: List[List[float]],
routes: List[str],
utterances: List[str],
function_schemas: Optional[List[Dict[str, Any]]] = None,
metadata_list: List[Dict[str, Any]] = [],
**kwargs,
):
"""Add embeddings to the index.
:param embeddings: List of embeddings to add to the index.
:type embeddings: List[List[float]]
:param routes: List of routes to add to the index.
:type routes: List[str]
:param utterances: List of utterances to add to the index.
:type utterances: List[str]
:param function_schemas: List of function schemas to add to the index.
:type function_schemas: Optional[List[Dict[str, Any]]]
:param metadata_list: List of metadata to add to the index.
:type metadata_list: List[Dict[str, Any]]
"""
embeds = np.array(embeddings) # type: ignore
routes_arr = np.array(routes)
if isinstance(utterances[0], str):
utterances_arr = np.array(utterances)
else:
utterances_arr = np.array(utterances, dtype=object)
if self.index is None:
self.index = embeds # type: ignore
self.routes = routes_arr
self.utterances = utterances_arr
self.metadata = (
np.array(metadata_list, dtype=object)
if metadata_list
else np.array([{} for _ in utterances], dtype=object)
)
else:
self.index = np.concatenate([self.index, embeds])
self.routes = np.concatenate([self.routes, routes_arr])
self.utterances = np.concatenate([self.utterances, utterances_arr])
if self.metadata is not None:
self.metadata = np.concatenate(
[
self.metadata,
np.array(metadata_list, dtype=object)
if metadata_list
else np.array([{} for _ in utterances], dtype=object),
]
)
else:
self.metadata = (
np.array(metadata_list, dtype=object)
if metadata_list
else np.array([{} for _ in utterances], dtype=object)
)
def _remove_and_sync(self, routes_to_delete: dict) -> np.ndarray:
"""Remove and sync the index.
:param routes_to_delete: Dictionary of routes to delete.
:type routes_to_delete: dict
:return: A numpy array of the removed route utterances.
:rtype: np.ndarray
"""
if self.index is None or self.routes is None or self.utterances is None:
raise ValueError("Index, routes, or utterances are not populated.")
# TODO JB: implement routes and utterances as a numpy array
route_utterances = np.array([self.routes, self.utterances]).T
# initialize our mask with all true values (ie keep all)
mask = np.ones(len(route_utterances), dtype=bool)
for route, utterances in routes_to_delete.items():
# TODO JB: we should be able to vectorize this?
for utterance in utterances:
mask &= ~(
(route_utterances[:, 0] == route)
& (route_utterances[:, 1] == utterance)
)
# apply the mask to index, routes, and utterances
self.index = self.index[mask]
self.routes = self.routes[mask]
self.utterances = self.utterances[mask]
if self.metadata is not None:
self.metadata = self.metadata[mask]
# return what was removed
return route_utterances[~mask]
def get_utterances(self, include_metadata: bool = False) -> List[Utterance]:
"""Gets a list of route and utterance objects currently stored in the index.
:param include_metadata: Whether to include function schemas and metadata in
the returned Utterance objects - LocalIndex now includes metadata if present.
:return: A list of Utterance objects.
:rtype: List[Utterance]
"""
if self.routes is None or self.utterances is None:
return []
if include_metadata and self.metadata is not None:
return [
Utterance(
route=route,
utterance=utterance,
function_schemas=None,
metadata=metadata,
)
for route, utterance, metadata in zip(
self.routes, self.utterances, self.metadata
)
]
else:
return [Utterance.from_tuple(x) for x in zip(self.routes, self.utterances)]
def describe(self) -> IndexConfig:
"""Describe the index.
:return: An IndexConfig object.
:rtype: IndexConfig
"""
return IndexConfig(
type=self.type,
dimensions=self.index.shape[1] if self.index is not None else 0,
vectors=self.index.shape[0] if self.index is not None else 0,
)
def is_ready(self) -> bool:
"""Checks if the index is ready to be used.
:return: True if the index is ready, False otherwise.
:rtype: bool
"""
return self.index is not None and self.routes is not None
async def ais_ready(self) -> bool:
"""Checks if the index is ready to be used asynchronously.
:return: True if the index is ready, False otherwise.
:rtype: bool
"""
return self.index is not None and self.routes is not None
def query(
self,
vector: np.ndarray,
top_k: int = 5,
route_filter: Optional[List[str]] = None,
sparse_vector: dict[int, float] | SparseEmbedding | None = None,
) -> Tuple[np.ndarray, List[str]]:
"""Search the index for the query and return top_k results.
:param vector: The vector to search for.
:type vector: np.ndarray
:param top_k: The number of results to return.
:type top_k: int
:param route_filter: The routes to filter the search by.
:type route_filter: Optional[List[str]]
:param sparse_vector: The sparse vector to search for.
:type sparse_vector: dict[int, float] | SparseEmbedding | None
:return: A tuple containing the query vector and a list of route names.
:rtype: Tuple[np.ndarray, List[str]]
"""
if self.index is None or self.routes is None:
raise ValueError("Index or routes are not populated.")
if route_filter is not None:
filtered_index = []
filtered_routes = []
for route, vec in zip(self.routes, self.index):
if route in route_filter:
filtered_index.append(vec)
filtered_routes.append(route)
if not filtered_routes:
raise ValueError("No routes found matching the filter criteria.")
sim = similarity_matrix(vector, np.array(filtered_index))
scores, idx = top_scores(sim, top_k)
route_names = [filtered_routes[i] for i in idx]
else:
sim = similarity_matrix(vector, self.index)
scores, idx = top_scores(sim, top_k)
route_names = [self.routes[i] for i in idx]
return scores, route_names
async def aquery(
self,
vector: np.ndarray,
top_k: int = 5,
route_filter: Optional[List[str]] = None,
sparse_vector: dict[int, float] | SparseEmbedding | None = None,
) -> Tuple[np.ndarray, List[str]]:
"""Search the index for the query and return top_k results.
:param vector: The vector to search for.
:type vector: np.ndarray
:param top_k: The number of results to return.
:type top_k: int
:param route_filter: The routes to filter the search by.
:type route_filter: Optional[List[str]]
:param sparse_vector: The sparse vector to search for.
:type sparse_vector: dict[int, float] | SparseEmbedding | None
:return: A tuple containing the query vector and a list of route names.
:rtype: Tuple[np.ndarray, List[str]]
"""
if self.index is None or self.routes is None:
raise ValueError("Index or routes are not populated.")
if route_filter is not None:
filtered_index = []
filtered_routes = []
for route, vec in zip(self.routes, self.index):
if route in route_filter:
filtered_index.append(vec)
filtered_routes.append(route)
if not filtered_routes:
raise ValueError("No routes found matching the filter criteria.")
sim = similarity_matrix(vector, np.array(filtered_index))
scores, idx = top_scores(sim, top_k)
route_names = [filtered_routes[i] for i in idx]
else:
sim = similarity_matrix(vector, self.index)
scores, idx = top_scores(sim, top_k)
route_names = [self.routes[i] for i in idx]
return scores, route_names
async def aget_routes(self):
"""Get all routes from the index.
:return: A list of routes.
:rtype: List[str]
"""
logger.error("Async get routes is not implemented for LocalIndex.")
def _write_config(self, config: ConfigParameter):
"""Write the config to the index.
:param config: The config to write to the index.
:type config: ConfigParameter
"""
logger.warning("No config is written for LocalIndex.")
def delete(self, route_name: str):
"""Delete all records of a specific route from the index.
:param route_name: The name of the route to delete.
:type route_name: str
"""
if (
self.index is not None
and self.routes is not None
and self.utterances is not None
):
delete_idx = self._get_indices_for_route(route_name=route_name)
self.index = np.delete(self.index, delete_idx, axis=0)
self.routes = np.delete(self.routes, delete_idx, axis=0)
self.utterances = np.delete(self.utterances, delete_idx, axis=0)
if self.metadata is not None:
self.metadata = np.delete(self.metadata, delete_idx, axis=0)
else:
raise ValueError(
"Attempted to delete route records but either index, routes or "
"utterances is None."
)
async def adelete(self, route_name: str):
"""Delete all records of a specific route from the index. Note that this just points
to the sync delete method as async makes no difference for the local computations
of the LocalIndex.
:param route_name: The name of the route to delete.
:type route_name: str
"""
self.delete(route_name)
def delete_index(self):
"""Deletes the index, effectively clearing it and setting it to None.
:return: None
:rtype: None
"""
self.index = None
self.routes = None
self.utterances = None
self.metadata = None
async def adelete_index(self):
"""Deletes the index, effectively clearing it and setting it to None. Note that this just points
to the sync delete_index method as async makes no difference for the local computations
of the LocalIndex.
:return: None
:rtype: None
"""
self.index = None
self.routes = None
self.utterances = None
self.metadata = None
def _get_indices_for_route(self, route_name: str):
"""Gets an array of indices for a specific route.
:param route_name: The name of the route to get indices for.
:type route_name: str
:return: An array of indices for the route.
:rtype: np.ndarray
"""
if self.routes is None:
raise ValueError("Routes are not populated.")
idx = [i for i, route in enumerate(self.routes) if route == route_name]
return idx
def __len__(self):
if self.index is not None:
return self.index.shape[0]
else:
return 0