usearch 0.20.0 → 0.22.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.
@@ -5,12 +5,13 @@ from __future__ import annotations
5
5
  # Python tooling, linters, and static analyzers. It also embeds JIT
6
6
  # into the primary `Index` class, connecting USearch with Numba.
7
7
  import os
8
+ import math
8
9
  from typing import Optional, Union, NamedTuple, List, Iterable
9
10
 
10
11
  import numpy as np
11
12
  from tqdm import tqdm
12
13
 
13
- from usearch.compiled import Index as _CompiledIndex, IndexMetadata
14
+ from usearch.compiled import Index as _CompiledIndex, IndexMetadata, IndexStats
14
15
  from usearch.compiled import SparseIndex as _CompiledSetsIndex
15
16
 
16
17
  from usearch.compiled import MetricKind, ScalarKind, MetricSignature
@@ -141,6 +142,13 @@ class Matches(NamedTuple):
141
142
  best_matches = self.labels if not self.is_multiple else self.labels[:, 0]
142
143
  return np.sum(best_matches == expected) / len(expected)
143
144
 
145
+ def recall(self, expected: np.ndarray) -> float:
146
+ assert len(expected) == self.batch_size
147
+ recall = 0
148
+ for i in range(self.batch_size):
149
+ recall += expected[i] in self.labels[i]
150
+ return recall / len(expected)
151
+
144
152
  def __repr__(self) -> str:
145
153
  return (
146
154
  f"usearch.Matches({self.total_matches})"
@@ -311,15 +319,18 @@ class Index:
311
319
  pass `copy=False`, if you can guarantee the lifetime of the
312
320
  primary vectors store during the process of construction.
313
321
 
314
- :param labels: Unique identifier for passed vectors, optional
322
+ :param labels: Unique identifier(s) for passed vectors, optional
315
323
  :type labels: Buffer
316
- :param vectors: Collection of vectors.
324
+ :param vectors: Vector or a row-major matrix
317
325
  :type vectors: Buffer
318
326
  :param copy: Should the index store a copy of vectors, defaults to True
319
327
  :type copy: bool, optional
320
328
  :param threads: Optimal number of cores to use, defaults to 0
321
329
  :type threads: int, optional
322
-
330
+ :param log: Whether to print the progress bar, default to False
331
+ :type log: Union[str, bool], optional
332
+ :param batch_size: Number of vectors to process at once, defaults to 0
333
+ :type batch_size: int, optional
323
334
  :return: Inserted label or labels
324
335
  :type: Union[int, np.ndarray]
325
336
  """
@@ -347,6 +358,10 @@ class Index:
347
358
  count_labels = len(labels) if isinstance(labels, Iterable) else 1
348
359
  assert count_labels == count_vectors
349
360
 
361
+ # If logging is requested, and batch size is undefined, set it to grow 1% at a time:
362
+ if log and batch_size == 0:
363
+ batch_size = int(math.ceil(count_vectors / 100))
364
+
350
365
  # Split into batches and log progress, if needed
351
366
  if batch_size:
352
367
  labels = [
@@ -387,27 +402,99 @@ class Index:
387
402
  log: Union[str, bool] = False,
388
403
  batch_size: int = 0,
389
404
  ) -> Matches:
390
- """Performs approximate nearest neighbors search for one or more queries.
405
+ """
406
+ Performs approximate nearest neighbors search for one or more queries.
391
407
 
392
408
  :param vectors: Query vector or vectors.
393
409
  :type vectors: Buffer
394
410
  :param k: Upper limit on the number of matches to find, defaults to 10
395
411
  :type k: int, optional
396
-
397
412
  :param threads: Optimal number of cores to use, defaults to 0
398
413
  :type threads: int, optional
399
414
  :param exact: Perform exhaustive linear-time exact search, defaults to False
400
415
  :type exact: bool, optional
416
+ :param log: Whether to print the progress bar, default to False
417
+ :type log: Union[str, bool], optional
418
+ :param batch_size: Number of vectors to process at once, defaults to 0
419
+ :type batch_size: int, optional
401
420
  :return: Approximate matches for one or more queries
402
421
  :rtype: Matches
403
422
  """
404
- tuple_ = self._compiled.search(
405
- vectors,
406
- k,
407
- exact=exact,
408
- threads=threads,
409
- )
410
- return Matches(*tuple_)
423
+
424
+ assert isinstance(vectors, np.ndarray), "Expects a NumPy array"
425
+ assert vectors.ndim == 1 or vectors.ndim == 2, "Expects a matrix or vector"
426
+ count_vectors = vectors.shape[0] if vectors.ndim == 2 else 1
427
+
428
+ if log and batch_size == 0:
429
+ batch_size = int(math.ceil(count_vectors / 100))
430
+
431
+ if batch_size:
432
+ tasks = [
433
+ vectors[start_row : start_row + batch_size, :]
434
+ for start_row in range(0, count_vectors, batch_size)
435
+ ]
436
+ tasks_matches = []
437
+ name = log if isinstance(log, str) else "Search"
438
+ pbar = tqdm(
439
+ tasks,
440
+ desc=name,
441
+ total=count_vectors,
442
+ unit="Vector",
443
+ disable=log is False,
444
+ )
445
+ for vectors in tasks:
446
+ tuple_ = self._compiled.search(
447
+ vectors,
448
+ k,
449
+ exact=exact,
450
+ threads=threads,
451
+ )
452
+ tasks_matches.append(Matches(*tuple_))
453
+ pbar.update(vectors.shape[0])
454
+
455
+ pbar.close()
456
+ return Matches(
457
+ labels=np.vstack([m.labels for m in tasks_matches]),
458
+ distances=np.vstack([m.distances for m in tasks_matches]),
459
+ counts=np.concatenate([m.counts for m in tasks_matches], axis=None),
460
+ )
461
+
462
+ else:
463
+ tuple_ = self._compiled.search(
464
+ vectors,
465
+ k,
466
+ exact=exact,
467
+ threads=threads,
468
+ )
469
+ return Matches(*tuple_)
470
+
471
+ def remove(
472
+ self,
473
+ labels: Union[int, Iterable[int]],
474
+ *,
475
+ compact: bool = False,
476
+ threads: int = 0,
477
+ ) -> Union[bool, int]:
478
+ """Removes one or move vectors from the index.
479
+
480
+ When working with extremely large indexes, you may want to
481
+ mark some entries deleted, instead of rebuilding a filtered index.
482
+ In other cases, rebuilding - is the recommended approach.
483
+
484
+ :param labels: Unique identifier for passed vectors, optional
485
+ :type labels: Buffer
486
+ :param compact: Removes links to removed nodes (expensive), defaults to False
487
+ :type compact: bool, optional
488
+ :param threads: Optimal number of cores to use, defaults to 0
489
+ :type threads: int, optional
490
+ :return: Number of removed entries
491
+ :type: Union[bool, int]
492
+ """
493
+ return self._compiled.remove(labels, compact=compact, threads=threads)
494
+
495
+ def rename(self, label_from: int, label_to: int) -> bool:
496
+ """Relabel existing entry"""
497
+ return self._compiled.rename(label_from, label_to)
411
498
 
412
499
  @property
413
500
  def specs(self) -> dict:
@@ -429,6 +516,19 @@ class Index:
429
516
  def __len__(self) -> int:
430
517
  return self._compiled.__len__()
431
518
 
519
+ def __delitem__(self, label: int) -> bool:
520
+ raise self.remove(label)
521
+
522
+ def __contains__(self, label: int) -> bool:
523
+ return self._compiled.__contains__(label)
524
+
525
+ def __getitem__(self, label: int) -> np.ndarray:
526
+ dtype = self.dtype
527
+ get_dtype = _to_numpy_compatible_dtype(dtype)
528
+ vector = self._compiled.__getitem__(label, get_dtype)
529
+ view_dtype = _to_numpy_dtype(dtype)
530
+ return None if vector is None else vector.view(view_dtype)
531
+
432
532
  @property
433
533
  def jit(self) -> bool:
434
534
  return self._metric_jit is not None
@@ -442,8 +542,8 @@ class Index:
442
542
  return self._compiled.ndim
443
543
 
444
544
  @property
445
- def metric(self) -> MetricKind:
446
- return self._metric_kind
545
+ def metric(self) -> Union[MetricKind, CompiledMetric]:
546
+ return self._metric_jit if self._metric_jit else self._metric_kind
447
547
 
448
548
  @property
449
549
  def dtype(self) -> ScalarKind:
@@ -501,13 +601,64 @@ class Index:
501
601
  def close(self):
502
602
  self._compiled.close()
503
603
 
604
+ def copy(self) -> Index:
605
+ result = Index(
606
+ ndim=self.ndim,
607
+ metric=self.metric,
608
+ dtype=self.dtype,
609
+ connectivity=self.connectivity,
610
+ expansion_add=self.expansion_add,
611
+ expansion_search=self.expansion_search,
612
+ path=self.path,
613
+ )
614
+ result._compiled = self._compiled.copy()
615
+ return result
616
+
617
+ def join(self, other: Index, max_proposals: int = 0, exact: bool = False) -> dict:
618
+ """Performs "Semantic Join" or pairwise matching between `self` & `other` index.
619
+ Is different from `search`, as no collisions are allowed in resulting pairs.
620
+ Uses the concept of "Stable Marriages" from Combinatorics, famous for the 2012
621
+ Nobel Prize in Economics.
622
+
623
+ :param other: Another index.
624
+ :type other: Index
625
+ :param max_proposals: Limit on candidates evaluated per vector, defaults to 0
626
+ :type max_proposals: int, optional
627
+ :param exact: Controls if underlying `search` should be exact, defaults to False
628
+ :type exact: bool, optional
629
+ :return: Mapping from labels of `self` to labels of `other`
630
+ :rtype: dict
631
+ """
632
+ return self._compiled.join(
633
+ other=other._compiled,
634
+ max_proposals=max_proposals,
635
+ exact=exact,
636
+ )
637
+
638
+ def get_labels(self, offset: int = 0, limit: int = 0) -> np.ndarray:
639
+ if limit == 0:
640
+ limit = 2**63 - 1
641
+ return self._compiled.get_labels(offset, limit)
642
+
504
643
  @property
505
644
  def labels(self) -> np.ndarray:
645
+ """Retrieves the labels of all vectors present in `self`
646
+
647
+ :return: Array of labels
648
+ :rtype: np.ndarray
649
+ """
506
650
  return self._compiled.labels
507
651
 
508
652
  def get_vectors(
509
- self, labels: np.ndarray, dtype: ScalarKind = ScalarKind.F32
653
+ self,
654
+ labels: np.ndarray,
655
+ dtype: ScalarKind = ScalarKind.F32,
510
656
  ) -> np.ndarray:
657
+ """Retrieves vectors associated with given `labels`
658
+
659
+ :return: Matrix of vectors (row-major)
660
+ :rtype: np.ndarray
661
+ """
511
662
  dtype = _normalize_dtype(dtype, self._metric_kind)
512
663
  get_dtype = _to_numpy_compatible_dtype(dtype)
513
664
  vectors = np.vstack([self._compiled.__getitem__(l, get_dtype) for l in labels])
@@ -518,15 +669,45 @@ class Index:
518
669
  def vectors(self) -> np.ndarray:
519
670
  return self.get_vectors(self.labels, self.dtype)
520
671
 
521
- def __delitem__(self, label: int):
522
- raise NotImplementedError()
672
+ @property
673
+ def max_level(self) -> int:
674
+ return self._compiled.max_level
523
675
 
524
- def __contains__(self, label: int) -> bool:
525
- return self._compiled.__contains__(label)
676
+ @property
677
+ def levels_stats(self) -> IndexStats:
678
+ return self._compiled.levels_stats
526
679
 
527
- def __getitem__(self, label: int) -> np.ndarray:
528
- dtype = self.dtype
529
- get_dtype = _to_numpy_compatible_dtype(dtype)
530
- vector = self._compiled.__getitem__(label, get_dtype)
531
- view_dtype = _to_numpy_dtype(dtype)
532
- return None if vector is None else vector.view(view_dtype)
680
+ def level_stats(self, level: int) -> IndexStats:
681
+ return self._compiled.level_stats(level)
682
+
683
+ def __repr__(self) -> str:
684
+ f = "usearch.Index({} x {}, {}, expansion: {} & {}, {} vectors in {} levels)"
685
+ return f.format(
686
+ self.dtype,
687
+ self.ndim,
688
+ self.metric,
689
+ self.expansion_add,
690
+ self.expansion_search,
691
+ len(self),
692
+ self.max_level + 1,
693
+ )
694
+
695
+ def _repr_pretty_(self) -> str:
696
+ level_stats = [
697
+ f"--- {i}. {self.level_stats(i).nodes} nodes" for i in range(self.max_level)
698
+ ]
699
+ return "\n".join(
700
+ [
701
+ "usearch.Index",
702
+ "- config" f"-- data type: {self.dtype}",
703
+ f"-- dimensions: {self.ndim}",
704
+ f"-- metric: {self.metric}",
705
+ f"-- expansion on addition:{self.expansion_add} candidates",
706
+ f"-- expansion on search: {self.expansion_search} candidates",
707
+ "- state",
708
+ f"-- size: {self.size} vectors",
709
+ f"-- memory usage: {self.memory_usage} bytes",
710
+ f"-- max level: {self.max_level}",
711
+ *level_stats,
712
+ ]
713
+ )
package/rust/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # USearch for Rust
2
+
3
+ ## Installation
4
+
5
+ ```sh
6
+ cargo add usearch
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ ```rust
12
+
13
+ let options = IndexOptions {
14
+ dimensions: 5,
15
+ metric: MetricKind::IP,
16
+ quantization: ScalarKind::F16,
17
+ connectivity: 0,
18
+ expansion_add: 0,
19
+ expansion_search: 0
20
+ };
21
+
22
+ let index = new_index(&options).unwrap();
23
+
24
+ assert!(index.reserve(10).is_ok());
25
+ assert!(index.capacity() >= 10);
26
+ assert!(index.connectivity() != 0);
27
+ assert_eq!(index.dimensions(), 3);
28
+ assert_eq!(index.size(), 0);
29
+
30
+ let first: [f32; 3] = [0.2, 0.1, 0.2];
31
+ let second: [f32; 3] = [0.2, 0.1, 0.2];
32
+
33
+ assert!(index.add(42, &first).is_ok());
34
+ assert!(index.add(43, &second).is_ok());
35
+ assert_eq!(index.size(), 2);
36
+
37
+ // Read back the tags
38
+ let results = index.search(&first, 10).unwrap();
39
+ assert_eq!(results.count, 2);
40
+ ```
41
+
42
+ ## Multi-Threading
43
+
44
+ ```rust
45
+ assert!(index.add_in_thread(42, &first, 0).is_ok());
46
+ assert!(index.add_in_thread(43, &second, 0).is_ok());
47
+ let results = index.search_in_thread(&first, 10, 0).unwrap();
48
+ ```
49
+
50
+ Being a systems-programming language, Rust has better control over memory management and concurrency but lacks function overloading.
51
+ Aside from the `add` and `search`, USearch Rust binding also provides `add_in_thread` and `search_in_thread`, which let users identify the calling thread to use underlying temporary memory more efficiently.
52
+
53
+ ## Serialization
54
+
55
+ ```rust
56
+ assert!(index.save("index.usearch").is_ok());
57
+ assert!(index.load("index.usearch").is_ok());
58
+ assert!(index.view("index.usearch").is_ok());
59
+ ```
60
+
61
+ ## Metrics
62
+
63
+ ```rust
64
+ assert!(new_l2sq(3, &quant, 0, 0, 0).is_ok());
65
+ assert!(new_cos(3, &quant, 0, 0, 0).is_ok());
66
+ assert!(new_haversine(&quant, 0, 0, 0).is_ok());
67
+ ```
@@ -0,0 +1,20 @@
1
+ # USearch for Swift
2
+
3
+ ## Installation
4
+
5
+ ```txt
6
+ https://github.com/unum-cloud/usearch
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ ```swift
12
+ let index = Index.l2sq(dimensions: 3, connectivity: 8)
13
+ let vectorA: [Float32] = [0.3, 0.5, 1.2]
14
+ let vectorB: [Float32] = [0.4, 0.2, 1.2]
15
+ index.add(label: 42, vector: vectorA[...])
16
+ index.add(label: 43, vector: vectorB[...])
17
+
18
+ let results = index.search(vector: vectorA[...], count: 10)
19
+ assert(results.0[0] == 42)
20
+ ```
@@ -0,0 +1,15 @@
1
+ include(emscripten)
2
+
3
+ set(CMAKE_C_COMPILER emcc)
4
+ set(CMAKE_CXX_COMPILER em++)
5
+
6
+ set(USEARCH_PUNNED_INCLUDE_DIRS
7
+ "${CMAKE_CURRENT_SOURCE_DIR}/../c"
8
+ "${CMAKE_CURRENT_SOURCE_DIR}/../include"
9
+ "${CMAKE_CURRENT_SOURCE_DIR}/../fp16/include"
10
+ "${CMAKE_CURRENT_SOURCE_DIR}/../robin-map/include"
11
+ )
12
+
13
+ add_executable(index lib.cpp)
14
+ target_include_directories(index PRIVATE ${USEARCH_PUNNED_INCLUDE_DIRS})
15
+ set_target_properties(index PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/wasm)
package/wasm/lib.cpp ADDED
@@ -0,0 +1,5 @@
1
+ #define USEARCH_EXPORT EMSCRIPTEN_KEEPALIVE
2
+
3
+ #include <emscripten/emscripten.h>
4
+
5
+ #include <lib.cpp>
package/wasmer.toml ADDED
@@ -0,0 +1,8 @@
1
+ [package]
2
+ name="unum/usearch"
3
+ version="0.19.3"
4
+ description="Smaller & Faster Single-File Vector Search Engine from Unum"
5
+
6
+ [[module]]
7
+ name="index"
8
+ source="build/wasm/index.wasm"
@@ -1,98 +0,0 @@
1
- from collections import deque
2
-
3
- import numpy as np
4
- from numba import njit
5
-
6
- from index import Index, Matches, Label
7
-
8
-
9
- class bidict(dict):
10
- def __init__(self, *args, **kwargs):
11
- super(bidict, self).__init__(*args, **kwargs)
12
- self.inverse = {}
13
- for key, value in self.items():
14
- self.inverse.setdefault(value, []).append(key)
15
-
16
- def __setitem__(self, key, value):
17
- if key in self:
18
- self.inverse[self[key]].remove(key)
19
- super(bidict, self).__setitem__(key, value)
20
- self.inverse.setdefault(value, []).append(key)
21
-
22
- def __delitem__(self, key):
23
- self.inverse.setdefault(self[key], []).remove(key)
24
- if self[key] in self.inverse and not self.inverse[self[key]]:
25
- del self.inverse[self[key]]
26
- super(bidict, self).__delitem__(key)
27
-
28
-
29
- @njit
30
- def index_in_array(array: np.ndarray, item: Label) -> int:
31
- for idx, val in np.ndenumerate(array):
32
- if val == item:
33
- return idx
34
-
35
- return len(array)
36
-
37
-
38
- def semantic_join(a: Index, b: Index, resolution: int = 10) -> bidict:
39
- """Performs a Semantic Join, matching one entry from `a` with
40
- one entry from `b`, converging towards a Stable Marriage.
41
- Assuming the collections can be different in size, classical solution
42
- doesn't provide
43
-
44
- :param resolution: Approximate matches per member to consider, defaults to 10
45
- :type resolution: int, optional
46
- :return: Bidirectional mapping from men to women and `.inverse`
47
- :rtype: bidict
48
- """
49
-
50
- men, women = (a, b) if len(a) > len(b) else (b, a)
51
- matches = bidict()
52
-
53
- men_vectors = np.vstack(men[i] for i in range(len(men)))
54
- women_vectors = np.vstack(women[i] for i in range(len(women)))
55
-
56
- man_count_proposed = np.zeros(len(men))
57
- man_to_women_preferences: Matches = men.search(women_vectors, resolution)
58
- woman_to_men_preferences: Matches = women.search(men_vectors, resolution)
59
-
60
- # A nice optimization is to prioritize free men by the quality of their
61
- # best remaining variant. TODO: Replace with `heapq`
62
- free_men = deque(range(len(men)))
63
-
64
- idle_cycles: int = 0
65
- hopeless_men_count = len(men) - len(women)
66
- while len(free_men) > hopeless_men_count:
67
- # In the worst case scenario we may need to match more candidates for every
68
- # remaining man. This, however, may drastically increase the runtime.
69
- if len(free_men) == idle_cycles:
70
- break
71
-
72
- man: Label = free_men.popleft()
73
- count_proposals = man_count_proposed[man]
74
- if count_proposals == man_to_women_preferences.counts[man]:
75
- free_men.append(man)
76
- idle_cycles += 1
77
- continue
78
-
79
- woman: Label = man_to_women_preferences.labels[man, count_proposals]
80
- man_count_proposed[man] += 1
81
-
82
- if woman not in matches.inverse:
83
- matches[man] = woman
84
-
85
- else:
86
- her_preferences = woman_to_men_preferences.labels[woman, :]
87
- husband: Label = matches.inverse[woman]
88
- husband_rank = index_in_array(her_preferences, husband)
89
- challenger_rank = index_in_array(her_preferences, man)
90
- if challenger_rank < husband_rank:
91
- del matches[husband]
92
- free_men.append(husband)
93
- matches[man] = woman
94
-
95
- else:
96
- free_men.append(man)
97
-
98
- return matches