usearch 1.1.1 → 1.2.0

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 (70) hide show
  1. package/.github/workflows/prerelease.yml +3 -3
  2. package/.github/workflows/release.yml +3 -3
  3. package/.github/workflows/update_version.sh +1 -1
  4. package/.gitmodules +1 -4
  5. package/Cargo.toml +1 -1
  6. package/Package.swift +2 -1
  7. package/README.md +86 -42
  8. package/binding.gyp +16 -12
  9. package/build.gradle +1 -1
  10. package/build.rs +0 -1
  11. package/c/CMakeLists.txt +11 -7
  12. package/c/lib.cpp +36 -24
  13. package/c/usearch.h +42 -12
  14. package/cluster.ipynb +167 -0
  15. package/conanfile.py +1 -1
  16. package/cpp/CMakeLists.txt +8 -3
  17. package/cpp/bench.cpp +2 -2
  18. package/cpp/test.cpp +42 -25
  19. package/docs/benchmarks.md +8 -0
  20. package/docs/compilation.md +7 -7
  21. package/docs/conf.py +27 -24
  22. package/docs/javascript/reference.rst +3 -0
  23. package/golang/README.md +31 -7
  24. package/golang/lib.go +1 -1
  25. package/include/usearch/index.hpp +348 -69
  26. package/include/usearch/index_dense.hpp +587 -179
  27. package/include/usearch/index_plugins.hpp +194 -16
  28. package/java/cloud/unum/usearch/Index.java +13 -0
  29. package/java/cloud/unum/usearch/cloud_unum_usearch_Index.cpp +40 -9
  30. package/java/cloud/unum/usearch/cloud_unum_usearch_Index.h +16 -0
  31. package/javascript/docs.js +111 -0
  32. package/javascript/lib.cpp +29 -9
  33. package/javascript/usearch.d.ts +9 -1
  34. package/objc/USearchObjective.mm +11 -11
  35. package/package.json +2 -2
  36. package/pyproject.toml +1 -1
  37. package/python/lib.cpp +488 -158
  38. package/python/scripts/index_faiss.py +3 -3
  39. package/python/scripts/join.py +6 -6
  40. package/python/scripts/test_index.py +219 -0
  41. package/python/scripts/test_sparse.py +21 -21
  42. package/python/scripts/test_tooling.py +111 -0
  43. package/python/usearch/eval.py +9 -5
  44. package/python/usearch/index.py +491 -216
  45. package/rust/lib.cpp +6 -24
  46. package/rust/lib.hpp +1 -3
  47. package/rust/lib.rs +89 -0
  48. package/setup.py +5 -6
  49. package/wasm/CMakeLists.txt +0 -1
  50. package/wasmer.toml +1 -1
  51. package/python/scripts/test.py +0 -364
  52. package/robin-map/.codecov.yml +0 -5
  53. package/robin-map/.github/workflows/ci.yml +0 -136
  54. package/robin-map/CMakeLists.txt +0 -86
  55. package/robin-map/LICENSE +0 -21
  56. package/robin-map/README.md +0 -483
  57. package/robin-map/cmake/tsl-robin-mapConfig.cmake.in +0 -9
  58. package/robin-map/doxygen.conf +0 -2483
  59. package/robin-map/include/tsl/robin_growth_policy.h +0 -406
  60. package/robin-map/include/tsl/robin_hash.h +0 -1639
  61. package/robin-map/include/tsl/robin_map.h +0 -807
  62. package/robin-map/include/tsl/robin_set.h +0 -660
  63. package/robin-map/tests/CMakeLists.txt +0 -26
  64. package/robin-map/tests/custom_allocator_tests.cpp +0 -138
  65. package/robin-map/tests/main.cpp +0 -26
  66. package/robin-map/tests/policy_tests.cpp +0 -97
  67. package/robin-map/tests/robin_map_tests.cpp +0 -1451
  68. package/robin-map/tests/robin_set_tests.cpp +0 -164
  69. package/robin-map/tests/utils.h +0 -443
  70. package/robin-map/tsl-robin-map.natvis +0 -78
@@ -6,8 +6,17 @@ from __future__ import annotations
6
6
  # into the primary `Index` class, connecting USearch with Numba.
7
7
  import os
8
8
  import math
9
- from typing import Optional, Union, NamedTuple, List, Iterable
10
9
  from dataclasses import dataclass
10
+ from typing import (
11
+ Optional,
12
+ Union,
13
+ NamedTuple,
14
+ List,
15
+ Iterable,
16
+ Tuple,
17
+ Dict,
18
+ Callable,
19
+ )
11
20
 
12
21
  import numpy as np
13
22
  from tqdm import tqdm
@@ -34,8 +43,23 @@ MetricKindBitwise = (
34
43
  MetricKind.Sorensen,
35
44
  )
36
45
 
46
+
47
+ class CompiledMetric(NamedTuple):
48
+ pointer: int
49
+ kind: MetricKind
50
+ signature: MetricSignature
51
+
52
+
37
53
  Key = np.uint64
38
54
 
55
+ KeyOrKeysLike = Union[Key, Iterable[Key], int, Iterable[int], np.ndarray, memoryview]
56
+
57
+ VectorOrVectorsLike = Union[np.ndarray, Iterable[np.ndarray], memoryview]
58
+
59
+ DTypeLike = Union[str, ScalarKind]
60
+
61
+ MetricLike = Union[str, MetricKind, CompiledMetric]
62
+
39
63
 
40
64
  def _normalize_dtype(dtype, metric: MetricKind = MetricKind.Cos) -> ScalarKind:
41
65
  if dtype is None or dtype == "":
@@ -58,19 +82,11 @@ def _normalize_dtype(dtype, metric: MetricKind = MetricKind.Cos) -> ScalarKind:
58
82
  "float32": ScalarKind.F32,
59
83
  "float16": ScalarKind.F16,
60
84
  "int8": ScalarKind.I8,
61
- }
62
- if isinstance(dtype, np.dtype):
63
- dtype = dtype.name
64
- return _normalize[dtype]
65
-
66
-
67
- def _to_numpy_compatible_dtype(dtype: ScalarKind) -> ScalarKind:
68
- _normalize = {
69
- ScalarKind.F64: ScalarKind.F64,
70
- ScalarKind.F32: ScalarKind.F32,
71
- ScalarKind.F16: ScalarKind.F16,
72
- ScalarKind.I8: ScalarKind.F16,
73
- ScalarKind.B1: ScalarKind.B1,
85
+ np.float64: ScalarKind.F64,
86
+ np.float32: ScalarKind.F32,
87
+ np.float16: ScalarKind.F16,
88
+ np.int8: ScalarKind.I8,
89
+ np.uint8: ScalarKind.B1,
74
90
  }
75
91
  return _normalize[dtype]
76
92
 
@@ -80,13 +96,15 @@ def _to_numpy_dtype(dtype: ScalarKind):
80
96
  ScalarKind.F64: np.float64,
81
97
  ScalarKind.F32: np.float32,
82
98
  ScalarKind.F16: np.float16,
83
- ScalarKind.I8: np.float16,
99
+ ScalarKind.I8: np.int8,
84
100
  ScalarKind.B1: np.uint8,
85
101
  }
102
+ if dtype in _normalize.values():
103
+ return dtype
86
104
  return _normalize[dtype]
87
105
 
88
106
 
89
- def _normalize_metric(metric):
107
+ def _normalize_metric(metric) -> MetricKind:
90
108
  if metric is None:
91
109
  return MetricKind.Cos
92
110
 
@@ -111,14 +129,12 @@ def _normalize_metric(metric):
111
129
 
112
130
 
113
131
  def _search_in_compiled(
114
- *,
115
- compiled: Union[_CompiledIndex, _CompiledIndexes],
132
+ compiled_callable: Callable,
116
133
  vectors: np.ndarray,
117
- k: int,
118
- threads: int,
119
- exact: bool,
134
+ *,
120
135
  log: Union[str, bool],
121
136
  batch_size: int,
137
+ **kwargs,
122
138
  ) -> Union[Matches, BatchMatches]:
123
139
  #
124
140
  assert isinstance(vectors, np.ndarray), "Expects a NumPy array"
@@ -150,12 +166,7 @@ def _search_in_compiled(
150
166
  disable=log is False,
151
167
  )
152
168
  for vectors in tasks:
153
- tuple_ = compiled.search(
154
- vectors,
155
- k,
156
- exact=exact,
157
- threads=threads,
158
- )
169
+ tuple_ = compiled_callable(vectors, **kwargs)
159
170
  tasks_matches.append(BatchMatches(*tuple_))
160
171
  pbar.update(vectors.shape[0])
161
172
 
@@ -165,22 +176,19 @@ def _search_in_compiled(
165
176
  keys=np.vstack([m.keys for m in tasks_matches]),
166
177
  distances=np.vstack([m.distances for m in tasks_matches]),
167
178
  counts=np.concatenate([m.counts for m in tasks_matches], axis=None),
179
+ visited_members=sum([m.visited_members for m in tasks_matches]),
180
+ computed_distances=sum([m.computed_distances for m in tasks_matches]),
168
181
  )
169
182
  )
170
183
 
171
184
  else:
172
- tuple_ = compiled.search(
173
- vectors,
174
- k,
175
- exact=exact,
176
- threads=threads,
177
- )
185
+ tuple_ = compiled_callable(vectors, **kwargs)
178
186
  return distil_batch(BatchMatches(*tuple_))
179
187
 
180
188
 
181
189
  def _add_to_compiled(
182
- *,
183
190
  compiled,
191
+ *,
184
192
  keys,
185
193
  vectors,
186
194
  copy: bool,
@@ -231,13 +239,13 @@ def _add_to_compiled(
231
239
  disable=log is False,
232
240
  )
233
241
  for keys, vectors in tasks:
234
- compiled.add(keys, vectors, copy=copy, threads=threads)
242
+ compiled.add_many(keys, vectors, copy=copy, threads=threads)
235
243
  pbar.update(len(keys))
236
244
 
237
245
  pbar.close()
238
246
 
239
247
  else:
240
- compiled.add(keys, vectors, copy=copy, threads=threads)
248
+ compiled.add_many(keys, vectors, copy=copy, threads=threads)
241
249
 
242
250
  return keys
243
251
 
@@ -249,6 +257,9 @@ class Match:
249
257
  key: int
250
258
  distance: float
251
259
 
260
+ def to_tuple(self) -> tuple:
261
+ return self.key, self.distance
262
+
252
263
 
253
264
  @dataclass
254
265
  class Matches:
@@ -258,8 +269,8 @@ class Matches:
258
269
  keys: np.ndarray
259
270
  distances: np.ndarray
260
271
 
261
- visited_members: int
262
- computed_distances: int
272
+ visited_members: int = 0
273
+ computed_distances: int = 0
263
274
 
264
275
  def __len__(self) -> int:
265
276
  return len(self.keys)
@@ -293,8 +304,8 @@ class BatchMatches:
293
304
  distances: np.ndarray
294
305
  counts: np.ndarray
295
306
 
296
- visited_members: int
297
- computed_distances: int
307
+ visited_members: int = 0
308
+ computed_distances: int = 0
298
309
 
299
310
  def __len__(self) -> int:
300
311
  return len(self.counts)
@@ -313,58 +324,140 @@ class BatchMatches:
313
324
  def to_list(self) -> List[List[tuple]]:
314
325
  """Convert the result for each query to the list of tuples with information about its matches."""
315
326
  list_of_matches = [self.__getitem__(row) for row in range(self.__len__())]
316
- return [match.to_list() for matches in list_of_matches for match in matches]
327
+ return [match.to_tuple() for matches in list_of_matches for match in matches]
317
328
 
318
- def mean_recall(self, expected: np.ndarray, k: Optional[int] = None) -> float:
329
+ def mean_recall(self, expected: np.ndarray, count: Optional[int] = None) -> float:
319
330
  """Measures recall [0, 1] as of `Matches` that contain the corresponding
320
331
  `expected` entry anywhere among results."""
321
- return self.count_matches(expected, k=k) / len(expected)
332
+ return self.count_matches(expected, count=count) / len(expected)
322
333
 
323
- def count_matches(self, expected: np.ndarray, k: Optional[int] = None) -> int:
334
+ def count_matches(self, expected: np.ndarray, count: Optional[int] = None) -> int:
324
335
  """Measures recall [0, len(expected)] as of `Matches` that contain the corresponding
325
336
  `expected` entry anywhere among results.
326
337
  """
327
338
  assert len(expected) == len(self)
328
339
  recall = 0
329
- if k is None:
330
- k = self.keys.shape[1]
340
+ if count is None:
341
+ count = self.keys.shape[1]
331
342
 
332
- if k == 1:
343
+ if count == 1:
333
344
  recall = np.sum(self.keys[:, 0] == expected)
334
345
  else:
335
346
  for i in range(len(self)):
336
- recall += expected[i] in self.keys[i, :k]
347
+ recall += expected[i] in self.keys[i, :count]
337
348
  return recall
338
349
 
339
350
  def __repr__(self) -> str:
340
351
  return f"usearch.BatchMatches({np.sum(self.counts)} across {len(self)} queries)"
341
352
 
342
353
 
343
- class CompiledMetric(NamedTuple):
344
- pointer: int
345
- kind: MetricKind
346
- signature: MetricSignature
354
+ @dataclass
355
+ class Clustering:
356
+ def __init__(
357
+ self,
358
+ index: Index,
359
+ matches: BatchMatches,
360
+ queries: Optional[np.ndarray] = None,
361
+ ) -> None:
362
+ if queries is None:
363
+ queries = index._compiled.get_keys_in_slice()
364
+ self.index = index
365
+ self.queries = queries
366
+ self.matches = matches
367
+
368
+ def __repr__(self) -> str:
369
+ return f"usearch.Clustering(for {len(self.queries)} queries)"
370
+
371
+ @property
372
+ def centroids_popularity(self) -> Tuple[np.ndarray, np.ndarray]:
373
+ return np.unique(self.matches.keys, return_counts=True)
374
+
375
+ def members_of(self, centroid: Key) -> np.ndarray:
376
+ return self.queries[self.matches.keys.flatten() == centroid]
377
+
378
+ def subcluster(self, centroid: Key, **clustering_kwards) -> Clustering:
379
+ sub_keys = self.members_of(centroid)
380
+ return self.index.cluster(keys=sub_keys, **clustering_kwards)
381
+
382
+ def plot_centroids_popularity(self):
383
+ from matplotlib import pyplot as plt
384
+
385
+ _, sizes = self.centroids_popularity
386
+ plt.yscale("log")
387
+ plt.plot(sorted(sizes), np.arange(len(sizes)))
388
+ plt.show()
389
+
390
+ @property
391
+ def network(self):
392
+ import networkx as nx
393
+
394
+ keys, sizes = self.centroids_popularity
395
+
396
+ g = nx.Graph()
397
+ for key, size in zip(keys, sizes):
398
+ g.add_node(key, size=size)
399
+
400
+ for i, i_key in enumerate(keys):
401
+ for j_key in keys[:i]:
402
+ d = self.index.pairwise_distance(i_key, j_key)
403
+ g.add_edge(i_key, j_key, distance=d)
404
+
405
+ return g
406
+
407
+
408
+ class IndexedKeys:
409
+ """Smart-reference for the range of keys present in a specific `Index`"""
410
+
411
+ def __init__(self, index: Index) -> None:
412
+ self.index = index
413
+
414
+ def __len__(self) -> int:
415
+ return len(self.index)
416
+
417
+ def __getitem__(
418
+ self,
419
+ offset_offsets_or_slice: Union[int, np.ndarray, slice],
420
+ ) -> Union[Key, np.ndarray]:
421
+ if isinstance(offset_offsets_or_slice, slice):
422
+ start, stop, step = offset_offsets_or_slice.indices(len(self))
423
+ if step:
424
+ raise
425
+ return self.index._compiled.get_keys_in_slice(start, stop - start)
426
+
427
+ elif isinstance(offset_offsets_or_slice, Iterable):
428
+ offsets = np.array(offset_offsets_or_slice)
429
+ return self.index._compiled.get_keys_at_offsets(offsets)
430
+
431
+ else:
432
+ offset = int(offset_offsets_or_slice)
433
+ return self.index._compiled.get_key_at_offset(offset)
434
+
435
+ def __array__(self, dtype=None) -> np.ndarray:
436
+ if dtype is None:
437
+ dtype = Key
438
+ return self.index._compiled.get_keys_in_slice().astype(dtype)
347
439
 
348
440
 
349
441
  class Index:
350
- """Fast JIT-compiled vector-search index for dense equi-dimensional embeddings.
442
+ """Fast vector-search engine for dense equi-dimensional embeddings.
351
443
 
352
444
  Vector keys must be integers.
353
445
  Vectors must have the same number of dimensions within the index.
354
- Supports Inner Product, Cosine Distance, Ln measures
355
- like the Euclidean metric, as well as automatic downcasting
356
- and quantization.
446
+ Supports Inner Product, Cosine Distance, L^n measures like the Euclidean metric,
447
+ as well as automatic downcasting to low-precision floating-point and integral
448
+ representations.
357
449
  """
358
450
 
359
451
  def __init__(
360
452
  self,
361
453
  *,
362
454
  ndim: int = 0,
363
- metric: Union[str, MetricKind, CompiledMetric] = MetricKind.Cos,
364
- dtype: Optional[Union[str, ScalarKind]] = None,
455
+ metric: MetricLike = MetricKind.Cos,
456
+ dtype: Optional[DTypeLike] = None,
365
457
  connectivity: Optional[int] = None,
366
458
  expansion_add: Optional[int] = None,
367
459
  expansion_search: Optional[int] = None,
460
+ multi: bool = False,
368
461
  path: Optional[os.PathLike] = None,
369
462
  view: bool = False,
370
463
  ) -> None:
@@ -377,41 +470,43 @@ class Index:
377
470
  coordinates. Angular (Cos) and Euclidean (L2sq), obviously, apply to
378
471
  vectors with arbitrary number of dimensions.
379
472
 
380
- :param metric: Distance function, defaults to MetricKind.Cos
381
- :type metric: Union[MetricKind, Callable, str], optional
473
+ :param metric: Distance function
474
+ :type metric: MetricLike, defaults to MetricKind.Cos
382
475
  Kind of the distance function, or the Numba `cfunc` JIT-compiled object.
383
476
  Possible `MetricKind` values: IP, Cos, L2sq, Haversine, Pearson,
384
477
  Hamming, Tanimoto, Sorensen.
385
478
 
386
- :param dtype: Scalar type for internal vector storage, defaults to None
387
- :type dtype: Optional[Union[str, ScalarKind]], optional
479
+ :param dtype: Scalar type for internal vector storage
480
+ :type dtype: Optional[DTypeLike], defaults to None
388
481
  For continuous metrics can be: f16, f32, f64, or i8.
389
482
  For bitwise metrics it's implementation-defined, and can't change.
390
483
  Example: you can use the `f16` index with `f32` vectors in Euclidean space,
391
484
  which will be automatically downcasted.
392
485
 
393
- :param connectivity: Connections per node in HNSW, defaults to None
394
- :type connectivity: Optional[int], optional
486
+ :param connectivity: Connections per node in HNSW
487
+ :type connectivity: Optional[int], defaults to None
395
488
  Hyper-parameter for the number of Graph connections
396
489
  per layer of HNSW. The original paper calls it "M".
397
490
  Optional, but can't be changed after construction.
398
491
 
399
- :param expansion_add: Traversal depth on insertions, defaults to None
400
- :type expansion_add: Optional[int], optional
492
+ :param expansion_add: Traversal depth on insertions
493
+ :type expansion_add: Optional[int], defaults to None
401
494
  Hyper-parameter for the search depth when inserting new
402
495
  vectors. The original paper calls it "efConstruction".
403
496
  Can be changed afterwards, as the `.expansion_add`.
404
497
 
405
- :param expansion_search: Traversal depth on queries, defaults to None
406
- :type expansion_search: Optional[int], optional
498
+ :param expansion_search: Traversal depth on queries
499
+ :type expansion_search: Optional[int], defaults to None
407
500
  Hyper-parameter for the search depth when querying
408
501
  nearest neighbors. The original paper calls it "ef".
409
502
  Can be changed afterwards, as the `.expansion_search`.
410
503
 
411
- :param path: Where to store the index, defaults to None
412
- :type path: Optional[os.PathLike], optional
413
- :param view: Are we simply viewing an immutable index, defaults to False
414
- :type view: bool, optional
504
+ :param multi: Allow multiple vectors with the same key
505
+ :type multi: bool, defaults to True
506
+ :param path: Where to store the index
507
+ :type path: Optional[os.PathLike], defaults to None
508
+ :param view: Are we simply viewing an immutable index
509
+ :type view: bool, defaults to False
415
510
  """
416
511
 
417
512
  if connectivity is None:
@@ -449,6 +544,7 @@ class Index:
449
544
  connectivity=connectivity,
450
545
  expansion_add=expansion_add,
451
546
  expansion_search=expansion_search,
547
+ multi=multi,
452
548
  metric_kind=self._metric_kind,
453
549
  metric_pointer=self._metric_pointer,
454
550
  metric_signature=self._metric_signature,
@@ -456,6 +552,7 @@ class Index:
456
552
 
457
553
  self.path = path
458
554
  if path and os.path.exists(path):
555
+ path = os.fspath(path)
459
556
  if view:
460
557
  self._compiled.view(path)
461
558
  else:
@@ -463,6 +560,7 @@ class Index:
463
560
 
464
561
  @staticmethod
465
562
  def metadata(path: os.PathLike) -> Optional[dict]:
563
+ path = os.fspath(path)
466
564
  if not os.path.exists(path):
467
565
  return None
468
566
  try:
@@ -472,6 +570,7 @@ class Index:
472
570
 
473
571
  @staticmethod
474
572
  def restore(path: os.PathLike, view: bool = False) -> Optional[Index]:
573
+ path = os.fspath(path)
475
574
  meta = Index.metadata(path)
476
575
  if not meta:
477
576
  return None
@@ -483,10 +582,13 @@ class Index:
483
582
  view=view,
484
583
  )
485
584
 
585
+ def __len__(self) -> int:
586
+ return self._compiled.__len__()
587
+
486
588
  def add(
487
589
  self,
488
- keys,
489
- vectors,
590
+ keys: KeyOrKeysLike,
591
+ vectors: VectorOrVectorsLike,
490
592
  *,
491
593
  copy: bool = True,
492
594
  threads: int = 0,
@@ -507,23 +609,23 @@ class Index:
507
609
  pass `copy=False`, if you can guarantee the lifetime of the
508
610
  primary vectors store during the process of construction.
509
611
 
510
- :param keys: Unique identifier(s) for passed vectors, optional
511
- :type keys: np.ndarray
612
+ :param keys: Unique identifier(s) for passed vectors
613
+ :type keys: Optional[KeyOrKeysLike], can be `None`
512
614
  :param vectors: Vector or a row-major matrix
513
- :type vectors: np.ndarray
514
- :param copy: Should the index store a copy of vectors, defaults to True
515
- :type copy: bool, optional
516
- :param threads: Optimal number of cores to use, defaults to 0
517
- :type threads: int, optional
518
- :param log: Whether to print the progress bar, default to False
519
- :type log: Union[str, bool], optional
520
- :param batch_size: Number of vectors to process at once, defaults to 0
521
- :type batch_size: int, optional
615
+ :type vectors: VectorOrVectorsLike
616
+ :param copy: Should the index store a copy of vectors
617
+ :type copy: bool, defaults to True
618
+ :param threads: Optimal number of cores to use
619
+ :type threads: int, defaults to 0
620
+ :param log: Whether to print the progress bar
621
+ :type log: Union[str, bool], defaults to False
622
+ :param batch_size: Number of vectors to process at once
623
+ :type batch_size: int, defaults to 0
522
624
  :return: Inserted key or keys
523
625
  :type: Union[int, np.ndarray]
524
626
  """
525
627
  return _add_to_compiled(
526
- compiled=self._compiled,
628
+ self._compiled,
527
629
  keys=keys,
528
630
  vectors=vectors,
529
631
  copy=copy,
@@ -534,8 +636,9 @@ class Index:
534
636
 
535
637
  def search(
536
638
  self,
537
- vectors,
538
- k: int = 10,
639
+ vectors: VectorOrVectorsLike,
640
+ count: int = 10,
641
+ radius: float = math.inf,
539
642
  *,
540
643
  threads: int = 0,
541
644
  exact: bool = False,
@@ -546,38 +649,113 @@ class Index:
546
649
  Performs approximate nearest neighbors search for one or more queries.
547
650
 
548
651
  :param vectors: Query vector or vectors.
549
- :type vectors: np.ndarray
550
- :param k: Upper limit on the number of matches to find, defaults to 10
551
- :type k: int, optional
552
- :param threads: Optimal number of cores to use, defaults to 0
553
- :type threads: int, optional
554
- :param exact: Perform exhaustive linear-time exact search, defaults to False
555
- :type exact: bool, optional
652
+ :type vectors: VectorOrVectorsLike
653
+ :param count: Upper count on the number of matches to find
654
+ :type count: int, defaults to 10
655
+ :param threads: Optimal number of cores to use
656
+ :type threads: int, defaults to 0
657
+ :param exact: Perform exhaustive linear-time exact search
658
+ :type exact: bool, defaults to False
556
659
  :param log: Whether to print the progress bar, default to False
557
660
  :type log: Union[str, bool], optional
558
- :param batch_size: Number of vectors to process at once, defaults to 0
559
- :type batch_size: int, optional
661
+ :param batch_size: Number of vectors to process at once
662
+ :type batch_size: int, defaults to 0
560
663
  :return: Matches for one or more queries
561
664
  :rtype: Union[Matches, BatchMatches]
562
665
  """
563
666
 
564
667
  return _search_in_compiled(
565
- compiled=self._compiled,
566
- vectors=vectors,
567
- k=k,
568
- exact=exact,
569
- threads=threads,
668
+ self._compiled.search_many,
669
+ vectors,
670
+ # Batch scheduling:
570
671
  log=log,
571
672
  batch_size=batch_size,
673
+ # Search constraints:
674
+ count=count,
675
+ exact=exact,
676
+ threads=threads,
572
677
  )
573
678
 
679
+ def contains(self, keys: KeyOrKeysLike) -> Union[bool, np.ndarray]:
680
+ if isinstance(keys, Iterable):
681
+ return self._compiled.contains_many(np.array(keys, dtype=Key))
682
+ else:
683
+ return self._compiled.contains_one(int(keys))
684
+
685
+ def __contains__(self, keys: KeyOrKeysLike) -> Union[bool, np.ndarray]:
686
+ return self.contains(keys)
687
+
688
+ def count(self, keys: KeyOrKeysLike) -> Union[int, np.ndarray]:
689
+ if isinstance(keys, Iterable):
690
+ return self._compiled.count_many(np.array(keys, dtype=Key))
691
+ else:
692
+ return self._compiled.count_one(int(keys))
693
+
694
+ def get(
695
+ self,
696
+ keys: KeyOrKeysLike,
697
+ dtype: Optional[DTypeLike] = None,
698
+ ) -> Union[Optional[np.ndarray], Tuple[Optional[np.ndarray]]]:
699
+ """Looks up one or more keys from the `Index`, retrieving corresponding vectors.
700
+
701
+ Returns `None`, if one key is requested, and its not present.
702
+ Returns a (row) vector, if the key maps into a single vector.
703
+ Returns a (row-major) matrix, if the key maps into a multiple vectors.
704
+ If multiple keys are requested, composes many such responses into a `tuple`.
705
+
706
+ :param keys: One or more keys to lookup
707
+ :type keys: KeyOrKeysLike
708
+ :return: One or more keys lookup results
709
+ :rtype: Union[Optional[np.ndarray], Tuple[Optional[np.ndarray]]]
710
+ """
711
+ if not dtype:
712
+ dtype = self.dtype
713
+ else:
714
+ dtype = _normalize_dtype(dtype)
715
+
716
+ view_dtype = _to_numpy_dtype(dtype)
717
+
718
+ def cast(result):
719
+ if result is not None:
720
+ return result.view(view_dtype)
721
+ return result
722
+
723
+ is_one = not isinstance(keys, Iterable)
724
+ if is_one:
725
+ keys = [keys]
726
+ if not isinstance(keys, np.ndarray):
727
+ keys = np.array(keys, dtype=Key)
728
+ else:
729
+ keys = keys.astype(Key)
730
+
731
+ results = self._compiled.get_many(keys, dtype)
732
+ results = [cast(result) for result in results]
733
+ return results[0] if is_one else results
734
+
735
+ def __getitem__(
736
+ self, keys: KeyOrKeysLike
737
+ ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
738
+ """Looks up one or more keys from the `Index`, retrieving corresponding vectors.
739
+
740
+ Returns `None`, if one key is requested, and its not present.
741
+ Returns a (row) vector, if the key maps into a single vector.
742
+ Returns a (row-major) matrix, if the key maps into a multiple vectors.
743
+ If multiple keys are requested, composes many such responses into a `tuple`.
744
+
745
+ :param keys: One or more keys to lookup
746
+ :type keys: KeyOrKeysLike
747
+ :return: One or more keys lookup results
748
+ :rtype: Union[Optional[np.ndarray], Tuple[Optional[np.ndarray]]]
749
+ """
750
+ return self.get(keys)
751
+
574
752
  def remove(
575
753
  self,
576
- keys: Union[int, Iterable[int]],
754
+ keys: KeyOrKeysLike,
577
755
  *,
578
756
  compact: bool = False,
579
757
  threads: int = 0,
580
- ) -> Union[bool, int]:
758
+ ) -> Union[int, np.ndarray]:
581
759
  """Removes one or move vectors from the index.
582
760
 
583
761
  When working with extremely large indexes, you may want to
@@ -585,57 +763,72 @@ class Index:
585
763
  In other cases, rebuilding - is the recommended approach.
586
764
 
587
765
  :param keys: Unique identifier for passed vectors, optional
588
- :type keys: np.ndarray
766
+ :type keys: KeyOrKeysLike
589
767
  :param compact: Removes links to removed nodes (expensive), defaults to False
590
768
  :type compact: bool, optional
591
769
  :param threads: Optimal number of cores to use, defaults to 0
592
770
  :type threads: int, optional
593
- :return: Number of removed entries
594
- :type: Union[bool, int]
771
+ :return: Array of integers for the number of removed vectors per key
772
+ :type: Union[int, np.ndarray]
595
773
  """
596
- return self._compiled.remove(keys, compact=compact, threads=threads)
597
-
598
- def rename(self, label_from: int, label_to: int) -> bool:
599
- """Relabel existing entry"""
600
- return self._compiled.rename(label_from, label_to)
601
-
602
- @property
603
- def specs(self) -> dict:
604
- return {
605
- "Class": "usearch.Index",
606
- "Connectivity": self.connectivity,
607
- "Size": self.size,
608
- "Dimensions": self.ndim,
609
- "Expansion@Add": self.expansion_add,
610
- "Expansion@Search": self.expansion_search,
611
- "OpenMP": USES_OPENMP,
612
- "SimSIMD": USES_SIMSIMD,
613
- "NativeF16": USES_NATIVE_F16,
614
- "JIT": self.jit,
615
- "DType": self.dtype,
616
- "Path": self.path,
617
- }
774
+ if not isinstance(keys, Iterable):
775
+ return self._compiled.remove_one(keys, compact=compact, threads=threads)
776
+ else:
777
+ keys = np.array(keys, dtype=Key)
778
+ return self._compiled.remove_many(keys, compact=compact, threads=threads)
618
779
 
619
- def __len__(self) -> int:
620
- return self._compiled.__len__()
780
+ def __delitem__(self, keys: KeyOrKeysLike) -> Union[int, np.ndarray]:
781
+ raise self.remove(keys)
621
782
 
622
- def __delitem__(self, key: int) -> bool:
623
- raise self.remove(key)
783
+ def rename(
784
+ self,
785
+ from_: KeyOrKeysLike,
786
+ to: KeyOrKeysLike,
787
+ ) -> Union[int, np.ndarray]:
788
+ """Rename existing member vector or vectors.
789
+
790
+ May be used in iterative clustering procedures, where one would iteratively
791
+ relabel every vector with the name of the cluster an entry belongs to, until
792
+ the system converges.
793
+
794
+ :param from_: One or more keys to be renamed
795
+ :type from_: KeyOrKeysLike
796
+ :param to: New name or names (of identical length as `from_`)
797
+ :type to: KeyOrKeysLike
798
+ :return: Number of vectors that were found and renamed
799
+ :rtype: int
800
+ """
801
+ if isinstance(from_, Iterable):
802
+ from_ = np.array(from_, dtype=Key)
803
+ if isinstance(to, Iterable):
804
+ to = np.array(to, dtype=Key)
805
+ return self._compiled.rename_many_to_many(from_, to)
624
806
 
625
- def __contains__(self, key: int) -> bool:
626
- return self._compiled.__contains__(key)
807
+ else:
808
+ return self._compiled.rename_many_to_one(from_, int(to))
627
809
 
628
- def __getitem__(self, key: int) -> np.ndarray:
629
- dtype = self.dtype
630
- get_dtype = _to_numpy_compatible_dtype(dtype)
631
- vector = self._compiled.__getitem__(key, get_dtype)
632
- view_dtype = _to_numpy_dtype(dtype)
633
- return None if vector is None else vector.view(view_dtype)
810
+ else:
811
+ return self._compiled.rename_one_to_one(int(from_), int(to))
634
812
 
635
813
  @property
636
814
  def jit(self) -> bool:
815
+ """
816
+ :return: True, if the provided `metric` was JIT-ed
817
+ :rtype: bool
818
+ """
637
819
  return self._metric_jit is not None
638
820
 
821
+ @property
822
+ def hardware_acceleration(self) -> str:
823
+ """Describes the kind of hardware-acceleration support used in
824
+ that exact instance of the `Index`, for that metric kind, and
825
+ the given number of dimensions.
826
+
827
+ :return: "auto", if nothing is available, ISA subset name otherwise
828
+ :rtype: str
829
+ """
830
+ return self._compiled.hardware_acceleration
831
+
639
832
  @property
640
833
  def size(self) -> int:
641
834
  return self._compiled.size
@@ -649,7 +842,7 @@ class Index:
649
842
  return self._metric_jit if self._metric_jit else self._metric_kind
650
843
 
651
844
  @metric.setter
652
- def metric(self, metric: Union[str, MetricKind, CompiledMetric]):
845
+ def metric(self, metric: MetricLike):
653
846
  metric = _normalize_metric(metric)
654
847
  if isinstance(metric, MetricKind):
655
848
  metric_kind = metric
@@ -728,6 +921,9 @@ class Index:
728
921
  """Erases all members from index, closing files, and returning RAM to OS."""
729
922
  self._compiled.reset()
730
923
 
924
+ def __del__(self):
925
+ self.reset()
926
+
731
927
  def copy(self) -> Index:
732
928
  result = Index(
733
929
  ndim=self.ndim,
@@ -741,7 +937,12 @@ class Index:
741
937
  result._compiled = self._compiled.copy()
742
938
  return result
743
939
 
744
- def join(self, other: Index, max_proposals: int = 0, exact: bool = False) -> dict:
940
+ def join(
941
+ self,
942
+ other: Index,
943
+ max_proposals: int = 0,
944
+ exact: bool = False,
945
+ ) -> Dict[Key, Key]:
745
946
  """Performs "Semantic Join" or pairwise matching between `self` & `other` index.
746
947
  Is different from `search`, as no collisions are allowed in resulting pairs.
747
948
  Uses the concept of "Stable Marriages" from Combinatorics, famous for the 2012
@@ -754,7 +955,7 @@ class Index:
754
955
  :param exact: Controls if underlying `search` should be exact, defaults to False
755
956
  :type exact: bool, optional
756
957
  :return: Mapping from keys of `self` to keys of `other`
757
- :rtype: dict
958
+ :rtype: Dict[Key, Key]
758
959
  """
759
960
  return self._compiled.join(
760
961
  other=other._compiled,
@@ -762,44 +963,91 @@ class Index:
762
963
  exact=exact,
763
964
  )
764
965
 
765
- def get_keys(self, offset: int = 0, limit: int = 0) -> np.ndarray:
766
- if limit == 0:
767
- limit = 2**63 - 1
768
- return self._compiled.get_keys(offset, limit)
966
+ def cluster(
967
+ self,
968
+ *,
969
+ vectors: Optional[np.ndarray] = None,
970
+ keys: Optional[np.ndarray] = None,
971
+ min_count: Optional[int] = None,
972
+ max_count: Optional[int] = None,
973
+ threads: int = 0,
974
+ log: Union[str, bool] = False,
975
+ batch_size: int = 0,
976
+ ) -> Clustering:
977
+ """
978
+ Clusters already indexed or provided `vectors`, mapping them to various centroids.
769
979
 
770
- @property
771
- def keys(self) -> np.ndarray:
772
- """Retrieves the keys of all vectors present in `self`
980
+ :param vectors: .
981
+ :type vectors: Optional[VectorOrVectorsLike]
982
+ :param count: Upper bound on the number of clusters to produce
983
+ :type count: Optional[int], defaults to None
773
984
 
774
- :return: Array of keys
775
- :rtype: np.ndarray
985
+ :param threads: Optimal number of cores to use,
986
+ :type threads: int, defaults to 0
987
+ :param log: Whether to print the progress bar
988
+ :type log: Union[str, bool], defaults to False
989
+ :param batch_size: Number of vectors to process at once, defaults to 0
990
+ :type batch_size: int, defaults to 0
991
+ :return: Matches for one or more queries
992
+ :rtype: Union[Matches, BatchMatches]
776
993
  """
777
- return self._compiled.keys
994
+ if min_count is None:
995
+ min_count = 0
996
+ if max_count is None:
997
+ max_count = 0
998
+
999
+ if vectors is not None:
1000
+ assert keys is None, "You can either cluster vectors or member keys"
1001
+ results = self._compiled.cluster_vectors(
1002
+ vectors,
1003
+ min_count=min_count,
1004
+ max_count=max_count,
1005
+ threads=threads,
1006
+ )
1007
+ else:
1008
+ if keys is None:
1009
+ keys = self._compiled.get_keys_in_slice()
1010
+ if not isinstance(keys, np.ndarray):
1011
+ keys = np.array(keys)
1012
+ keys = keys.astype(Key)
1013
+ results = self._compiled.cluster_keys(
1014
+ keys,
1015
+ min_count=min_count,
1016
+ max_count=max_count,
1017
+ threads=threads,
1018
+ )
778
1019
 
779
- def get_vectors(
780
- self,
781
- keys: np.ndarray,
782
- dtype: ScalarKind = ScalarKind.F32,
783
- ) -> np.ndarray:
784
- """Retrieves vectors associated with given `keys`
1020
+ batch_matches = BatchMatches(*results)
1021
+ return Clustering(self, batch_matches, keys)
785
1022
 
786
- :return: Matrix of vectors (row-major)
787
- :rtype: np.ndarray
788
- """
789
- dtype = _normalize_dtype(dtype, self._metric_kind)
790
- get_dtype = _to_numpy_compatible_dtype(dtype)
791
- vectors = np.vstack([self._compiled.__getitem__(l, get_dtype) for l in keys])
792
- view_dtype = _to_numpy_dtype(dtype)
793
- return vectors.view(view_dtype)
1023
+ def pairwise_distance(
1024
+ self, left: KeyOrKeysLike, right: KeyOrKeysLike
1025
+ ) -> Union[np.ndarray, float]:
1026
+ assert isinstance(left, Iterable) == isinstance(right, Iterable)
1027
+
1028
+ if not isinstance(left, Iterable):
1029
+ return self._compiled.pairwise_distance(int(left), int(right))
1030
+ else:
1031
+ left = np.array(left).astype(Key)
1032
+ right = np.array(right).astype(Key)
1033
+ return self._compiled.pairwise_distances(left, right)
1034
+
1035
+ @property
1036
+ def keys(self) -> IndexedKeys:
1037
+ return IndexedKeys(self)
794
1038
 
795
1039
  @property
796
1040
  def vectors(self) -> np.ndarray:
797
- return self.get_vectors(self.keys, self.dtype)
1041
+ return self.get(self.keys, vstack=True)
798
1042
 
799
1043
  @property
800
1044
  def max_level(self) -> int:
801
1045
  return self._compiled.max_level
802
1046
 
1047
+ @property
1048
+ def nlevels(self) -> int:
1049
+ return self._compiled.max_level + 1
1050
+
803
1051
  @property
804
1052
  def levels_stats(self) -> _CompiledIndexStats:
805
1053
  """Get the accumulated statistics for the entire multi-level graph.
@@ -829,37 +1077,63 @@ class Index:
829
1077
  """
830
1078
  return self._compiled.level_stats(level)
831
1079
 
1080
+ @property
1081
+ def specs(self) -> Dict[str, Union[str, int, bool]]:
1082
+ return {
1083
+ "Class": "usearch.Index",
1084
+ "Connectivity": self.connectivity,
1085
+ "Size": self.size,
1086
+ "Dimensions": self.ndim,
1087
+ "Expansion@Add": self.expansion_add,
1088
+ "Expansion@Search": self.expansion_search,
1089
+ "OpenMP": USES_OPENMP,
1090
+ "SimSIMD": USES_SIMSIMD,
1091
+ "NativeF16": USES_NATIVE_F16,
1092
+ "JIT": self.jit,
1093
+ "DType": self.dtype,
1094
+ "Path": self.path,
1095
+ }
1096
+
832
1097
  def __repr__(self) -> str:
833
- f = "usearch.Index({} x {}, {}, expansion: {} & {}, {} vectors in {} levels)"
1098
+ f = "usearch.Index({} x {}, {}, connectivity: {}, expansion: {} & {}, {} vectors in {} levels)"
834
1099
  return f.format(
835
1100
  self.dtype,
836
1101
  self.ndim,
837
1102
  self.metric,
1103
+ self.connectivity,
838
1104
  self.expansion_add,
839
1105
  self.expansion_search,
840
1106
  len(self),
841
- self.max_level + 1,
1107
+ self.nlevels,
842
1108
  )
843
1109
 
844
- def _repr_pretty_(self) -> str:
1110
+ def _repr_pretty_(self, printer, cycle) -> str:
845
1111
  level_stats = [
846
- f"--- {i}. {self.level_stats(i).nodes} nodes" for i in range(self.max_level)
1112
+ f"--- {i}. {self.level_stats(i).nodes:,} nodes" for i in range(self.nlevels)
847
1113
  ]
848
- return "\n".join(
1114
+ lines = "\n".join(
849
1115
  [
850
1116
  "usearch.Index",
851
- "- config" f"-- data type: {self.dtype}",
1117
+ "- config",
1118
+ f"-- data type: {self.dtype}",
852
1119
  f"-- dimensions: {self.ndim}",
853
1120
  f"-- metric: {self.metric}",
1121
+ f"-- connectivity: {self.connectivity}",
854
1122
  f"-- expansion on addition:{self.expansion_add} candidates",
855
1123
  f"-- expansion on search: {self.expansion_search} candidates",
1124
+ "- binary",
1125
+ f"-- uses OpenMP: {USES_OPENMP}",
1126
+ f"-- uses SimSIMD: {USES_SIMSIMD}",
1127
+ f"-- supports half-precision: {USES_NATIVE_F16}",
1128
+ f"-- uses hardware acceletion: {self.hardware_acceleration}",
856
1129
  "- state",
857
- f"-- size: {self.size} vectors",
858
- f"-- memory usage: {self.memory_usage} bytes",
1130
+ f"-- size: {self.size:,} vectors",
1131
+ f"-- memory usage: {self.memory_usage:,} bytes",
859
1132
  f"-- max level: {self.max_level}",
860
1133
  *level_stats,
861
1134
  ]
862
1135
  )
1136
+ printer.text(lines)
863
1137
 
864
1138
 
865
1139
  class Indexes:
@@ -872,11 +1146,14 @@ class Indexes:
872
1146
  ) -> None:
873
1147
  self._compiled = _CompiledIndexes()
874
1148
  for index in indexes:
875
- self._compiled.add(index._compiled)
876
- self._compiled.add_paths(paths, view=view, threads=threads)
1149
+ self._compiled.merge(index._compiled)
1150
+ self._compiled.merge_paths(paths, view=view, threads=threads)
1151
+
1152
+ def merge(self, index: Index):
1153
+ self._compiled.merge(index._compiled)
877
1154
 
878
- def add(self, index: Index):
879
- self._compiled.add(index._compiled)
1155
+ def merge_path(self, path: os.PathLike):
1156
+ self._compiled.merge_path(os.fspath(path))
880
1157
 
881
1158
  def __len__(self) -> int:
882
1159
  return self._compiled.__len__()
@@ -884,27 +1161,29 @@ class Indexes:
884
1161
  def search(
885
1162
  self,
886
1163
  vectors,
887
- k: int = 10,
1164
+ count: int = 10,
888
1165
  *,
889
1166
  threads: int = 0,
890
1167
  exact: bool = False,
891
1168
  ):
892
1169
  return _search_in_compiled(
893
- compiled=self._compiled,
894
- vectors=vectors,
895
- k=k,
896
- exact=exact,
897
- threads=threads,
1170
+ self._compiled.search_many,
1171
+ vectors,
1172
+ # Batch scheduling:
898
1173
  log=False,
899
1174
  batch_size=None,
1175
+ # Search constraints:
1176
+ count=count,
1177
+ exact=exact,
1178
+ threads=threads,
900
1179
  )
901
1180
 
902
1181
 
903
1182
  def search(
904
1183
  dataset: np.ndarray,
905
1184
  query: np.ndarray,
906
- k: int = 10,
907
- metric: Union[str, MetricKind, CompiledMetric] = MetricKind.Cos,
1185
+ count: int = 10,
1186
+ metric: MetricLike = MetricKind.Cos,
908
1187
  *,
909
1188
  exact: bool = False,
910
1189
  threads: int = 0,
@@ -919,11 +1198,11 @@ def search(
919
1198
  :param query: Query vector or vectors (also row-major), to find in `dataset`.
920
1199
  :type query: np.ndarray
921
1200
 
922
- :param k: Upper limit on the number of matches to find, defaults to 10
923
- :type k: int, optional
1201
+ :param count: Upper count on the number of matches to find, defaults to 10
1202
+ :type count: int, optional
924
1203
 
925
- :param metric: Distance function, defaults to MetricKind.Cos
926
- :type metric: Union[MetricKind, Callable, str], optional
1204
+ :param metric: Distance function
1205
+ :type metric: MetricLike, defaults to MetricKind.Cos
927
1206
  Kind of the distance function, or the Numba `cfunc` JIT-compiled object.
928
1207
  Possible `MetricKind` values: IP, Cos, L2sq, Haversine, Pearson,
929
1208
  Hamming, Tanimoto, Sorensen.
@@ -957,7 +1236,7 @@ def search(
957
1236
  )
958
1237
  return index.search(
959
1238
  query,
960
- k,
1239
+ count,
961
1240
  threads=threads,
962
1241
  log=log,
963
1242
  batch_size=batch_size,
@@ -975,31 +1254,27 @@ def search(
975
1254
  else:
976
1255
  raise ValueError("The `metric` must be a `CompiledMetric` or a `MetricKind`")
977
1256
 
978
- class WrappedDataset:
979
- def __init__(self) -> None:
980
- pass
981
-
982
- def search(self, query, k, **kwargs):
983
- kwargs.pop("exact")
984
- kwargs.update(
985
- dict(
986
- metric_kind=metric_kind,
987
- metric_pointer=metric_pointer,
988
- metric_signature=metric_signature,
989
- )
990
- )
991
- assert dataset.shape[1] == query.shape[1], "Number of dimensions differs"
992
- if dataset.dtype != query.dtype:
993
- query = query.astype(dataset.dtype)
1257
+ def search_batch(query, **kwargs):
1258
+ assert dataset.shape[1] == query.shape[1], "Number of dimensions differs"
1259
+ if dataset.dtype != query.dtype:
1260
+ query = query.astype(dataset.dtype)
994
1261
 
995
- return _exact_search(dataset, query, k, **kwargs)
1262
+ return _exact_search(
1263
+ dataset,
1264
+ query,
1265
+ metric_kind=metric_kind,
1266
+ metric_pointer=metric_pointer,
1267
+ metric_signature=metric_signature,
1268
+ **kwargs,
1269
+ )
996
1270
 
997
1271
  return _search_in_compiled(
998
- compiled=WrappedDataset(),
999
- vectors=query,
1000
- k=k,
1001
- threads=threads,
1002
- exact=True,
1272
+ search_batch,
1273
+ query,
1274
+ # Batch scheduling:
1003
1275
  log=log,
1004
1276
  batch_size=batch_size,
1277
+ # Search constraints:
1278
+ count=count,
1279
+ threads=threads,
1005
1280
  )