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/.github/workflows/prerelease.yml +46 -0
- package/.github/workflows/release.yml +28 -0
- package/.github/workflows/update_version.sh +2 -1
- package/CMakeLists.txt +4 -0
- package/Cargo.toml +1 -1
- package/README.md +93 -585
- package/c/lib.cpp +19 -15
- package/c/usearch.h +26 -23
- package/cmake/emscripten.cmake +8 -0
- package/conanfile.py +1 -1
- package/cpp/CMakeLists.txt +6 -2
- package/cpp/README.md +88 -0
- package/cpp/bench.cpp +72 -44
- package/cpp/test.cpp +13 -7
- package/docs/benchmarks.md +14 -12
- package/docs/golang/index.rst +6 -0
- package/docs/index.rst +5 -2
- package/docs/python/reference.rst +9 -2
- package/docs/rust/index.rst +6 -0
- package/docs/swift/index.rst +6 -0
- package/golang/README.md +28 -0
- package/include/usearch/index.hpp +668 -123
- package/include/usearch/index_punned_dense.hpp +305 -93
- package/include/usearch/index_punned_helpers.hpp +28 -3
- package/java/README.md +22 -0
- package/javascript/README.md +27 -0
- package/javascript/lib.cpp +1 -1
- package/package.json +1 -1
- package/python/README.md +283 -0
- package/python/lib.cpp +118 -16
- package/python/scripts/join.py +155 -0
- package/python/scripts/test.py +45 -241
- package/python/scripts/test_jit.py +244 -0
- package/python/usearch/eval.py +11 -3
- package/python/usearch/index.py +207 -26
- package/rust/README.md +67 -0
- package/swift/README.md +20 -0
- package/wasm/CMakeLists.txt +15 -0
- package/wasm/lib.cpp +5 -0
- package/wasmer.toml +8 -0
- package/python/usearch/join.py +0 -98
package/package.json
CHANGED
package/python/README.md
ADDED
|
@@ -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
|
|
184
|
-
|
|
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 (
|
|
251
|
-
|
|
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>
|
|
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("
|
|
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
|
|
741
|
+
i.def("clear", &clear_index<dense_index_py_t>, py::call_guard<py::gil_scoped_release>());
|
|
742
|
+
i.def("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
|
|
708
|
-
si.def("load", &sparse_index_py_t
|
|
709
|
-
si.def("view", &sparse_index_py_t
|
|
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
|
+
)
|