usearch 0.12.1 → 0.13.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.
@@ -28,7 +28,7 @@ jobs:
28
28
  - run: npm install --save-dev @semantic-release/exec @semantic-release/git conventional-changelog-eslint semantic-release && npx semantic-release
29
29
 
30
30
  rebase:
31
- name: Rebase from main into main-dev
31
+ name: Rebase Developmtn Branch
32
32
  needs: versioning
33
33
  runs-on: ubuntu-latest
34
34
  steps:
@@ -37,14 +37,14 @@ jobs:
37
37
  with:
38
38
  fetch-depth: 0
39
39
 
40
- - name: Perform Rebase
40
+ - name: Perform rebase
41
41
  run: |
42
42
  git checkout main-dev
43
43
  git rebase main
44
44
  - name: Push changes
45
45
  uses: ad-m/github-push-action@master
46
46
  with:
47
- github_token: ${{ secrets.GITHUB_TOKEN }}
47
+ github_token: ${{ secrets.SEMANTIC_REBASE_TOKEN }}
48
48
  branch: main-dev
49
49
 
50
50
  build_wheels:
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "usearch"
3
- version = "0.12.1"
3
+ version = "0.13.0"
4
4
  authors = ["Ashot Vardanian <1983160+ashvardanian@users.noreply.github.com>"]
5
5
  description = "Smaller & Faster Single-File Vector Search Engine from Unum"
6
6
  edition = "2021"
package/README.md CHANGED
@@ -30,22 +30,25 @@ Euclidean • Angular • Jaccard • Hamming • Haversine • User-Defined Met
30
30
  <a href="#golang">GoLang</a> •
31
31
  <a href="#wolfram">Wolfram</a>
32
32
  <br/>
33
- Linux • MacOS • Windows
33
+ Linux • MacOS • Windows • Docker • WebAssembly 🔜
34
34
  </p>
35
35
 
36
36
  ---
37
37
 
38
38
  - [x] Industry-leading [performance](#performance).
39
39
  - [x] Easily-extendible [single C++11 header][usearch-header] implementation.
40
- - [x] [User-defined](#define-custom-metrics) and pre-packaged SIMD-accelerated metrics.
40
+ - [x] SIMD-accelerated and [User-defined](#define-custom-metrics) metrics with JIT-compilation.
41
+ - [x] Variable dimensionality vectors - for [obscure use-cases, like GIS and Chess][obscure-use-cases].
42
+ - [x] Bitwise Tanimoto and Sorensen coefficients for Genomics and Chemistry.
41
43
  - [x] [Half-precision `f16` and Quarter-precision `f8`](#quantize-on-the-fly) support on any hardware.
42
44
  - [x] [View from disk](#view-larger-indexes-from-disk), without loading into RAM.
43
45
  - [x] [4B+](#go-beyond-4b-entries) sized space efficient point-clouds with `uint40_t`.
44
- - [x] Variable dimensionality vectors - for [obscure use-cases][obscure-use-cases].
45
- - [x] [Bring your threads](#bring-your-threads), like OpenMP.
46
+ - [x] [Bring your threads](#bring-your-threads), like OpenMP or C++23 executors.
46
47
  - [x] Multiple vectors per label.
47
48
  - [ ] Thread-safe `reserve`.
48
- - [x] AI + Vector Search = [Semantic Search](#ai--vector-search--semantic-search).
49
+ - [ ] On-the-fly deletions.
50
+ - [x] USearch + UForm Transformers = [Semantic Search](#ai--vector-search--semantic-search).
51
+ - [x] USearch + RDKit = [Molecular Search](#ai--vector-search--semantic-search).
49
52
 
50
53
  [usearch-header]: https://github.com/unum-cloud/usearch/blob/main/include/usearch/usearch.hpp
51
54
  [obscure-use-cases]: https://ashvardanian.com/posts/abusing-vector-search
@@ -500,25 +503,27 @@ func main() {
500
503
  - Rust: Allow passing a custom thread ID.
501
504
  - C# .NET bindings.
502
505
 
503
- ## AI + Vector Search = Semantic Search
506
+ ## Application Examples
507
+
508
+ ### USearch + AI = Multi-Modal Semantic Search
504
509
 
505
510
  AI has a growing number of applications, but one of the coolest classic ideas is to use it for Semantic Search.
506
- One can take an encoder model, like the multi-modal UForm, and a web-programming framework, like UCall, and build an image search platform in just 20 lines of Python.
511
+ One can take an encoder model, like the multi-modal UForm, and a web-programming framework, like UCall, and build a text-to-image search platform in just 20 lines of Python.
507
512
 
508
513
  ```python
509
- import ucall.rich_posix as ucall
514
+ import ucall
510
515
  import uform
511
- from usearch.index import Index
516
+ import usearch
512
517
 
513
518
  import numpy as np
514
- from PIL import Image
519
+ import PIL as pil
515
520
 
516
521
  server = ucall.Server()
517
522
  model = uform.get_model('unum-cloud/uform-vl-multilingual')
518
- index = Index(ndim=256)
523
+ index = usearch.index.Index(ndim=256)
519
524
 
520
525
  @server
521
- def add(label: int, photo: Image.Image):
526
+ def add(label: int, photo: pil.Image.Image):
522
527
  image = model.preprocess_image(photo)
523
528
  vector = model.encode_image(image).detach().numpy()
524
529
  index.add(label, vector.flatten(), copy=True)
@@ -527,10 +532,43 @@ def add(label: int, photo: Image.Image):
527
532
  def search(query: str) -> np.ndarray:
528
533
  tokens = model.preprocess_text(query)
529
534
  vector = model.encode_text(tokens).detach().numpy()
530
- neighbors, _, _ = index.search(vector.flatten(), 3)
531
- return neighbors
535
+ matches = index.search(vector.flatten(), 3)
536
+ return matches.labels
532
537
 
533
538
  server.run()
534
539
  ```
535
540
 
541
+ ### USearch + RDKit = Molecular Search
542
+
543
+ Research in natural sciences is expensive.
544
+ To search for properties of different molecules, large databases are used.
545
+ Comparing the molecules, however, is hard.
546
+ Instead, binary hash-like fingerprints are produced from the graph structure, using packages like RDKit.
547
+
548
+ ```python
549
+ from usearch.index import Index, MetricKind
550
+ from rdkit import Chem
551
+ from rdkit.Chem import AllChem
552
+
553
+ import numpy as np
554
+
555
+ molecules = [Chem.MolFromSmiles('CCOC'), Chem.MolFromSmiles('CCO')]
556
+ encoder = AllChem.GetRDKitFPGenerator()
557
+
558
+ fingerprints = np.vstack([encoder.GetFingerprint(x) for x in molecules])
559
+ fingerprints = np.packbits(fingerprints, axis=1)
560
+
561
+ index = Index(ndim=2048, metric=MetricKind.BitwiseTanimoto)
562
+ labels = np.array(len(molecules), dtype=np.longlong)
563
+
564
+ index.add(labels, fingerprints)
565
+ matches = index.search(fingerprints, 10)
566
+ ```
567
+
568
+ RDKit [provides][rdkit-fingerprints] Atom-Pair, Topological Torsion, Morgan, and Layered Fingerprints, among other options.
569
+
570
+ [rdkit-fingerprints]: https://www.rdkit.org/docs/RDKit_Book.html#additional-information-about-the-fingerprints
571
+
572
+ ---
573
+
536
574
  Check [that](https://github.com/ashvardanian/image-search) and [other](https://github.com/unum-cloud/examples) examples on our corporate GitHub 🤗
package/conanfile.py CHANGED
@@ -4,7 +4,7 @@ from conans import ConanFile
4
4
  class USearchConan(ConanFile):
5
5
 
6
6
  name = 'USearch'
7
- version = '0.12.1'
7
+ version = '0.13.0'
8
8
  license = 'Apache 2.0'
9
9
  url = 'https://github.com/unum-cloud/usearch'
10
10
  description = 'Smaller & Faster Single-File Vector Search Engine from Unum'
@@ -151,6 +151,13 @@ java -cp . -Djava.library.path=/Users/av/github/usearch/java cloud.unum.usearch.
151
151
  brew install --cask wolfram-engine
152
152
  ```
153
153
 
154
+ ## GoLang
155
+
156
+ ```sh
157
+ cd golang
158
+ go test -v
159
+ ```
160
+
154
161
  ## C
155
162
 
156
163
  ### Linux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usearch",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Smaller & Faster Single-File Vector Search Engine from Unum",
5
5
  "author": "Ashot Vardanian",
6
6
  "license": "Apache 2.0",
package/pyproject.toml CHANGED
@@ -46,4 +46,4 @@ before-build = [
46
46
  "git submodule update --init --recursive"
47
47
  ]
48
48
  archs = ["x86", "AMD64"]
49
- skip = ["*win32*"]
49
+ skip = ["*win32*", "pp*"]
package/python/lib.cpp CHANGED
@@ -717,7 +717,8 @@ PYBIND11_MODULE(compiled, m) {
717
717
  i.def_property_readonly("capacity", &punned_index_py_t::capacity);
718
718
  i.def_property_readonly( //
719
719
  "dtype", [](punned_index_py_t const& index) -> std::string { return accuracy_name(index.accuracy()); });
720
- i.def_property_readonly("memory_usage", &punned_index_py_t::memory_usage);
720
+ i.def_property_readonly("memory_usage",
721
+ [](punned_index_py_t const& index) -> std::size_t { return index.memory_usage(); });
721
722
 
722
723
  i.def_property("expansion_add", &get_expansion_add<punned_index_py_t>, &punned_index_py_t::change_expansion_add);
723
724
  i.def_property("expansion_search", &get_expansion_search<punned_index_py_t>,
@@ -1,6 +1,7 @@
1
1
  import time
2
2
  import numpy as np
3
3
  import math
4
+ from typing import Tuple, Any, Optional
4
5
 
5
6
  import fire
6
7
 
@@ -8,7 +9,7 @@ from usearch.index import Index
8
9
  from usearch.io import load_matrix
9
10
 
10
11
 
11
- def measure(f) -> float:
12
+ def measure(f) -> Tuple[float, Any]:
12
13
  a = time.time_ns()
13
14
  result = f()
14
15
  b = time.time_ns()
@@ -18,7 +19,10 @@ def measure(f) -> float:
18
19
  return secs, result
19
20
 
20
21
 
21
- def bench_faiss(index, vectors: np.array, queries: np.array, neighbors: np.array):
22
+ def bench_faiss(
23
+ index,
24
+ vectors: np.ndarray, queries: np.ndarray, neighbors: np.ndarray):
25
+
22
26
  dt, _ = measure(lambda: index.add(vectors))
23
27
  print(f'- Performance: {vectors.shape[0]/dt:.2f} insertions/s')
24
28
 
@@ -33,7 +37,10 @@ def bench_faiss(index, vectors: np.array, queries: np.array, neighbors: np.array
33
37
  print(f'- Recall@1: {recall_at_one * 100:.2f} %')
34
38
 
35
39
 
36
- def bench_usearch(index, vectors: np.array, queries: np.array, neighbors: np.array):
40
+ def bench_usearch(
41
+ index,
42
+ vectors: np.ndarray, queries: np.ndarray, neighbors: np.ndarray):
43
+
37
44
  labels = np.arange(vectors.shape[0], dtype=np.longlong)
38
45
  assert len(index) == 0
39
46
  dt, _ = measure(lambda: index.add(labels, vectors))
@@ -58,12 +65,15 @@ def main(
58
65
  connectivity: int = 16,
59
66
  expansion_add: int = 128,
60
67
  expansion_search: int = 64,
68
+ k: Optional[int] = None,
61
69
  ):
62
70
 
63
71
  vectors_mat = load_matrix(vectors)
64
72
  queries_mat = load_matrix(queries)
65
73
  neighbors_mat = load_matrix(neighbors)
66
74
  dim = vectors_mat.shape[1]
75
+ if k:
76
+ neighbors_mat = neighbors_mat[:, :1]
67
77
 
68
78
  print('USearch: HNSW')
69
79
  index = Index(
@@ -142,7 +152,7 @@ def main(
142
152
  expansion_add=expansion_add,
143
153
  expansion_search=expansion_search,
144
154
  connectivity=connectivity,
145
- metric=inner_product_f32.address,
155
+ metric=inner_product_f32,
146
156
  )
147
157
  bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
148
158
 
@@ -153,7 +163,7 @@ def main(
153
163
  expansion_add=expansion_add,
154
164
  expansion_search=expansion_search,
155
165
  connectivity=connectivity,
156
- metric=cos_f8.address,
166
+ metric=cos_f8,
157
167
  )
158
168
  bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
159
169
  except ImportError:
@@ -8,7 +8,6 @@ import pandas as pd
8
8
 
9
9
 
10
10
  from usearch.index import Index
11
- from usearch.io import load_matrix
12
11
 
13
12
 
14
13
  def measure(f) -> float:
@@ -40,6 +39,11 @@ class Main:
40
39
  expansion_add: int = 128,
41
40
  expansion_search: int = 64,
42
41
  ):
42
+ """Measures indexing speed for different dimensionality vectors.
43
+
44
+ :param count: Number of vectors, defaults to 1_000_000
45
+ :type count: int, optional
46
+ """
43
47
 
44
48
  dimensions = [
45
49
  2, 3, 4, 8, 16, 32, 96, 100,
@@ -78,12 +82,20 @@ class Main:
78
82
  expansion_add: int = 128,
79
83
  expansion_search: int = 64,
80
84
  ):
85
+ """Measures indexing speed and accuracy for different level of
86
+ connectivity in the levels of the hierarchical proximity graph.
87
+
88
+ :param count: Number of vectors, defaults to 1_000_000
89
+ :type count: int, optional
90
+ :param ndim: Number of dimensions per vector, defaults to 256
91
+ :type ndim: int, optional
92
+ """
81
93
 
82
- connectivities = range(10, 20)
94
+ connectivity_options = range(10, 20)
83
95
  results = []
84
96
 
85
97
  try:
86
- for connectivity in connectivities:
98
+ for connectivity in connectivity_options:
87
99
  vectors_mat = np.random.rand(count, ndim).astype(np.float32)
88
100
 
89
101
  print(f'USearch for {connectivity} connectivity')
@@ -107,6 +119,43 @@ class Main:
107
119
  __file__), 'bench_connectivity.png'))
108
120
  fig.show()
109
121
 
122
+ def robustness(
123
+ self,
124
+ experiments: int = 10,
125
+ count: int = 1_000_000,
126
+ ndim: int = 256,
127
+ connectivity: int = 16,
128
+ expansion_add: int = 128,
129
+ expansion_search: int = 64,
130
+ ):
131
+ """How much does accuracy fluctuate depending on the indexing order.
132
+
133
+ :param experiments: How many times to repeat, defaults to 10
134
+ :type experiments: int, optional
135
+ :param count: Number of vectors, defaults to 1_000_000
136
+ :type count: int, optional
137
+ :param ndim: Number of dimensions per vector, defaults to 256
138
+ :type ndim: int, optional
139
+ """
140
+
141
+ recall_levels = []
142
+ vectors_mat = np.random.rand(count, ndim).astype(np.float32)
143
+
144
+ for _ in range(experiments):
145
+
146
+ index = Index(
147
+ ndim=ndim,
148
+ expansion_add=expansion_add,
149
+ expansion_search=expansion_search,
150
+ connectivity=connectivity,
151
+ )
152
+ bench_usearch(index, vectors_mat)
153
+ del index
154
+
155
+ min_ = min(recall_levels)
156
+ max_ = max(recall_levels)
157
+ print(f'Recall@1 varies between {min_:.3f} and {max_:.3f}')
158
+
110
159
 
111
160
  if __name__ == '__main__':
112
161
  fire.Fire(Main)
@@ -31,18 +31,43 @@ SetsIndex = _CompiledSetsIndex
31
31
  class Matches(NamedTuple):
32
32
  labels: np.ndarray
33
33
  distances: np.ndarray
34
- counts: np.ndarray
34
+ counts: Union[np.ndarray, int]
35
35
 
36
+ @property
37
+ def is_batch(self) -> bool:
38
+ return isinstance(self.counts, np.ndarray)
39
+
40
+ @property
41
+ def batch_size(self) -> int:
42
+ return len(self.counts) if isinstance(self.counts, np.ndarray) else 1
43
+
44
+ @property
45
+ def total_matches(self) -> int:
46
+ return np.sum(self.counts)
47
+
48
+ def to_list(self, row: Optional[int] = None) -> Union[List[dict], List[List[dict]]]:
36
49
 
37
- def list_matches(results: Matches, row: int) -> List[dict]:
50
+ if not self.is_batch:
51
+ assert row is None, 'Exporting a single sequence is only for batch requests'
52
+ labels = self.labels
53
+ distances = self.distances
38
54
 
39
- count = results[2][row]
40
- labels = results[0][row, :count]
41
- distances = results[1][row, :count]
42
- return [
43
- {'label': int(label), 'distance': float(distance)}
44
- for label, distance in zip(labels, distances)
45
- ]
55
+ elif row is None:
56
+ return [self.to_list(i) for i in range(self.batch_size)]
57
+
58
+ else:
59
+ count = self.counts[row]
60
+ labels = self.labels[row, :count]
61
+ distances = self.distances[row, :count]
62
+
63
+ return [
64
+ {'label': int(label), 'distance': float(distance)}
65
+ for label, distance in zip(labels, distances)
66
+ ]
67
+
68
+ def __repr__(self) -> str:
69
+ return f'usearch.Matches({self.total_matches})' if self.is_batch else \
70
+ f'usearch.Matches({self.total_matches} across {self.batch_size} queries)'
46
71
 
47
72
 
48
73
  def jit_metric(ndim: int, metric: MetricKind, dtype: str = 'f32') -> Callable:
@@ -150,9 +175,9 @@ class Index:
150
175
  :param metric: Distance function, defaults to MetricKind.IP
151
176
  :type metric: Union[MetricKind, Callable], optional
152
177
  Kind of the distance function, or the Numba `cfunc` JIT-compiled object.
153
- Possible `MetricKind` values: IP, Cosine, L2sq, Haversine,
154
- Hamming, Tanimoto, Sorensen.
155
- Not every kind is JIT-able.
178
+ Possible `MetricKind` values: IP, Cos, L2sq, Haversine, Pearson,
179
+ BitwiseHamming, BitwiseTanimoto, BitwiseSorensen.
180
+ Not every kind is JIT-able. For Jaccard distance, use `SetsIndex`.
156
181
 
157
182
  :param dtype: Scalar type for internal vector storage, defaults to None
158
183
  :type dtype: str, optional
@@ -333,6 +358,10 @@ class Index:
333
358
  def ndim(self) -> int:
334
359
  return self._compiled.ndim
335
360
 
361
+ @property
362
+ def metric(self) -> MetricKind:
363
+ return self._metric_kind
364
+
336
365
  @property
337
366
  def dtype(self) -> str:
338
367
  return self._compiled.dtype
@@ -1,16 +1,21 @@
1
1
  import struct
2
+
2
3
  import numpy as np
3
4
 
4
5
 
5
- def load_matrix(filename: str, start_row: int = 0, count_rows: int = None, view: bool = False):
6
- """
7
- Read *.ibin, *.hbin, *.fbin, *.dbin files with matrixes.
8
- Args:
9
- :param filename (str): path to the matrix file
10
- :param start_row (int): start reading vectors from this index
11
- :param count_rows (int): number of vectors to read. If None, read all vectors
12
- Returns:
13
- Parsed matrix (numpy.ndarray)
6
+ def load_matrix(
7
+ filename: str,
8
+ start_row: int = 0, count_rows: int = None,
9
+ view: bool = False) -> np.ndarray:
10
+ """Read *.ibin, *.hbin, *.fbin, *.dbin files with matrixes.
11
+
12
+ :param filename: path to the matrix file
13
+ :param start_row: start reading vectors from this index
14
+ :param count_rows: number of vectors to read. If None, read all vectors
15
+ :param view: set to `True` to memory-map the file instead of loading to RAM
16
+
17
+ :return: parsed matrix
18
+ :rtype: numpy.ndarray
14
19
  """
15
20
  dtype = np.float32
16
21
  scalar_size = 4
@@ -35,17 +40,21 @@ def load_matrix(filename: str, start_row: int = 0, count_rows: int = None, view:
35
40
  row_offset = start_row * scalar_size * cols
36
41
 
37
42
  if view:
38
- return np.memmap(f, dtype=dtype, mode='r', offset=8+row_offset, shape=(rows, cols))
43
+ return np.memmap(
44
+ f, dtype=dtype, mode='r', offset=8+row_offset, shape=(rows, cols))
39
45
  else:
40
- return np.fromfile(f, count=rows * cols, dtype=dtype, offset=row_offset).reshape(rows, cols)
46
+ return np.fromfile(
47
+ f, count=rows * cols,
48
+ dtype=dtype, offset=row_offset).reshape(rows, cols)
41
49
 
42
50
 
43
- def save_matrix(vecs: np.array, filename: str):
44
- """
45
- Write *.ibin, *.hbin, *.fbin, *.dbin files with matrixes.
46
- Args:
47
- :param vecs (numpy.array): the matrix to serialize
48
- :param filename (str): path to the matrix file
51
+ def save_matrix(vectors: np.ndarray, filename: str):
52
+ """Write *.ibin, *.hbin, *.fbin, *.dbin files with matrixes.
53
+
54
+ :param vectors: the matrix to serialize
55
+ :type vectors: numpy.ndarray
56
+ :param filename: path to the matrix file
57
+ :type filename: str
49
58
  """
50
59
  dtype = np.float32
51
60
  if filename.endswith('.fbin'):
@@ -59,9 +68,9 @@ def save_matrix(vecs: np.array, filename: str):
59
68
  else:
60
69
  raise Exception('Unknown file type')
61
70
 
62
- assert len(vecs.shape) == 2, 'Input array must have 2 dimensions'
71
+ assert len(vectors.shape) == 2, 'Input array must have 2 dimensions'
63
72
  with open(filename, 'wb') as f:
64
- nvecs, dim = vecs.shape
65
- f.write(struct.pack('<i', nvecs))
73
+ count, dim = vectors.shape
74
+ f.write(struct.pack('<i', count))
66
75
  f.write(struct.pack('<i', dim))
67
- vecs.astype(dtype).flatten().tofile(f)
76
+ vectors.astype(dtype).flatten().tofile(f)
@@ -0,0 +1,32 @@
1
+ import numpy as np
2
+
3
+ from usearch.index import Index, Matches, MetricKind, BitwiseMetricKind
4
+
5
+
6
+ def vectors(index: Index, count: int) -> np.ndarray:
7
+
8
+ if not isinstance(index, Index):
9
+ raise ValueError('Unsupported `index` type')
10
+
11
+ dimensions = index.ndim
12
+
13
+ if index.metric in BitwiseMetricKind:
14
+ bit_vectors = np.random.randint(2, size=(count, dimensions))
15
+ bit_vectors = np.packbits(bit_vectors, axis=1)
16
+ return bit_vectors
17
+
18
+ else:
19
+ x = np.random.rand(count, dimensions).astype(index.numpy_dtype)
20
+ if index.metric == MetricKind.IP:
21
+ return x / np.linalg.norm(x, axis=1, keepdims=True)
22
+ return x
23
+
24
+
25
+ def recall_at_one(index: Index, vectors: np.ndarray) -> float:
26
+ empty_replica: Index = index.fork()
27
+
28
+ labels = np.arange(vectors.shape[0])
29
+ empty_replica.add(labels=labels, vectors=vectors)
30
+ matches: Matches = empty_replica.search(empty_replica, 1)
31
+
32
+ return np.sum(matches.labels.flatten() == labels) / len(labels)