usearch 0.21.0 → 0.22.1

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.
@@ -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,6 +15,7 @@
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>
@@ -45,6 +46,7 @@ using distance_t = punned_distance_t;
45
46
  using dense_index_t = punned_small_t;
46
47
  using dense_add_result_t = typename dense_index_t::add_result_t;
47
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;
48
50
 
49
51
  struct dense_index_py_t : public dense_index_t {
50
52
  using native_t = dense_index_t;
@@ -181,8 +183,8 @@ static void add_one_to_index(dense_index_py_t& index, label_t label, py::buffer
181
183
  if (vector_dimensions != static_cast<Py_ssize_t>(index.scalar_words()))
182
184
  throw std::invalid_argument("The number of vector dimensions doesn't match!");
183
185
 
184
- if (index.size() + 1 >= index.capacity())
185
- index.reserve(ceil2(index.size() + 1));
186
+ if (!index.reserve(ceil2(index.size() + 1)))
187
+ throw std::invalid_argument("Out of memory!");
186
188
 
187
189
  add_config_t config;
188
190
  config.store_vector = copy;
@@ -224,9 +226,6 @@ static void add_many_to_index( //
224
226
  dense_index_py_t& index, py::buffer labels, py::buffer vectors, //
225
227
  bool copy, std::size_t threads) {
226
228
 
227
- if (index.limits().threads_add < threads)
228
- throw std::invalid_argument("Can't use that many threads!");
229
-
230
229
  py::buffer_info labels_info = labels.request();
231
230
  py::buffer_info vectors_info = vectors.request();
232
231
 
@@ -248,8 +247,10 @@ static void add_many_to_index( //
248
247
  if (labels_count != vectors_count)
249
248
  throw std::invalid_argument("Number of labels and vectors must match!");
250
249
 
251
- if (index.size() + vectors_count >= index.capacity())
252
- 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!");
253
254
 
254
255
  switch (numpy_string_to_kind(vectors_info.format)) {
255
256
  case scalar_kind_t::b1x8_k: add_typed_to_index<b1x8_t>(index, labels_info, vectors_info, copy, threads); break;
@@ -317,6 +318,11 @@ static void search_typed( //
317
318
  Py_ssize_t vectors_count = vectors_info.shape[0];
318
319
  char const* vectors_data = reinterpret_cast<char const*>(vectors_info.ptr);
319
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
+
320
326
  executor_default_t{threads}.execute_bulk(vectors_count, [&](std::size_t thread_idx, std::size_t task_idx) {
321
327
  search_config_t config;
322
328
  config.thread = thread_idx;
@@ -450,16 +456,17 @@ template <typename index_at> py::object get_member(index_at const& index, label_
450
456
  throw std::invalid_argument("Incompatible scalars in the query matrix!");
451
457
  }
452
458
 
453
- 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) {
454
461
  limit = std::min(index.size(), limit);
455
462
  py::array_t<label_t> result_py(static_cast<Py_ssize_t>(limit));
456
463
  auto result_py1d = result_py.template mutable_unchecked<1>();
457
- index.export_labels(&result_py1d(0), limit);
464
+ index.export_labels(&result_py1d(0), offset, limit);
458
465
  return result_py;
459
466
  }
460
467
 
461
468
  template <typename index_at> py::array_t<label_t> get_all_labels(index_at const& index) {
462
- return get_labels(index, index.size());
469
+ return get_labels(index, 0, index.size());
463
470
  }
464
471
 
465
472
  template <typename element_at> bool has_duplicates(element_at const* begin, element_at const* end) {
@@ -666,6 +673,48 @@ PYBIND11_MODULE(compiled, m) {
666
673
  py::arg("threads") = 0 //
667
674
  );
668
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
+
669
718
  i.def("__len__", &dense_index_py_t::size);
670
719
  i.def_property_readonly("size", &dense_index_py_t::size);
671
720
  i.def_property_readonly("ndim", &dense_index_py_t::dimensions);
@@ -681,7 +730,8 @@ PYBIND11_MODULE(compiled, m) {
681
730
  i.def_property("expansion_search", &dense_index_py_t::expansion_search, &dense_index_py_t::change_expansion_search);
682
731
 
683
732
  i.def_property_readonly("labels", &get_all_labels<dense_index_py_t>);
684
- 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());
685
735
  i.def("__contains__", &dense_index_py_t::contains);
686
736
  i.def("__getitem__", &get_member<dense_index_py_t>, py::arg("label"), py::arg("dtype") = scalar_kind_t::f32_k);
687
737
 
@@ -5,7 +5,11 @@ wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/image
5
5
  wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/texts.fbin -P datasets/cc_3M/
6
6
  wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/uform_english_images.fbin -P datasets/cc_3M/
7
7
  wget -nc https://huggingface.co/datasets/unum-cloud/ann-cc-3m/resolve/main/uform_english_texts.fbin -P datasets/cc_3M/
8
- rm -rf datasets/cc_3M/*.usearch
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
9
13
  python python/scripts/join.py
10
14
  """
11
15
  from numpy import dot
@@ -45,13 +49,13 @@ metric = CompiledMetric(
45
49
  a = Index(
46
50
  a_mat.shape[1],
47
51
  metric=metric,
48
- path=f"datasets/{a_name}.f32.usearch",
52
+ path=f"datasets/{a_name}-{max_elements}.f32.usearch",
49
53
  dtype="f32",
50
54
  )
51
55
  b = Index(
52
56
  b_mat.shape[1],
53
57
  metric=metric,
54
- path=f"datasets/{b_name}.f32.usearch",
58
+ path=f"datasets/{b_name}-{max_elements}.f32.usearch",
55
59
  dtype="f32",
56
60
  )
57
61