usearch 0.12.2 → 0.14.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 Development Branch
32
32
  needs: versioning
33
33
  runs-on: ubuntu-latest
34
34
  steps:
@@ -37,15 +37,18 @@ 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
45
  - name: Push changes
45
- uses: ad-m/github-push-action@master
46
+ uses: CasperWA/push-protected@v2
46
47
  with:
47
- github_token: ${{ secrets.GITHUB_TOKEN }}
48
+ token: ${{ secrets.SEMANTIC_REBASE_TOKEN }}
48
49
  branch: main-dev
50
+ unprotect_reviews: True
51
+ force: True
49
52
 
50
53
  build_wheels:
51
54
  name: Build Wheels for ${{ matrix.os }}
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "usearch"
3
- version = "0.12.2"
3
+ version = "0.14.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.2'
7
+ version = '0.14.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'
@@ -94,6 +94,7 @@ Checking the effect of different embedding dimensions on construction speed:
94
94
  ```sh
95
95
  python python/scripts/bench_params.py dimensions
96
96
  python python/scripts/bench_params.py connectivity
97
+ python python/scripts/bench_params.py robustness
97
98
  ```
98
99
 
99
100
  ## JavaScript
@@ -151,6 +152,13 @@ java -cp . -Djava.library.path=/Users/av/github/usearch/java cloud.unum.usearch.
151
152
  brew install --cask wolfram-engine
152
153
  ```
153
154
 
155
+ ## GoLang
156
+
157
+ ```sh
158
+ cd golang
159
+ go test -v
160
+ ```
161
+
154
162
  ## C
155
163
 
156
164
  ### Linux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usearch",
3
- "version": "0.12.2",
3
+ "version": "0.14.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/python/lib.cpp CHANGED
@@ -347,8 +347,8 @@ static py::tuple search_one_in_index(punned_index_py_t& index, py::buffer vector
347
347
  py::array_t<label_t> labels_py(static_cast<Py_ssize_t>(wanted));
348
348
  py::array_t<distance_t> distances_py(static_cast<Py_ssize_t>(wanted));
349
349
  std::size_t count{};
350
- auto labels_py1d = labels_py.mutable_unchecked<1>();
351
- auto distances_py1d = distances_py.mutable_unchecked<1>();
350
+ auto labels_py1d = labels_py.template mutable_unchecked<1>();
351
+ auto distances_py1d = distances_py.template mutable_unchecked<1>();
352
352
 
353
353
  search_config_t config;
354
354
  config.exact = exact;
@@ -416,9 +416,9 @@ static py::tuple search_many_in_index( //
416
416
  py::array_t<label_t> labels_py({vectors_count, static_cast<Py_ssize_t>(wanted)});
417
417
  py::array_t<distance_t> distances_py({vectors_count, static_cast<Py_ssize_t>(wanted)});
418
418
  py::array_t<Py_ssize_t> counts_py(vectors_count);
419
- auto labels_py2d = labels_py.mutable_unchecked<2>();
420
- auto distances_py2d = distances_py.mutable_unchecked<2>();
421
- auto counts_py1d = counts_py.mutable_unchecked<1>();
419
+ auto labels_py2d = labels_py.template mutable_unchecked<2>();
420
+ auto distances_py2d = distances_py.template mutable_unchecked<2>();
421
+ auto counts_py1d = counts_py.template mutable_unchecked<1>();
422
422
 
423
423
  // https://docs.python.org/3/library/struct.html#format-characters
424
424
  if (vectors_info.format == "c" || vectors_info.format == "b")
@@ -508,9 +508,9 @@ static py::tuple search_many_in_bits_index( //
508
508
  py::array_t<label_t> labels_py({vectors_count, static_cast<Py_ssize_t>(wanted)});
509
509
  py::array_t<distance_t> distances_py({vectors_count, static_cast<Py_ssize_t>(wanted)});
510
510
  py::array_t<Py_ssize_t> counts_py(vectors_count);
511
- auto labels_py2d = labels_py.mutable_unchecked<2>();
512
- auto distances_py2d = distances_py.mutable_unchecked<2>();
513
- auto counts_py1d = counts_py.mutable_unchecked<1>();
511
+ auto labels_py2d = labels_py.template mutable_unchecked<2>();
512
+ auto distances_py2d = distances_py.template mutable_unchecked<2>();
513
+ auto counts_py1d = counts_py.template mutable_unchecked<1>();
514
514
 
515
515
  // https://docs.python.org/3/library/struct.html#format-characters
516
516
  if (vectors_info.format != "B")
@@ -548,6 +548,36 @@ template <typename index_at> std::size_t get_expansion_add(index_at const &index
548
548
  template <typename index_at> std::size_t get_expansion_search(index_at const &index) { return index.config().expansion_search; }
549
549
  // clang-format on
550
550
 
551
+ template <typename scalar_at, typename index_at>
552
+ py::array_t<scalar_at> get_typed_member(index_at const& index, label_t label) {
553
+ py::array_t<scalar_at> result_py(static_cast<Py_ssize_t>(index.dimensions()));
554
+ auto result_py1d = result_py.template mutable_unchecked<1>();
555
+ index.reconstruct(label, &result_py1d(0));
556
+ return result_py;
557
+ }
558
+
559
+ template <typename index_at>
560
+ py::array get_member(index_at const& index, label_t label, common_scalar_kind_t scalar_kind) {
561
+ if (scalar_kind == common_scalar_kind_t::f32_k)
562
+ return get_typed_member<f32_t>(index, label);
563
+ else if (scalar_kind == common_scalar_kind_t::f64_k)
564
+ return get_typed_member<f64_t>(index, label);
565
+ else if (scalar_kind == common_scalar_kind_t::f16_k)
566
+ return get_typed_member<f16_bits_t>(index, label);
567
+ else if (scalar_kind == common_scalar_kind_t::f8_k)
568
+ return get_typed_member<f8_bits_t>(index, label);
569
+ else
570
+ throw std::invalid_argument("Incompatible scalars in the query matrix!");
571
+ }
572
+
573
+ template <typename index_at> py::array_t<label_t> get_labels(index_at const& index) {
574
+ std::size_t result_length = index.size();
575
+ py::array_t<label_t> result_py(static_cast<Py_ssize_t>(result_length));
576
+ auto result_py1d = result_py.template mutable_unchecked<1>();
577
+ index.export_labels(&result_py1d(0), result_length);
578
+ return result_py;
579
+ }
580
+
551
581
  template <typename element_at> bool has_duplicates(element_at const* begin, element_at const* end) {
552
582
  if (begin == end)
553
583
  return false;
@@ -611,7 +641,7 @@ py::array_t<bits_numpy_word_t> hash_typed_buffer(py::buffer_info const& info, st
611
641
  Py_ssize_t rows_stride = info.strides[1];
612
642
  auto hashes_shape = py_shape_t{rows_count, words_per_row};
613
643
  auto hashes_py = py::array_t<bits_numpy_word_t>(hashes_shape);
614
- auto hashes_proxy = hashes_py.mutable_unchecked<2>();
644
+ auto hashes_proxy = hashes_py.template mutable_unchecked<2>();
615
645
 
616
646
  for (Py_ssize_t i = 0; i != rows_count; ++i)
617
647
  hash_typed_row( //
@@ -622,7 +652,7 @@ py::array_t<bits_numpy_word_t> hash_typed_buffer(py::buffer_info const& info, st
622
652
  } else {
623
653
  auto hashes_shape = py_shape_t{words_per_row};
624
654
  auto hashes_py = py::array_t<bits_numpy_word_t>(hashes_shape);
625
- auto hashes_proxy = hashes_py.mutable_unchecked<1>();
655
+ auto hashes_proxy = hashes_py.template mutable_unchecked<1>();
626
656
 
627
657
  hash_typed_row( //
628
658
  data, hashes_info.strides[0], static_cast<std::size_t>(elements_per_row), //
@@ -658,6 +688,18 @@ PYBIND11_MODULE(compiled, m) {
658
688
  m.attr("DEFAULT_EXPANSION_ADD") = py::int_(default_expansion_add());
659
689
  m.attr("DEFAULT_EXPANSION_SEARCH") = py::int_(default_expansion_search());
660
690
 
691
+ #if defined(USEARCH_USE_OPENMP)
692
+ m.attr("USES_OPENMP") = py::int_(1);
693
+ #else
694
+ m.attr("USES_OPENMP") = py::int_(0);
695
+ #endif
696
+
697
+ #if defined(USEARCH_USE_SIMSIMD)
698
+ m.attr("USES_SIMSIMD") = py::int_(1);
699
+ #else
700
+ m.attr("USES_SIMSIMD") = py::int_(0);
701
+ #endif
702
+
661
703
  py::enum_<common_metric_kind_t>(m, "MetricKind")
662
704
  .value("Unknown", common_metric_kind_t::unknown_k)
663
705
  .value("IP", common_metric_kind_t::ip_k)
@@ -670,6 +712,13 @@ PYBIND11_MODULE(compiled, m) {
670
712
  .value("BitwiseTanimoto", common_metric_kind_t::bitwise_tanimoto_k)
671
713
  .value("BitwiseSorensen", common_metric_kind_t::bitwise_sorensen_k);
672
714
 
715
+ py::enum_<common_scalar_kind_t>(m, "ScalarKind")
716
+ .value("F64", common_scalar_kind_t::f64_k)
717
+ .value("F32", common_scalar_kind_t::f32_k)
718
+ .value("F16", common_scalar_kind_t::f16_k)
719
+ .value("F8", common_scalar_kind_t::f8_k)
720
+ .value("B1", common_scalar_kind_t::b1_k);
721
+
673
722
  auto i = py::class_<punned_index_py_t>(m, "Index");
674
723
 
675
724
  i.def(py::init(&make_index), //
@@ -717,12 +766,19 @@ PYBIND11_MODULE(compiled, m) {
717
766
  i.def_property_readonly("capacity", &punned_index_py_t::capacity);
718
767
  i.def_property_readonly( //
719
768
  "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);
769
+ i.def_property_readonly( //
770
+ "memory_usage", [](punned_index_py_t const& index) -> std::size_t { return index.memory_usage(); });
721
771
 
722
772
  i.def_property("expansion_add", &get_expansion_add<punned_index_py_t>, &punned_index_py_t::change_expansion_add);
723
773
  i.def_property("expansion_search", &get_expansion_search<punned_index_py_t>,
724
774
  &punned_index_py_t::change_expansion_search);
725
775
 
776
+ i.def_property_readonly("labels", &get_labels<punned_index_py_t>);
777
+ i.def("__contains__", &punned_index_py_t::contains);
778
+ i.def( //
779
+ "__getitem__", &get_member<punned_index_py_t>, py::arg("label"),
780
+ py::arg("dtype") = common_scalar_kind_t::f32_k);
781
+
726
782
  i.def("save", &save_index<punned_index_py_t>, py::arg("path"));
727
783
  i.def("load", &load_index<punned_index_py_t>, py::arg("path"));
728
784
  i.def("view", &view_index<punned_index_py_t>, py::arg("path"));
@@ -804,7 +860,7 @@ PYBIND11_MODULE(compiled, m) {
804
860
  auto proxy = set.unchecked<1>();
805
861
  auto view = set_view_t{&proxy(0), static_cast<std::size_t>(proxy.shape(0))};
806
862
  auto labels_py = py::array_t<label_t>(py_shape_t{static_cast<Py_ssize_t>(count)});
807
- auto labels_proxy = labels_py.mutable_unchecked<1>();
863
+ auto labels_proxy = labels_py.template mutable_unchecked<1>();
808
864
  auto result = index.search(view, count);
809
865
  result.error.raise();
810
866
  auto found = result.dump_to(&labels_proxy(0));
@@ -1,14 +1,20 @@
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
 
7
8
  from usearch.index import Index
8
9
  from usearch.io import load_matrix
10
+ from usearch.index import (
11
+ DEFAULT_CONNECTIVITY,
12
+ DEFAULT_EXPANSION_ADD,
13
+ DEFAULT_EXPANSION_SEARCH,
14
+ )
9
15
 
10
16
 
11
- def measure(f) -> float:
17
+ def measure(f) -> Tuple[float, Any]:
12
18
  a = time.time_ns()
13
19
  result = f()
14
20
  b = time.time_ns()
@@ -18,7 +24,10 @@ def measure(f) -> float:
18
24
  return secs, result
19
25
 
20
26
 
21
- def bench_faiss(index, vectors: np.array, queries: np.array, neighbors: np.array):
27
+ def bench_faiss(
28
+ index,
29
+ vectors: np.ndarray, queries: np.ndarray, neighbors: np.ndarray):
30
+
22
31
  dt, _ = measure(lambda: index.add(vectors))
23
32
  print(f'- Performance: {vectors.shape[0]/dt:.2f} insertions/s')
24
33
 
@@ -33,7 +42,10 @@ def bench_faiss(index, vectors: np.array, queries: np.array, neighbors: np.array
33
42
  print(f'- Recall@1: {recall_at_one * 100:.2f} %')
34
43
 
35
44
 
36
- def bench_usearch(index, vectors: np.array, queries: np.array, neighbors: np.array):
45
+ def bench_usearch(
46
+ index,
47
+ vectors: np.ndarray, queries: np.ndarray, neighbors: np.ndarray):
48
+
37
49
  labels = np.arange(vectors.shape[0], dtype=np.longlong)
38
50
  assert len(index) == 0
39
51
  dt, _ = measure(lambda: index.add(labels, vectors))
@@ -55,15 +67,18 @@ def main(
55
67
  vectors: str,
56
68
  queries: str,
57
69
  neighbors: str,
58
- connectivity: int = 16,
59
- expansion_add: int = 128,
60
- expansion_search: int = 64,
70
+ connectivity: int = DEFAULT_CONNECTIVITY,
71
+ expansion_add: int = DEFAULT_EXPANSION_ADD,
72
+ expansion_search: int = DEFAULT_EXPANSION_SEARCH,
73
+ k: Optional[int] = None,
61
74
  ):
62
75
 
63
76
  vectors_mat = load_matrix(vectors)
64
77
  queries_mat = load_matrix(queries)
65
78
  neighbors_mat = load_matrix(neighbors)
66
79
  dim = vectors_mat.shape[1]
80
+ if k:
81
+ neighbors_mat = neighbors_mat[:, :1]
67
82
 
68
83
  print('USearch: HNSW')
69
84
  index = Index(
@@ -142,7 +157,7 @@ def main(
142
157
  expansion_add=expansion_add,
143
158
  expansion_search=expansion_search,
144
159
  connectivity=connectivity,
145
- metric=inner_product_f32.address,
160
+ metric=inner_product_f32,
146
161
  )
147
162
  bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
148
163
 
@@ -153,7 +168,7 @@ def main(
153
168
  expansion_add=expansion_add,
154
169
  expansion_search=expansion_search,
155
170
  connectivity=connectivity,
156
- metric=cos_f8.address,
171
+ metric=cos_f8,
157
172
  )
158
173
  bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
159
174
  except ImportError:
@@ -6,9 +6,15 @@ import plotly.express as px
6
6
  import numpy as np
7
7
  import pandas as pd
8
8
 
9
-
10
9
  from usearch.index import Index
11
- from usearch.io import load_matrix
10
+ from usearch.index import (
11
+ DEFAULT_CONNECTIVITY,
12
+ DEFAULT_EXPANSION_ADD,
13
+ DEFAULT_EXPANSION_SEARCH,
14
+
15
+ USES_OPENMP,
16
+ USES_SIMSIMD,
17
+ )
12
18
 
13
19
 
14
20
  def measure(f) -> float:
@@ -31,15 +37,29 @@ def bench_usearch(index, vectors: np.ndarray) -> float:
31
37
  return insertions_per_second
32
38
 
33
39
 
40
+ def recall_at_one(index: Index, vectors: np.ndarray) -> float:
41
+
42
+ labels = np.arange(vectors.shape[0])
43
+ index.add(labels=labels, vectors=vectors)
44
+ matches = index.search(vectors, 1)
45
+
46
+ return np.sum(matches.labels.flatten() == labels) / len(labels)
47
+
48
+
34
49
  class Main:
35
50
 
36
51
  def dimensions(
37
52
  self,
38
53
  count: int = 1_000_000,
39
- connectivity: int = 16,
40
- expansion_add: int = 128,
41
- expansion_search: int = 64,
54
+ connectivity: int = DEFAULT_CONNECTIVITY,
55
+ expansion_add: int = DEFAULT_EXPANSION_ADD,
56
+ expansion_search: int = DEFAULT_EXPANSION_SEARCH,
42
57
  ):
58
+ """Measures indexing speed for different dimensionality vectors.
59
+
60
+ :param count: Number of vectors, defaults to 1_000_000
61
+ :type count: int, optional
62
+ """
43
63
 
44
64
  dimensions = [
45
65
  2, 3, 4, 8, 16, 32, 96, 100,
@@ -75,15 +95,23 @@ class Main:
75
95
  self,
76
96
  count: int = 1_000_000,
77
97
  ndim: int = 256,
78
- expansion_add: int = 128,
79
- expansion_search: int = 64,
98
+ expansion_add: int = DEFAULT_EXPANSION_ADD,
99
+ expansion_search: int = DEFAULT_EXPANSION_SEARCH,
80
100
  ):
101
+ """Measures indexing speed and accuracy for different level of
102
+ connectivity in the levels of the hierarchical proximity graph.
81
103
 
82
- connectivities = range(10, 20)
104
+ :param count: Number of vectors, defaults to 1_000_000
105
+ :type count: int, optional
106
+ :param ndim: Number of dimensions per vector, defaults to 256
107
+ :type ndim: int, optional
108
+ """
109
+
110
+ connectivity_options = range(10, 20)
83
111
  results = []
84
112
 
85
113
  try:
86
- for connectivity in connectivities:
114
+ for connectivity in connectivity_options:
87
115
  vectors_mat = np.random.rand(count, ndim).astype(np.float32)
88
116
 
89
117
  print(f'USearch for {connectivity} connectivity')
@@ -107,6 +135,67 @@ class Main:
107
135
  __file__), 'bench_connectivity.png'))
108
136
  fig.show()
109
137
 
138
+ def robustness(
139
+ self,
140
+ experiments: int = 1,
141
+ count: int = 10000,
142
+ ndim: int = 256,
143
+ connectivity: int = DEFAULT_CONNECTIVITY,
144
+ expansion_add: int = DEFAULT_EXPANSION_ADD,
145
+ expansion_search: int = DEFAULT_EXPANSION_SEARCH,
146
+ ):
147
+ """How much does accuracy and speed fluctuate depending on the indexing order.
148
+
149
+ :param experiments: How many times to repeat, defaults to 10
150
+ :type experiments: int, optional
151
+ :param count: Number of vectors, defaults to 1_000_000
152
+ :type count: int, optional
153
+ :param ndim: Number of dimensions per vector, defaults to 256
154
+ :type ndim: int, optional
155
+ """
156
+
157
+ print(f'Connectivity: {connectivity}')
158
+ print(f'Number of vectors: {count}')
159
+ print(f'Vector dimensions: {ndim}')
160
+ print(f'Expansion on Add: {expansion_add}')
161
+ print(f'Expansion on Search: {expansion_search}')
162
+ print(f'Uses OpenMP: {USES_OPENMP}')
163
+ print(f'Uses SimSIMD: {USES_SIMSIMD}')
164
+
165
+ recall_levels = []
166
+ vectors_mat = np.random.rand(count, ndim).astype(np.float32)
167
+
168
+ for _ in range(experiments):
169
+ np.random.shuffle(vectors_mat)
170
+ index = Index(
171
+ ndim=ndim,
172
+ expansion_add=expansion_add,
173
+ expansion_search=expansion_search,
174
+ connectivity=connectivity,
175
+ )
176
+ bench_usearch(index, vectors_mat)
177
+ recall = recall_at_one(index, vectors_mat) * 100
178
+ print(f'- Recall @ 1 {recall:.2f} %')
179
+ recall_levels.append(recall)
180
+ del index
181
+
182
+ min_ = min(recall_levels) * 100
183
+ max_ = max(recall_levels) * 100
184
+ print(f'Recall @ 1 varies between {min_:.3f} and {max_:.3f}')
185
+
186
+ # Now let's try clustering and inserting in clusters
187
+ try:
188
+ from sklearn.cluster import KMeans
189
+ except ImportError:
190
+ return
191
+
192
+ count_clusters = count / expansion_add
193
+ clustering = KMeans(
194
+ n_clusters=count_clusters,
195
+ random_state=0, n_init='auto').fit(vectors_mat)
196
+ NamedTuple()
197
+
110
198
 
111
199
  if __name__ == '__main__':
200
+ Main().robustness()
112
201
  fire.Fire(Main)
@@ -3,14 +3,21 @@ import pytest
3
3
  import numpy as np
4
4
 
5
5
  from usearch.io import load_matrix, save_matrix
6
- from usearch.index import Index, SetsIndex, MetricKind
6
+ from usearch.synthetic import recall_members
7
+
8
+ from usearch.index import Index, SetsIndex, MetricKind, Matches
9
+ from usearch.index import (
10
+ DEFAULT_CONNECTIVITY,
11
+ DEFAULT_EXPANSION_ADD,
12
+ DEFAULT_EXPANSION_SEARCH,
13
+ )
7
14
 
8
15
 
9
16
  dimensions = [13, 99, 100, 256]
10
17
  batch_sizes = [1, 2, 100]
11
18
  index_types = ['f32', 'f64', 'f16', 'f8']
12
19
  numpy_types = [np.float32, np.float64, np.float16, np.byte]
13
- connectivity_options = [3, 13, 50]
20
+ connectivity_options = [3, 13, 50, DEFAULT_CONNECTIVITY]
14
21
  jit_options = [False]
15
22
  continuous_metrics = [
16
23
  MetricKind.Cos,
@@ -26,6 +33,7 @@ hash_metrics = [
26
33
  @pytest.mark.parametrize('rows', batch_sizes)
27
34
  @pytest.mark.parametrize('cols', dimensions)
28
35
  def test_serializing_fbin_matrix(rows: int, cols: int):
36
+
29
37
  original = np.random.rand(rows, cols).astype(np.float32)
30
38
  save_matrix(original, 'tmp.fbin')
31
39
  reconstructed = load_matrix('tmp.fbin')
@@ -36,6 +44,7 @@ def test_serializing_fbin_matrix(rows: int, cols: int):
36
44
  @pytest.mark.parametrize('rows', batch_sizes)
37
45
  @pytest.mark.parametrize('cols', dimensions)
38
46
  def test_serializing_ibin_matrix(rows: int, cols: int):
47
+
39
48
  original = np.random.randint(0, rows+1, size=(rows, cols)).astype(np.int32)
40
49
  save_matrix(original, 'tmp.ibin')
41
50
  reconstructed = load_matrix('tmp.ibin')
@@ -59,6 +68,8 @@ def test_index(
59
68
  ndim=ndim,
60
69
  dtype=index_type,
61
70
  connectivity=connectivity,
71
+ expansion_add=DEFAULT_EXPANSION_ADD,
72
+ expansion_search=DEFAULT_EXPANSION_SEARCH,
62
73
  jit=jit,
63
74
  )
64
75
  assert index.ndim == ndim
@@ -76,6 +87,11 @@ def test_index(
76
87
  assert matches[0] == 42
77
88
  assert distances[0] == pytest.approx(0, abs=1e-3)
78
89
 
90
+ assert 42 in index
91
+ assert 42 in index.labels
92
+ if numpy_type != np.byte:
93
+ assert np.allclose(index[42], vector, atol=0.1)
94
+
79
95
  index.save('tmp.usearch')
80
96
  index.clear()
81
97
  assert len(index) == 0
@@ -101,10 +117,13 @@ def test_index_batch(
101
117
  labels = np.array(range(batch_size), dtype=np.longlong)
102
118
 
103
119
  index.add(labels, vectors)
104
- matches, distances, count = index.search(vectors, 10)
120
+ matches: Matches = index.search(vectors, 10)
105
121
 
106
- assert matches.shape[0] == distances.shape[0]
107
- assert count.shape[0] == batch_size
122
+ assert matches.labels.shape[0] == matches.distances.shape[0]
123
+ assert matches.counts.shape[0] == batch_size
124
+ assert np.all(np.sort(index.labels) == np.sort(labels))
125
+
126
+ assert recall_members(index, exact=True) == 1
108
127
 
109
128
 
110
129
  @pytest.mark.parametrize('ndim', dimensions)
@@ -17,6 +17,9 @@ from usearch.compiled import (
17
17
  DEFAULT_CONNECTIVITY,
18
18
  DEFAULT_EXPANSION_ADD,
19
19
  DEFAULT_EXPANSION_SEARCH,
20
+
21
+ USES_OPENMP,
22
+ USES_SIMSIMD,
20
23
  )
21
24
 
22
25
  BitwiseMetricKind = (
@@ -31,18 +34,47 @@ SetsIndex = _CompiledSetsIndex
31
34
  class Matches(NamedTuple):
32
35
  labels: np.ndarray
33
36
  distances: np.ndarray
34
- counts: np.ndarray
37
+ counts: Union[np.ndarray, int]
38
+
39
+ @property
40
+ def is_batch(self) -> bool:
41
+ return isinstance(self.counts, np.ndarray)
42
+
43
+ @property
44
+ def batch_size(self) -> int:
45
+ return len(self.counts) if isinstance(self.counts, np.ndarray) else 1
46
+
47
+ @property
48
+ def total_matches(self) -> int:
49
+ return np.sum(self.counts)
50
+
51
+ def to_list(self, row: Optional[int] = None) -> Union[List[dict], List[List[dict]]]:
52
+
53
+ if not self.is_batch:
54
+ assert row is None, 'Exporting a single sequence is only for batch requests'
55
+ labels = self.labels
56
+ distances = self.distances
35
57
 
58
+ elif row is None:
59
+ return [self.to_list(i) for i in range(self.batch_size)]
36
60
 
37
- def list_matches(results: Matches, row: int) -> List[dict]:
61
+ else:
62
+ count = self.counts[row]
63
+ labels = self.labels[row, :count]
64
+ distances = self.distances[row, :count]
65
+
66
+ return [
67
+ {'label': int(label), 'distance': float(distance)}
68
+ for label, distance in zip(labels, distances)
69
+ ]
70
+
71
+ def recall_first(self, expected: np.ndarray) -> float:
72
+ best_matches = self.labels if self.is_batch else self.labels[:, 0]
73
+ return np.sum(best_matches == expected) / len(expected)
38
74
 
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
- ]
75
+ def __repr__(self) -> str:
76
+ return f'usearch.Matches({self.total_matches})' if self.is_batch else \
77
+ f'usearch.Matches({self.total_matches} across {self.batch_size} queries)'
46
78
 
47
79
 
48
80
  def jit_metric(ndim: int, metric: MetricKind, dtype: str = 'f32') -> Callable:
@@ -150,9 +182,9 @@ class Index:
150
182
  :param metric: Distance function, defaults to MetricKind.IP
151
183
  :type metric: Union[MetricKind, Callable], optional
152
184
  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.
185
+ Possible `MetricKind` values: IP, Cos, L2sq, Haversine, Pearson,
186
+ BitwiseHamming, BitwiseTanimoto, BitwiseSorensen.
187
+ Not every kind is JIT-able. For Jaccard distance, use `SetsIndex`.
156
188
 
157
189
  :param dtype: Scalar type for internal vector storage, defaults to None
158
190
  :type dtype: str, optional
@@ -319,7 +351,7 @@ class Index:
319
351
  return Matches(*tuple_)
320
352
 
321
353
  def __len__(self) -> int:
322
- return len(self._compiled)
354
+ return self._compiled.__len__()
323
355
 
324
356
  @property
325
357
  def jit(self) -> bool:
@@ -333,6 +365,10 @@ class Index:
333
365
  def ndim(self) -> int:
334
366
  return self._compiled.ndim
335
367
 
368
+ @property
369
+ def metric(self) -> MetricKind:
370
+ return self._metric_kind
371
+
336
372
  @property
337
373
  def dtype(self) -> str:
338
374
  return self._compiled.dtype
@@ -358,11 +394,11 @@ class Index:
358
394
  return self._compiled.expansion_search
359
395
 
360
396
  @expansion_add.setter
361
- def change_expansion_add(self, v: int):
397
+ def expansion_add(self, v: int):
362
398
  self._compiled.expansion_add = v
363
399
 
364
400
  @expansion_search.setter
365
- def change_expansion_search(self, v: int):
401
+ def expansion_search(self, v: int):
366
402
  self._compiled.expansion_search = v
367
403
 
368
404
  def save(self, path: os.PathLike):
@@ -377,5 +413,15 @@ class Index:
377
413
  def clear(self):
378
414
  self._compiled.clear()
379
415
 
380
- def remove(self, label: int):
381
- pass
416
+ @property
417
+ def labels(self) -> np.ndarray:
418
+ return self._compiled.labels
419
+
420
+ def __delitem__(self, label: int):
421
+ raise NotImplementedError()
422
+
423
+ def __contains__(self, label: int) -> bool:
424
+ return self._compiled.__contains__(label)
425
+
426
+ def __getitem__(self, label: int) -> np.ndarray:
427
+ return self._compiled.__getitem__(label)
@@ -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,31 @@
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_members(index: Index, *args, **kwargs) -> float:
26
+ vectors: np.ndarray = np.vstack([
27
+ index[i] for i in range(len(index))
28
+ ])
29
+ labels: np.ndarray = np.arange(vectors.shape[0])
30
+ matches: Matches = index.search(vectors, 1, *args, **kwargs)
31
+ return matches.recall_first(labels)
package/src/punned.hpp CHANGED
@@ -33,6 +33,14 @@
33
33
  namespace unum {
34
34
  namespace usearch {
35
35
 
36
+ enum class common_scalar_kind_t {
37
+ f64_k,
38
+ f32_k,
39
+ f16_k,
40
+ f8_k,
41
+ b1_k,
42
+ };
43
+
36
44
  using byte_t = char;
37
45
  using punned_distance_t = float;
38
46
  using punned_metric_t = punned_distance_t (*)(byte_t const*, byte_t const*, std::size_t, std::size_t);
@@ -527,6 +535,17 @@ class punned_gt {
527
535
  return typed_->memory_usage(allocator_entry_bytes);
528
536
  }
529
537
 
538
+ bool contains(label_t label) const {
539
+ shared_lock_t lock(lookup_table_mutex_);
540
+ return lookup_table_.contains(label);
541
+ }
542
+
543
+ void export_labels(label_t* labels, std::size_t limit) const {
544
+ shared_lock_t lock(lookup_table_mutex_);
545
+ for (auto it = lookup_table_.begin(); it != lookup_table_.end() && limit; ++it, ++labels, --limit)
546
+ *labels = it->first;
547
+ }
548
+
530
549
  // clang-format off
531
550
  add_result_t add(label_t label, f8_bits_t const* vector) { return add_(label, vector, casts_.from_f8); }
532
551
  add_result_t add(label_t label, f16_bits_t const* vector) { return add_(label, vector, casts_.from_f16); }