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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usearch",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Smaller & Faster Single-File Vector Search Engine from Unum",
5
5
  "author": "Ashot Vardanian",
6
6
  "license": "Apache 2.0",
@@ -0,0 +1,283 @@
1
+ # USearch for Python
2
+
3
+ ## Installation
4
+
5
+ ```sh
6
+ pip install usearch
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ ```python
12
+ import numpy as np
13
+ from usearch.index import Index
14
+
15
+ index = Index(
16
+ ndim=3, # Define the number of dimensions in input vectors
17
+ metric='cos', # Choose 'l2sq', 'haversine' or other metric, default = 'ip'
18
+ dtype='f32', # Quantize to 'f16' or 'f8' if needed, default = 'f32'
19
+ connectivity=16, # How frequent should the connections in the graph be, optional
20
+ expansion_add=128, # Control the recall of indexing, optional
21
+ expansion_search=64, # Control the quality of search, optional
22
+ )
23
+
24
+ vector = np.array([0.2, 0.6, 0.4])
25
+ index.add(42, vector)
26
+ matches, distances, count = index.search(vector, 10)
27
+
28
+ assert len(index) == 1
29
+ assert count == 1
30
+ assert matches[0] == 42
31
+ assert distances[0] <= 0.001
32
+ assert np.allclose(index[42], vector)
33
+ ```
34
+
35
+ Python bindings are implemented with [`pybind/pybind11`](https://github.com/pybind/pybind11).
36
+ Assuming the presence of Global Interpreter Lock in Python, we spawn threads in the C++ layer on large insertions.
37
+
38
+ ## Serialization
39
+
40
+ ```py
41
+ index.save('index.usearch')
42
+ index.load('index.usearch') # Copy the whole index into memory
43
+ index.view('index.usearch') # View from disk without loading in memory
44
+ ```
45
+
46
+ If you don't know anything about the index except its path, there are two more endpoints to know:
47
+
48
+ ```py
49
+ Index.metadata('index.usearch') -> IndexMetadata
50
+ Index.restore('index.usearch', view=False) -> Index
51
+ ```
52
+
53
+ ## Batch Operations
54
+
55
+ Adding or querying a batch of entries is identical to adding a single vector.
56
+ The difference would be in the shape of the tensors.
57
+
58
+ ```py
59
+ n = 100
60
+ labels = np.arange(n)
61
+ vectors = np.random.uniform(0, 0.3, (n, index.ndim)).astype(np.float32)
62
+
63
+ index.add(labels, vectors, threads=..., copy=...)
64
+ matches, distances, counts = index.search(vectors, 10, threads=...)
65
+
66
+ assert matches.shape[0] == vectors.shape[0]
67
+ assert counts[0] <= 10
68
+ ```
69
+
70
+ You can also override the default `threads` and `copy` arguments in bulk workloads.
71
+ The first controls the number of threads spawned for the task.
72
+ The second controls whether the vector itself will be persisted inside the index.
73
+ If you can preserve the lifetime of the vector somewhere else, you can avoid the copy.
74
+
75
+ ## User-Defined Metrics and JIT in Python
76
+
77
+ ### [Numba][numba]
78
+
79
+ Assuming the language boundary exists between Python user code and C++ implementation, there are more efficient solutions than passing a Python callable to the engine.
80
+ Luckily, with the help of [Numba][numba], we can JIT compile a function with a matching signature and pass it down to the engine.
81
+
82
+ ```py
83
+ from numba import cfunc, types, carray
84
+
85
+ ndim = 256
86
+ signature = types.float32(
87
+ types.CPointer(types.float32),
88
+ types.CPointer(types.float32))
89
+
90
+ @cfunc(signature)
91
+ def inner_product(a, b):
92
+ a_array = carray(a, ndim)
93
+ b_array = carray(b, ndim)
94
+ c = 0.0
95
+ for i in range(ndim):
96
+ c += a_array[i] * b_array[i]
97
+ return 1 - c
98
+
99
+ index = Index(ndim=ndim, metric=CompiledMetric(
100
+ pointer=inner_product.address,
101
+ kind=MetricKind.IP,
102
+ signature=MetricSignature.ArrayArray,
103
+ ))
104
+ ```
105
+
106
+ Alternatively, you can avoid pre-defining the number of dimensions, and pass it separately:
107
+
108
+ ```py
109
+ signature = types.float32(
110
+ types.CPointer(types.float32),
111
+ types.CPointer(types.float32),
112
+ types.uint64)
113
+
114
+ @cfunc(signature)
115
+ def inner_product(a, b, ndim):
116
+ a_array = carray(a, ndim)
117
+ b_array = carray(b, ndim)
118
+ c = 0.0
119
+ for i in range(ndim):
120
+ c += a_array[i] * b_array[i]
121
+ return 1 - c
122
+
123
+ index = Index(ndim=ndim, metric=CompiledMetric(
124
+ pointer=inner_product.address,
125
+ kind=MetricKind.IP,
126
+ signature=MetricSignature.ArrayArraySize,
127
+ ))
128
+ ```
129
+
130
+ ```sh
131
+ pip install numba
132
+ ```
133
+
134
+ ### [Cppyy][cppyy]
135
+
136
+ Similarly, you can use Cppyy with Cling to JIT-compile native C or C++ code and pass it to USearch, which may be a good idea, if you want to explicitly request loop-unrolling or other low-level optimizations!
137
+
138
+ ```py
139
+ import cppyy
140
+ import cppyy.ll
141
+
142
+ cppyy.cppdef("""
143
+ float inner_product(float *a, float *b) {
144
+ float result = 0;
145
+ #pragma unroll
146
+ for (size_t i = 0; i != ndim; ++i)
147
+ result += a[i] * b[i];
148
+ return 1 - result;
149
+ }
150
+ """.replace("ndim", str(ndim)))
151
+
152
+ function = cppyy.gbl.inner_product
153
+ index = Index(ndim=ndim, metric=CompiledMetric(
154
+ pointer=cppyy.ll.addressof(function),
155
+ kind=MetricKind.IP,
156
+ signature=MetricSignature.ArrayArraySize,
157
+ ))
158
+ ```
159
+
160
+ ```sh
161
+ conda install -c conda-forge cppyy
162
+ ```
163
+
164
+ ### [PeachPy][peachpy]
165
+
166
+ We have covered JIT-ing Python with Numba and C++ with Cppyy and Cling.
167
+ How about writing Assembly directly?
168
+ That is also possible.
169
+ Below is an example of constructing the "Inner Product" distance for 8-dimensional `f32` vectors for x86 using [PeachPy][peachpy].
170
+
171
+ ```py
172
+ from peachpy import (
173
+ Argument,
174
+ ptr,
175
+ float_,
176
+ const_float_,
177
+ )
178
+ from peachpy.x86_64 import (
179
+ abi,
180
+ Function,
181
+ uarch,
182
+ isa,
183
+ GeneralPurposeRegister64,
184
+ LOAD,
185
+ YMMRegister,
186
+ VSUBPS,
187
+ VADDPS,
188
+ VHADDPS,
189
+ VMOVUPS,
190
+ VFMADD231PS,
191
+ VPERM2F128,
192
+ VXORPS,
193
+ RETURN,
194
+ )
195
+
196
+ a = Argument(ptr(const_float_), name="a")
197
+ b = Argument(ptr(const_float_), name="b")
198
+
199
+ with Function(
200
+ "inner_product", (a, b), float_, target=uarch.default + isa.avx2
201
+ ) as asm_function:
202
+
203
+ # Request two 64-bit general-purpose registers for addresses
204
+ reg_a, reg_b = GeneralPurposeRegister64(), GeneralPurposeRegister64()
205
+ LOAD.ARGUMENT(reg_a, a)
206
+ LOAD.ARGUMENT(reg_b, b)
207
+
208
+ # Load the vectors
209
+ ymm_a = YMMRegister()
210
+ ymm_b = YMMRegister()
211
+ VMOVUPS(ymm_a, [reg_a])
212
+ VMOVUPS(ymm_b, [reg_b])
213
+
214
+ # Prepare the accumulator
215
+ ymm_c = YMMRegister()
216
+ ymm_one = YMMRegister()
217
+ VXORPS(ymm_c, ymm_c, ymm_c)
218
+ VXORPS(ymm_one, ymm_one, ymm_one)
219
+
220
+ # Accumulate A and B product into C
221
+ VFMADD231PS(ymm_c, ymm_a, ymm_b)
222
+
223
+ # Reduce the contents of a YMM register
224
+ ymm_c_permuted = YMMRegister()
225
+ VPERM2F128(ymm_c_permuted, ymm_c, ymm_c, 1)
226
+ VADDPS(ymm_c, ymm_c, ymm_c_permuted)
227
+ VHADDPS(ymm_c, ymm_c, ymm_c)
228
+ VHADDPS(ymm_c, ymm_c, ymm_c)
229
+
230
+ # Negate the values, to go from "similarity" to "distance"
231
+ VSUBPS(ymm_c, ymm_one, ymm_c)
232
+
233
+ # A common convention is to return floats in XMM registers
234
+ RETURN(ymm_c.as_xmm)
235
+
236
+ python_function = asm_function.finalize(abi.detect()).encode().load()
237
+ metric = CompiledMetric(
238
+ pointer=python_function.loader.code_address,
239
+ kind=MetricKind.IP,
240
+ signature=MetricSignature.ArrayArray,
241
+ )
242
+ index = Index(ndim=ndim, metric=metric)
243
+ ```
244
+
245
+ [numba]: https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
246
+ [cppyy]: https://cppyy.readthedocs.io/en/latest/
247
+ [peachpy]: https://github.com/Maratyszcza/PeachPy
248
+
249
+ ## Tooling
250
+
251
+ To work with `bbin`, `fbin`, `ibin`, `hbin` matrix files USearch provides `load_matrix` and `save_matrix`.
252
+ Such files are standard in k-ANN tasks and represent a binary object with all the scalars, prepended by two 32-bit integers - the number of rows and columns in the matrix.
253
+
254
+ ```py
255
+ from usearch.index import Index
256
+ from usearch.io import load_matrix, save_matrix
257
+
258
+ vectors = load_matrix('deep1B.fbin')
259
+ index = Index(ndim=vectors.shape[1])
260
+ index.add(labels, vectors)
261
+ ```
262
+
263
+ One may often want to evaluate the quality of the constructed index before running in production.
264
+ The trivial way is to measure `recall@1` on the entries already present in the index.
265
+
266
+ ```py
267
+ from usearch.eval import recall_members
268
+
269
+ assert recall_members(index, exact=True) == 1
270
+ print(recall_members(index, exact=False))
271
+ ```
272
+
273
+ In case you have some ground-truth data for more than one entry, you compare search results against expected values:
274
+
275
+ ```py
276
+ from usearch.eval import relevance, dcg, ndcg, random_vectors
277
+
278
+ vectors = random_vectors(index=index)
279
+ matches_approximate = index.search(vectors)
280
+ matches_exact = index.search(vectors, exact=True)
281
+ relevance_scores = relevance(matches_exact, matches_approximate)
282
+ print(dcg(relevance_scores), ndcg(relevance_scores))
283
+ ```
package/python/lib.cpp CHANGED
@@ -15,9 +15,11 @@
15
15
  #include <limits> // `std::numeric_limits`
16
16
  #include <thread> // `std::thread`
17
17
 
18
+ #define _CRT_SECURE_NO_WARNINGS
18
19
  #define PY_SSIZE_T_CLEAN
19
20
  #include <pybind11/numpy.h>
20
21
  #include <pybind11/pybind11.h>
22
+ #include <pybind11/stl.h>
21
23
 
22
24
  #include <usearch/index_punned_dense.hpp>
23
25
 
@@ -44,6 +46,7 @@ using distance_t = punned_distance_t;
44
46
  using dense_index_t = punned_small_t;
45
47
  using dense_add_result_t = typename dense_index_t::add_result_t;
46
48
  using dense_search_result_t = typename dense_index_t::search_result_t;
49
+ using dense_labeling_result_t = typename dense_index_t::labeling_result_t;
47
50
 
48
51
  struct dense_index_py_t : public dense_index_t {
49
52
  using native_t = dense_index_t;
@@ -180,8 +183,8 @@ static void add_one_to_index(dense_index_py_t& index, label_t label, py::buffer
180
183
  if (vector_dimensions != static_cast<Py_ssize_t>(index.scalar_words()))
181
184
  throw std::invalid_argument("The number of vector dimensions doesn't match!");
182
185
 
183
- if (index.size() + 1 >= index.capacity())
184
- index.reserve(ceil2(index.size() + 1));
186
+ if (!index.reserve(ceil2(index.size() + 1)))
187
+ throw std::invalid_argument("Out of memory!");
185
188
 
186
189
  add_config_t config;
187
190
  config.store_vector = copy;
@@ -223,9 +226,6 @@ static void add_many_to_index( //
223
226
  dense_index_py_t& index, py::buffer labels, py::buffer vectors, //
224
227
  bool copy, std::size_t threads) {
225
228
 
226
- if (index.limits().threads_add < threads)
227
- throw std::invalid_argument("Can't use that many threads!");
228
-
229
229
  py::buffer_info labels_info = labels.request();
230
230
  py::buffer_info vectors_info = vectors.request();
231
231
 
@@ -247,8 +247,10 @@ static void add_many_to_index( //
247
247
  if (labels_count != vectors_count)
248
248
  throw std::invalid_argument("Number of labels and vectors must match!");
249
249
 
250
- if (index.size() + vectors_count >= index.capacity())
251
- index.reserve(ceil2(index.size() + vectors_count));
250
+ if (!threads)
251
+ threads = std::thread::hardware_concurrency();
252
+ if (!index.reserve(index_limits_t(ceil2(index.size() + vectors_count), threads)))
253
+ throw std::invalid_argument("Out of memory!");
252
254
 
253
255
  switch (numpy_string_to_kind(vectors_info.format)) {
254
256
  case scalar_kind_t::b1x8_k: add_typed_to_index<b1x8_t>(index, labels_info, vectors_info, copy, threads); break;
@@ -316,6 +318,11 @@ static void search_typed( //
316
318
  Py_ssize_t vectors_count = vectors_info.shape[0];
317
319
  char const* vectors_data = reinterpret_cast<char const*>(vectors_info.ptr);
318
320
 
321
+ if (!threads)
322
+ threads = std::thread::hardware_concurrency();
323
+ if (!index.reserve(index_limits_t(index.size(), threads)))
324
+ throw std::invalid_argument("Out of memory!");
325
+
319
326
  executor_default_t{threads}.execute_bulk(vectors_count, [&](std::size_t thread_idx, std::size_t task_idx) {
320
327
  search_config_t config;
321
328
  config.thread = thread_idx;
@@ -356,7 +363,6 @@ static py::tuple search_many_in_index( //
356
363
 
357
364
  Py_ssize_t vectors_count = vectors_info.shape[0];
358
365
  Py_ssize_t vectors_dimensions = vectors_info.shape[1];
359
- char const* vectors_data = reinterpret_cast<char const*>(vectors_info.ptr);
360
366
  if (vectors_dimensions != static_cast<Py_ssize_t>(index.scalar_words()))
361
367
  throw std::invalid_argument("The number of vector dimensions doesn't match!");
362
368
 
@@ -381,10 +387,48 @@ static py::tuple search_many_in_index( //
381
387
  return results;
382
388
  }
383
389
 
390
+ static std::unordered_map<label_t, label_t> join_index( //
391
+ dense_index_py_t const& a, dense_index_py_t const& b, //
392
+ std::size_t max_proposals, bool exact) {
393
+
394
+ std::unordered_map<label_t, label_t> a_to_b;
395
+ a_to_b.reserve((std::min)(a.size(), b.size()));
396
+
397
+ using join_result_t = typename dense_index_py_t::join_result_t;
398
+ join_config_t config;
399
+
400
+ config.max_proposals = max_proposals;
401
+ config.exact = exact;
402
+ config.expansion = (std::max)(a.expansion_search(), b.expansion_search());
403
+ std::size_t threads = (std::min)(a.limits().threads(), b.limits().threads());
404
+ executor_default_t executor{threads};
405
+ join_result_t result = dense_index_py_t::join( //
406
+ a, b, config, //
407
+ a_to_b, //
408
+ dummy_label_to_label_mapping_t{}, //
409
+ executor);
410
+ result.error.raise();
411
+ return a_to_b;
412
+ }
413
+
414
+ static dense_index_py_t copy_index(dense_index_py_t const& index) {
415
+
416
+ using copy_result_t = typename dense_index_py_t::copy_result_t;
417
+ copy_config_t config;
418
+
419
+ copy_result_t result = index.copy();
420
+ result.error.raise();
421
+ return std::move(result.index);
422
+ }
423
+
384
424
  // clang-format off
385
425
  template <typename index_at> void save_index(index_at const& index, std::string const& path) { index.save(path.c_str()).error.raise(); }
386
426
  template <typename index_at> void load_index(index_at& index, std::string const& path) { index.load(path.c_str()).error.raise(); }
387
427
  template <typename index_at> void view_index(index_at& index, std::string const& path) { index.view(path.c_str()).error.raise(); }
428
+ template <typename index_at> void clear_index(index_at& index) { index.clear(); }
429
+ template <typename index_at> std::size_t max_level(index_at const &index) { return index.max_level(); }
430
+ template <typename index_at> typename index_at::stats_t compute_stats(index_at const &index) { return index.stats(); }
431
+ template <typename index_at> typename index_at::stats_t compute_level_stats(index_at const &index, std::size_t level) { return index.stats(level); }
388
432
  // clang-format on
389
433
 
390
434
  template <typename internal_at, typename external_at = internal_at, typename index_at = void>
@@ -412,16 +456,17 @@ template <typename index_at> py::object get_member(index_at const& index, label_
412
456
  throw std::invalid_argument("Incompatible scalars in the query matrix!");
413
457
  }
414
458
 
415
- template <typename index_at> py::array_t<label_t> get_labels(index_at const& index, std::size_t limit) {
459
+ template <typename index_at>
460
+ py::array_t<label_t> get_labels(index_at const& index, std::size_t offset, std::size_t limit) {
416
461
  limit = std::min(index.size(), limit);
417
462
  py::array_t<label_t> result_py(static_cast<Py_ssize_t>(limit));
418
463
  auto result_py1d = result_py.template mutable_unchecked<1>();
419
- index.export_labels(&result_py1d(0), limit);
464
+ index.export_labels(&result_py1d(0), offset, limit);
420
465
  return result_py;
421
466
  }
422
467
 
423
468
  template <typename index_at> py::array_t<label_t> get_all_labels(index_at const& index) {
424
- return get_labels(index, index.size());
469
+ return get_labels(index, 0, index.size());
425
470
  }
426
471
 
427
472
  template <typename element_at> bool has_duplicates(element_at const* begin, element_at const* end) {
@@ -628,6 +673,48 @@ PYBIND11_MODULE(compiled, m) {
628
673
  py::arg("threads") = 0 //
629
674
  );
630
675
 
676
+ i.def(
677
+ "rename",
678
+ [](dense_index_py_t& index, label_t from, label_t to) -> bool {
679
+ dense_labeling_result_t result = index.rename(from, to);
680
+ result.error.raise();
681
+ return result.completed;
682
+ },
683
+ py::arg("from"), py::arg("to"));
684
+
685
+ i.def(
686
+ "remove",
687
+ [](dense_index_py_t& index, label_t label, bool compact, std::size_t threads) -> bool {
688
+ dense_labeling_result_t result = index.remove(label);
689
+ result.error.raise();
690
+
691
+ if (!threads)
692
+ threads = std::thread::hardware_concurrency();
693
+ if (!index.reserve(index_limits_t(index.size(), threads)))
694
+ throw std::invalid_argument("Out of memory!");
695
+
696
+ index.compact(executor_default_t{threads});
697
+ return result.completed;
698
+ },
699
+ py::arg("label"), py::arg("compact"), py::arg("threads"));
700
+
701
+ i.def(
702
+ "remove",
703
+ [](dense_index_py_t& index, std::vector<label_t> const& labels, bool compact,
704
+ std::size_t threads) -> std::size_t {
705
+ dense_labeling_result_t result = index.remove(labels.begin(), labels.end());
706
+ result.error.raise();
707
+
708
+ if (!threads)
709
+ threads = std::thread::hardware_concurrency();
710
+ if (!index.reserve(index_limits_t(index.size(), threads)))
711
+ throw std::invalid_argument("Out of memory!");
712
+
713
+ index.compact(executor_default_t{threads});
714
+ return result.completed;
715
+ },
716
+ py::arg("label"), py::arg("compact"), py::arg("threads"));
717
+
631
718
  i.def("__len__", &dense_index_py_t::size);
632
719
  i.def_property_readonly("size", &dense_index_py_t::size);
633
720
  i.def_property_readonly("ndim", &dense_index_py_t::dimensions);
@@ -643,14 +730,29 @@ PYBIND11_MODULE(compiled, m) {
643
730
  i.def_property("expansion_search", &dense_index_py_t::expansion_search, &dense_index_py_t::change_expansion_search);
644
731
 
645
732
  i.def_property_readonly("labels", &get_all_labels<dense_index_py_t>);
646
- i.def("get_labels", &get_labels<dense_index_py_t>, py::arg("limit") = std::numeric_limits<std::size_t>::max());
733
+ i.def("get_labels", &get_labels<dense_index_py_t>, py::arg("offset") = 0,
734
+ py::arg("limit") = std::numeric_limits<std::size_t>::max());
647
735
  i.def("__contains__", &dense_index_py_t::contains);
648
736
  i.def("__getitem__", &get_member<dense_index_py_t>, py::arg("label"), py::arg("dtype") = scalar_kind_t::f32_k);
649
737
 
650
738
  i.def("save", &save_index<dense_index_py_t>, py::arg("path"), py::call_guard<py::gil_scoped_release>());
651
739
  i.def("load", &load_index<dense_index_py_t>, py::arg("path"), py::call_guard<py::gil_scoped_release>());
652
740
  i.def("view", &view_index<dense_index_py_t>, py::arg("path"), py::call_guard<py::gil_scoped_release>());
653
- i.def("clear", &dense_index_py_t::clear, py::call_guard<py::gil_scoped_release>());
741
+ i.def("clear", &clear_index<dense_index_py_t>, py::call_guard<py::gil_scoped_release>());
742
+ i.def("copy", &copy_index, py::call_guard<py::gil_scoped_release>());
743
+ i.def("join", &join_index, py::arg("other"), py::arg("max_proposals") = 0, py::arg("exact") = false,
744
+ py::call_guard<py::gil_scoped_release>());
745
+
746
+ using punned_index_stats_t = typename dense_index_py_t::stats_t;
747
+ auto i_stats = py::class_<punned_index_stats_t>(m, "IndexStats");
748
+ i_stats.def_readonly("nodes", &punned_index_stats_t::nodes);
749
+ i_stats.def_readonly("edges", &punned_index_stats_t::edges);
750
+ i_stats.def_readonly("max_edges", &punned_index_stats_t::max_edges);
751
+ i_stats.def_readonly("allocated_bytes", &punned_index_stats_t::allocated_bytes);
752
+
753
+ i.def_property_readonly("max_level", &max_level<dense_index_py_t>);
754
+ i.def_property_readonly("levels_stats", &compute_stats<dense_index_py_t>);
755
+ i.def("level_stats", &compute_level_stats<dense_index_py_t>, py::arg("level"));
654
756
 
655
757
  auto si = py::class_<sparse_index_py_t>(m, "SparseIndex");
656
758
 
@@ -704,8 +806,8 @@ PYBIND11_MODULE(compiled, m) {
704
806
  si.def_property_readonly("connectivity", &sparse_index_py_t::connectivity);
705
807
  si.def_property_readonly("capacity", &sparse_index_py_t::capacity);
706
808
 
707
- si.def("save", &sparse_index_py_t::save, py::arg("path"));
708
- si.def("load", &sparse_index_py_t::load, py::arg("path"));
709
- si.def("view", &sparse_index_py_t::view, py::arg("path"));
809
+ si.def("save", &save_index<sparse_index_py_t>, py::arg("path"));
810
+ si.def("load", &load_index<sparse_index_py_t>, py::arg("path"));
811
+ si.def("view", &view_index<sparse_index_py_t>, py::arg("path"));
710
812
  si.def("clear", &sparse_index_py_t::clear);
711
813
  }
@@ -0,0 +1,155 @@
1
+ """
2
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/clip_images.fbin -P datasets/cc_3M/
3
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/clip_texts.fbin -P datasets/cc_3M/
4
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/images.fbin -P datasets/cc_3M/
5
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/texts.fbin -P datasets/cc_3M/
6
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/uform_english_images.fbin -P datasets/cc_3M/
7
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/uform_english_texts.fbin -P datasets/cc_3M/
8
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/clipbigg_images.fbin -P datasets/cc_3M/
9
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/clipbigg_texts.fbin -P datasets/cc_3M/
10
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-arxiv-2m/resolve/main/e5base_abstract.fbin -P datasets/arxiv_2M/
11
+ wget -nc https://huggingface.co/datasets/unum-cloud/ann-arxiv-2m/resolve/main/e5base_title.fbin -P datasets/arxiv_2M/
12
+ rm -rf datasets/cc_3M/*.usearch datasets/arxiv_2M/*.usearch
13
+ python python/scripts/join.py
14
+ """
15
+ from numpy import dot
16
+ from numpy.linalg import norm
17
+ from tqdm import tqdm
18
+ from simsimd import cos_f32x4_neon, to_int
19
+
20
+ from usearch.index import Index, MetricKind, CompiledMetric, MetricSignature
21
+ from usearch.io import load_matrix
22
+ from usearch.eval import measure_seconds
23
+
24
+ k = 10
25
+ exact = False
26
+ batch_size = 1024 * 4
27
+ max_elements = 1000000
28
+
29
+ a_name = "cc_3M/texts"
30
+ b_name = "cc_3M/images"
31
+
32
+ a_mat = load_matrix(f"datasets/{a_name}.fbin", view=True)
33
+ b_mat = load_matrix(f"datasets/{b_name}.fbin", view=True)
34
+
35
+ a_mat = a_mat[:max_elements]
36
+ b_mat = b_mat[:max_elements]
37
+
38
+ print(f"Loaded two datasets of shape: {a_mat.shape}, {b_mat.shape}")
39
+ print("--------------------------------------")
40
+ print("---------------Indexing---------------")
41
+ print("--------------------------------------")
42
+
43
+ metric = CompiledMetric(
44
+ pointer=to_int(cos_f32x4_neon),
45
+ kind=MetricKind.Cos,
46
+ signature=MetricSignature.ArrayArraySize,
47
+ )
48
+
49
+ a = Index(
50
+ a_mat.shape[1],
51
+ metric=metric,
52
+ path=f"datasets/{a_name}-{max_elements}.f32.usearch",
53
+ dtype="f32",
54
+ )
55
+ b = Index(
56
+ b_mat.shape[1],
57
+ metric=metric,
58
+ path=f"datasets/{b_name}-{max_elements}.f32.usearch",
59
+ dtype="f32",
60
+ )
61
+
62
+ if len(a) != a_mat.shape[0]:
63
+ a.clear()
64
+ a.add(None, a_mat, log=True, batch_size=batch_size)
65
+ a.save()
66
+
67
+ if len(b) != b_mat.shape[0]:
68
+ b.clear()
69
+ b.add(None, b_mat, log=True, batch_size=batch_size)
70
+ b.save()
71
+
72
+
73
+ print(
74
+ f"Loaded two indexes of size: {len(a):,} for {a_name} and {len(b):,} for {b_name}"
75
+ )
76
+ min_elements = min(len(a), len(b))
77
+
78
+ run_diagnostics = input("Would you like to run diagnostics? [Y/n]: ")
79
+ if len(run_diagnostics) == 0 or run_diagnostics.lower() == "y":
80
+ print("--------------------------------------")
81
+ print("-------------Diagnostics--------------")
82
+ print("--------------------------------------")
83
+
84
+ mean_similarity = 0.0
85
+ mean_recovered_similarity = 0.0
86
+
87
+ for i in tqdm(range(min_elements), desc="Pairwise Similarity"):
88
+ a_vec = a_mat[i]
89
+ b_vec = b_mat[i]
90
+ cos_similarity = dot(a_vec, b_vec) / (norm(a_vec) * norm(b_vec))
91
+ mean_similarity += cos_similarity
92
+
93
+ a_vec = a[i]
94
+ b_vec = b[i]
95
+ cos_similarity = dot(a_vec, b_vec) / (norm(a_vec) * norm(b_vec))
96
+ mean_recovered_similarity += cos_similarity
97
+
98
+ mean_similarity /= min_elements
99
+ mean_recovered_similarity /= min_elements
100
+ print(
101
+ f"Average vector similarity is {mean_similarity:.4f} in original dataset, "
102
+ f"and {mean_recovered_similarity:.4f} in recovered state in index"
103
+ )
104
+
105
+ dt = measure_seconds
106
+ args = dict(
107
+ k=k,
108
+ batch_size=batch_size,
109
+ log=True,
110
+ exact=exact,
111
+ )
112
+
113
+ secs, a_self_recall = dt(lambda: a.search(a.vectors, **args).recall(a.labels))
114
+ print(
115
+ "Self-recall @{} of {} index: {:.2f}%, took {:.2f}s".format(
116
+ k, a_name, a_self_recall * 100, secs
117
+ )
118
+ )
119
+
120
+ secs, b_self_recall = dt(lambda: b.search(b.vectors, **args).recall(b.labels))
121
+ print(
122
+ "Self-recall @{} of {} index: {:.2f}%, took {:.2f}s".format(
123
+ k, b_name, b_self_recall * 100, secs
124
+ )
125
+ )
126
+
127
+ secs, ab_recall = dt(lambda: b.search(a.vectors, **args).recall(b.labels))
128
+ print(
129
+ "Cross-recall @{} of {} in {}: {:.2f}%, took {:.2f}s".format(
130
+ k, a_name, b_name, ab_recall * 100, secs
131
+ )
132
+ )
133
+
134
+ secs, ba_recall = dt(lambda: a.search(b.vectors, **args).recall(a.labels))
135
+ print(
136
+ "Cross-recall @{} of {} in {}: {:.2f}%, took {:.2f}s".format(
137
+ k, b_name, a_name, ba_recall * 100, secs
138
+ )
139
+ )
140
+
141
+
142
+ print("--------------------------------------")
143
+ print("-----------------Join-----------------")
144
+ print("--------------------------------------")
145
+
146
+ secs, bimapping = measure_seconds(lambda: a.join(b, max_proposals=100))
147
+ mapping_size = len(bimapping)
148
+ recall = 0
149
+ for i, j in bimapping.items():
150
+ recall += i == j
151
+
152
+ recall *= 100.0 / min_elements
153
+ print(
154
+ f"Took {secs:.2f}s to find {mapping_size:,} pairings with {recall:.2f}% being exact"
155
+ )