usearch 0.4.0 → 0.8.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.
- package/.clang-tidy +60 -0
- package/.github/workflows/release.yml +42 -16
- package/.github/workflows/update_version.sh +1 -0
- package/.vscode/launch.json +22 -0
- package/.vscode/settings.json +5 -0
- package/.vscode/tasks.json +4 -4
- package/CITATION.cff +11 -0
- package/CMakeLists.txt +108 -95
- package/Cargo.toml +1 -1
- package/Dockerfile +14 -0
- package/README.md +8 -7
- package/VERSION +1 -1
- package/binding.gyp +2 -2
- package/build.rs +2 -2
- package/cmake/config.cmake.in +15 -0
- package/cmake/pkg-config.pc.in +4 -0
- package/cmake/usearchConfigVersion.cmake.in +20 -0
- package/conanfile.py +1 -1
- package/docs/benchmarks.md +11 -2
- package/docs/{Compilation.md → compilation.md} +54 -27
- package/include/usearch/usearch.hpp +664 -390
- package/java/cloud/unum/usearch/cloud_unum_usearch_Index.cpp +17 -16
- package/javascript/lib.cpp +13 -13
- package/javascript/test.js +1 -1
- package/objc/USearchObjective.mm +46 -29
- package/objc/include/USearchObjective.h +18 -26
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/python/lib.cpp +179 -120
- package/python/scripts/bench.py +188 -0
- package/python/scripts/bench_params.py +113 -0
- package/python/{test.py → scripts/test.py} +32 -13
- package/python/usearch/client.py +100 -14
- package/python/usearch/server.py +104 -23
- package/rust/lib.cpp +15 -14
- package/rust/lib.hpp +5 -5
- package/rust/lib.rs +3 -3
- package/setup.py +7 -0
- package/src/CMakeLists.txt +107 -0
- package/src/bench.cpp +45 -15
- package/src/{advanced.hpp → punned.hpp} +227 -199
- package/src/test.cpp +62 -0
- package/swift/Test.swift +1 -1
- package/wolfram/lib.cpp +14 -14
- package/python/bench.py +0 -146
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import numpy as np
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import fire
|
|
6
|
+
|
|
7
|
+
from usearch.index import Index
|
|
8
|
+
from usearch.io import load_matrix
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def measure(f) -> float:
|
|
12
|
+
a = time.time_ns()
|
|
13
|
+
result = f()
|
|
14
|
+
b = time.time_ns()
|
|
15
|
+
c = b - a
|
|
16
|
+
secs = c / (10 ** 9)
|
|
17
|
+
print(f'- Took: {secs:.2f} seconds')
|
|
18
|
+
return secs, result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bench_faiss(index, vectors: np.array, queries: np.array, neighbors: np.array):
|
|
22
|
+
dt, _ = measure(lambda: index.add(vectors))
|
|
23
|
+
print(f'- Performance: {vectors.shape[0]/dt:.2f} insertions/s')
|
|
24
|
+
|
|
25
|
+
dt, results = measure(lambda: index.search(queries, neighbors.shape[1]))
|
|
26
|
+
print(f'- Performance: {queries.shape[0]/dt:.2f} queries/s')
|
|
27
|
+
|
|
28
|
+
recall_at_one = 0.0
|
|
29
|
+
matches = results[1]
|
|
30
|
+
for i in range(queries.shape[0]):
|
|
31
|
+
recall_at_one += neighbors[i, 0] in matches[i, :]
|
|
32
|
+
recall_at_one /= queries.shape[0]
|
|
33
|
+
print(f'- Recall@1: {recall_at_one * 100:.2f} %')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bench_usearch(index, vectors: np.array, queries: np.array, neighbors: np.array):
|
|
37
|
+
labels = np.arange(vectors.shape[0], dtype=np.longlong)
|
|
38
|
+
assert len(index) == 0
|
|
39
|
+
dt, _ = measure(lambda: index.add(labels, vectors))
|
|
40
|
+
assert len(index) == vectors.shape[0]
|
|
41
|
+
print(f'- Performance: {vectors.shape[0]/dt:.2f} insertions/s')
|
|
42
|
+
|
|
43
|
+
dt, results = measure(lambda: index.search(queries, neighbors.shape[1]))
|
|
44
|
+
print(f'- Performance: {queries.shape[0]/dt:.2f} queries/s')
|
|
45
|
+
|
|
46
|
+
recall_at_one = 0.0
|
|
47
|
+
matches = results[0]
|
|
48
|
+
for i in range(queries.shape[0]):
|
|
49
|
+
recall_at_one += neighbors[i, 0] in matches[i, :]
|
|
50
|
+
recall_at_one /= queries.shape[0]
|
|
51
|
+
print(f'- Recall@1: {recall_at_one * 100:.2f} %')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(
|
|
55
|
+
vectors: str,
|
|
56
|
+
queries: str,
|
|
57
|
+
neighbors: str,
|
|
58
|
+
connectivity: int = 16,
|
|
59
|
+
expansion_add: int = 128,
|
|
60
|
+
expansion_search: int = 64,
|
|
61
|
+
):
|
|
62
|
+
|
|
63
|
+
vectors_mat = load_matrix(vectors)
|
|
64
|
+
queries_mat = load_matrix(queries)
|
|
65
|
+
neighbors_mat = load_matrix(neighbors)
|
|
66
|
+
dim = vectors_mat.shape[1]
|
|
67
|
+
|
|
68
|
+
print('USearch: HNSW')
|
|
69
|
+
index = Index(
|
|
70
|
+
ndim=dim,
|
|
71
|
+
expansion_add=expansion_add,
|
|
72
|
+
expansion_search=expansion_search,
|
|
73
|
+
connectivity=connectivity,
|
|
74
|
+
)
|
|
75
|
+
bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
|
|
76
|
+
|
|
77
|
+
print('USearch: HNSW + Half Precision')
|
|
78
|
+
index = Index(
|
|
79
|
+
ndim=dim,
|
|
80
|
+
dtype='f16',
|
|
81
|
+
expansion_add=expansion_add,
|
|
82
|
+
expansion_search=expansion_search,
|
|
83
|
+
connectivity=connectivity,
|
|
84
|
+
)
|
|
85
|
+
bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
|
|
86
|
+
|
|
87
|
+
print('USearch: HNSW + Quarter Precision')
|
|
88
|
+
index = Index(
|
|
89
|
+
ndim=dim,
|
|
90
|
+
dtype='f8',
|
|
91
|
+
expansion_add=expansion_add,
|
|
92
|
+
expansion_search=expansion_search,
|
|
93
|
+
connectivity=connectivity,
|
|
94
|
+
)
|
|
95
|
+
bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
|
|
96
|
+
|
|
97
|
+
# Showcases how to use Numba to JIT-compile similarity measures for USearch.
|
|
98
|
+
# https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
|
|
99
|
+
try:
|
|
100
|
+
from numba import cfunc, types, carray
|
|
101
|
+
|
|
102
|
+
signature_f32 = types.float32(
|
|
103
|
+
types.CPointer(types.float32),
|
|
104
|
+
types.CPointer(types.float32),
|
|
105
|
+
types.size_t, types.size_t)
|
|
106
|
+
|
|
107
|
+
@cfunc(signature_f32)
|
|
108
|
+
def inner_product_f32(a, b, n, m):
|
|
109
|
+
a_array = carray(a, n)
|
|
110
|
+
b_array = carray(b, n)
|
|
111
|
+
c = types.float32(0)
|
|
112
|
+
for i in range(n):
|
|
113
|
+
c += a_array[i] * b_array[i]
|
|
114
|
+
return types.float32(1 - c)
|
|
115
|
+
|
|
116
|
+
signature_f8 = types.float32(
|
|
117
|
+
types.CPointer(types.int8),
|
|
118
|
+
types.CPointer(types.int8),
|
|
119
|
+
types.size_t, types.size_t)
|
|
120
|
+
|
|
121
|
+
@cfunc(signature_f8)
|
|
122
|
+
def cos_f8(a, b, n, m):
|
|
123
|
+
a_array = carray(a, n)
|
|
124
|
+
b_array = carray(b, n)
|
|
125
|
+
c = types.int32(0)
|
|
126
|
+
a_sq = types.int32(0)
|
|
127
|
+
b_sq = types.int32(0)
|
|
128
|
+
for i in range(n):
|
|
129
|
+
ai = types.int16(a_array[i])
|
|
130
|
+
bi = types.int16(b_array[i])
|
|
131
|
+
c += ai * bi
|
|
132
|
+
a_sq += ai * ai
|
|
133
|
+
b_sq += bi * bi
|
|
134
|
+
a_norm = math.sqrt(types.float32(a_sq))
|
|
135
|
+
b_norm = math.sqrt(types.float32(b_sq))
|
|
136
|
+
c_norm = types.float32(c) / (a_norm * b_norm)
|
|
137
|
+
return types.float32(1 - c_norm)
|
|
138
|
+
|
|
139
|
+
print('USearch: HNSW + Numba JIT')
|
|
140
|
+
index = Index(
|
|
141
|
+
ndim=dim,
|
|
142
|
+
expansion_add=expansion_add,
|
|
143
|
+
expansion_search=expansion_search,
|
|
144
|
+
connectivity=connectivity,
|
|
145
|
+
metric_pointer=inner_product_f32.address,
|
|
146
|
+
)
|
|
147
|
+
bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
|
|
148
|
+
|
|
149
|
+
print('USearch: HNSW + Quarter Precision + Numba JIT')
|
|
150
|
+
index = Index(
|
|
151
|
+
ndim=dim,
|
|
152
|
+
dtype='f8',
|
|
153
|
+
expansion_add=expansion_add,
|
|
154
|
+
expansion_search=expansion_search,
|
|
155
|
+
connectivity=connectivity,
|
|
156
|
+
metric_pointer=cos_f8.address,
|
|
157
|
+
)
|
|
158
|
+
bench_usearch(index, vectors_mat, queries_mat, neighbors_mat)
|
|
159
|
+
except ImportError:
|
|
160
|
+
print('Skipping Numba benchmarks')
|
|
161
|
+
|
|
162
|
+
# Don't depend on the FAISS installation for benchmarks
|
|
163
|
+
try:
|
|
164
|
+
from faiss import IndexHNSWFlat, IndexIVFPQ
|
|
165
|
+
|
|
166
|
+
print('FAISS: HNSW')
|
|
167
|
+
index = IndexHNSWFlat(dim, connectivity)
|
|
168
|
+
index.hnsw.efSearch = expansion_search
|
|
169
|
+
index.hnsw.efConstruction = expansion_add
|
|
170
|
+
bench_faiss(index, vectors_mat, queries_mat, neighbors_mat)
|
|
171
|
+
|
|
172
|
+
print('FAISS: HNSW + IVFPQ')
|
|
173
|
+
# Number of inverted lists (number of partitions or cells).
|
|
174
|
+
nlist = 10000
|
|
175
|
+
nsegment = 16 # Number of segments for PQ (number of subquantizers).
|
|
176
|
+
nbit = 8 # Number of bits to encode each segment.
|
|
177
|
+
coarse_quantizer = IndexHNSWFlat(dim, connectivity)
|
|
178
|
+
index = IndexIVFPQ(coarse_quantizer, dim, nlist, nsegment, nbit)
|
|
179
|
+
index.train(vectors_mat)
|
|
180
|
+
index.nprobe = 10
|
|
181
|
+
bench_faiss(index, vectors_mat, queries_mat, neighbors_mat)
|
|
182
|
+
|
|
183
|
+
except ImportError:
|
|
184
|
+
print('Skipping FAISS benchmarks')
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == '__main__':
|
|
188
|
+
fire.Fire(main)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
import fire
|
|
6
|
+
import plotly.express as px
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from usearch.index import Index
|
|
12
|
+
from usearch.io import load_matrix
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def measure(f) -> float:
|
|
16
|
+
a = time.time_ns()
|
|
17
|
+
result = f()
|
|
18
|
+
b = time.time_ns()
|
|
19
|
+
c = b - a
|
|
20
|
+
secs = c / (10 ** 9)
|
|
21
|
+
print(f'- Took: {secs:.2f} seconds')
|
|
22
|
+
return secs, result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bench_usearch(index, vectors: np.ndarray) -> float:
|
|
26
|
+
labels = np.arange(vectors.shape[0], dtype=np.longlong)
|
|
27
|
+
assert len(index) == 0
|
|
28
|
+
dt, _ = measure(lambda: index.add(labels, vectors))
|
|
29
|
+
assert len(index) == vectors.shape[0]
|
|
30
|
+
insertions_per_second = vectors.shape[0]/dt
|
|
31
|
+
print(f'- Performance: {insertions_per_second:.2f} insertions/s')
|
|
32
|
+
return insertions_per_second
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Main:
|
|
36
|
+
|
|
37
|
+
def dimensions(
|
|
38
|
+
self,
|
|
39
|
+
count: int = 1_000_000,
|
|
40
|
+
connectivity: int = 16,
|
|
41
|
+
expansion_add: int = 128,
|
|
42
|
+
expansion_search: int = 64,
|
|
43
|
+
):
|
|
44
|
+
|
|
45
|
+
dimensions = [
|
|
46
|
+
2, 3, 4, 8, 16, 32, 96, 100,
|
|
47
|
+
256, 384, 512, 768, 1024, 1536]
|
|
48
|
+
results = []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
for ndim in dimensions:
|
|
52
|
+
vectors_mat = np.random.rand(count, ndim).astype(np.float32)
|
|
53
|
+
|
|
54
|
+
print(f'USearch for {ndim}-dimensional f32 vectors')
|
|
55
|
+
index = Index(
|
|
56
|
+
ndim=ndim,
|
|
57
|
+
expansion_add=expansion_add,
|
|
58
|
+
expansion_search=expansion_search,
|
|
59
|
+
connectivity=connectivity,
|
|
60
|
+
)
|
|
61
|
+
speed = bench_usearch(index, vectors_mat)
|
|
62
|
+
results.append({'ndim': ndim, 'speed': speed})
|
|
63
|
+
del index
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
df = pd.DataFrame(results)
|
|
68
|
+
fig = px.line(
|
|
69
|
+
df, x='ndim', y='speed', log_x=True,
|
|
70
|
+
title='HNSW Indexing Speed vs Vector Dimensions')
|
|
71
|
+
fig.write_image(os.path.join(os.path.dirname(
|
|
72
|
+
__file__), 'bench_dimensions.png'))
|
|
73
|
+
fig.show()
|
|
74
|
+
|
|
75
|
+
def connectivity(
|
|
76
|
+
self,
|
|
77
|
+
count: int = 1_000_000,
|
|
78
|
+
ndim: int = 256,
|
|
79
|
+
expansion_add: int = 128,
|
|
80
|
+
expansion_search: int = 64,
|
|
81
|
+
):
|
|
82
|
+
|
|
83
|
+
connectivities = range(10, 20)
|
|
84
|
+
results = []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
for connectivity in connectivities:
|
|
88
|
+
vectors_mat = np.random.rand(count, ndim).astype(np.float32)
|
|
89
|
+
|
|
90
|
+
print(f'USearch for {connectivity} connectivity')
|
|
91
|
+
index = Index(
|
|
92
|
+
ndim=ndim,
|
|
93
|
+
expansion_add=expansion_add,
|
|
94
|
+
expansion_search=expansion_search,
|
|
95
|
+
connectivity=connectivity,
|
|
96
|
+
)
|
|
97
|
+
speed = bench_usearch(index, vectors_mat)
|
|
98
|
+
results.append({'connectivity': connectivity, 'speed': speed})
|
|
99
|
+
del index
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
df = pd.DataFrame(results)
|
|
104
|
+
fig = px.line(
|
|
105
|
+
df, x='connectivity', y='speed', log_x=True,
|
|
106
|
+
title='HNSW Indexing Speed vs Connectivity')
|
|
107
|
+
fig.write_image(os.path.join(os.path.dirname(
|
|
108
|
+
__file__), 'bench_connectivity.png'))
|
|
109
|
+
fig.show()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == '__main__':
|
|
113
|
+
fire.Fire(Main)
|
|
@@ -1,37 +1,57 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
import numpy as np
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
from usearch.io import load_matrix
|
|
5
5
|
from usearch.index import Index, SetsIndex
|
|
6
6
|
|
|
7
7
|
dimensions = [13, 99, 100, 256]
|
|
8
8
|
sizes = [1, 2, 100]
|
|
9
|
-
|
|
9
|
+
index_types = ['f64', 'f32', 'f16', 'f8']
|
|
10
|
+
numpy_types = [np.float64, np.float32, np.float16, np.byte]
|
|
11
|
+
connectivities = [3, 13, 50]
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
@pytest.mark.parametrize('ndim', dimensions)
|
|
13
|
-
@pytest.mark.parametrize('
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
@pytest.mark.parametrize('index_type', index_types)
|
|
16
|
+
@pytest.mark.parametrize('numpy_type', numpy_types)
|
|
17
|
+
@pytest.mark.parametrize('connectivity', connectivities)
|
|
18
|
+
def test_l2sq(ndim: int, index_type: str, numpy_type: str, connectivity: int):
|
|
19
|
+
index = Index(
|
|
20
|
+
metric='l2sq',
|
|
21
|
+
ndim=ndim,
|
|
22
|
+
dtype=index_type,
|
|
23
|
+
connectivity=connectivity,
|
|
24
|
+
)
|
|
25
|
+
assert index.ndim == ndim
|
|
26
|
+
assert index.connectivity == connectivity
|
|
27
|
+
|
|
28
|
+
vector = np.random.uniform(0, 0.7, (index.ndim)).astype(numpy_type)
|
|
17
29
|
index.add(42, vector)
|
|
18
30
|
matches, distances, count = index.search(vector, 10)
|
|
19
31
|
|
|
32
|
+
assert len(index) == 1
|
|
20
33
|
assert len(matches) == count
|
|
21
34
|
assert len(distances) == count
|
|
22
35
|
assert count == 1
|
|
23
36
|
assert matches[0] == 42
|
|
24
37
|
assert distances[0] == pytest.approx(0, abs=1e-3)
|
|
25
38
|
|
|
39
|
+
index.save('tmp.usearch')
|
|
40
|
+
index.clear()
|
|
41
|
+
assert len(index) == 0
|
|
42
|
+
|
|
43
|
+
index.load('tmp.usearch')
|
|
44
|
+
assert len(index) == 1
|
|
45
|
+
|
|
26
46
|
|
|
27
47
|
@pytest.mark.parametrize('ndim', dimensions)
|
|
28
48
|
@pytest.mark.parametrize('size', sizes)
|
|
29
|
-
@pytest.mark.parametrize('
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
@pytest.mark.parametrize('index_type', index_types)
|
|
50
|
+
@pytest.mark.parametrize('numpy_type', numpy_types)
|
|
51
|
+
def test_l2sq_batch(ndim: int, size: int, index_type: str, numpy_type: str):
|
|
52
|
+
index = Index(ndim=ndim, metric='l2sq', dtype=index_type)
|
|
32
53
|
|
|
33
|
-
vectors = np.random.uniform(
|
|
34
|
-
0, 0.3, (size, index.ndim)).astype(np.float32)
|
|
54
|
+
vectors = np.random.uniform(0, 0.7, (size, index.ndim)).astype(numpy_type)
|
|
35
55
|
labels = np.array(range(size), dtype=np.longlong)
|
|
36
56
|
|
|
37
57
|
index.add(labels, vectors)
|
|
@@ -84,8 +104,7 @@ def test_sets():
|
|
|
84
104
|
index = SetsIndex()
|
|
85
105
|
index.add(10, np.array([10, 12, 15], dtype=np.uint32))
|
|
86
106
|
index.add(11, np.array([11, 12, 15, 16], dtype=np.uint32))
|
|
87
|
-
results = index.search(np.array([12, 15], dtype=np.uint32),
|
|
88
|
-
|
|
107
|
+
# results = index.search(np.array([12, 15], dtype=np.uint32), 10)
|
|
89
108
|
# assert list(results) == [10, 11]
|
|
90
109
|
|
|
91
110
|
|
package/python/usearch/client.py
CHANGED
|
@@ -1,34 +1,120 @@
|
|
|
1
|
+
from typing import Union, Optional
|
|
2
|
+
|
|
1
3
|
import numpy as np
|
|
2
4
|
from ucall.client import Client
|
|
3
5
|
|
|
4
6
|
|
|
7
|
+
def _vector_to_ascii(vector: np.ndarray) -> Optional[str]:
|
|
8
|
+
if vector.dtype != np.int8 and vector.dtype != np.uint8 and vector.dtype != np.byte:
|
|
9
|
+
return None
|
|
10
|
+
if not np.all((vector >= 0) | (vector <= 100)):
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
# Let's map [0, 100] to the range from [23, 123],
|
|
14
|
+
# poking 60 and replacing with the 124.
|
|
15
|
+
vector += 23
|
|
16
|
+
vector[vector == 60] = 124
|
|
17
|
+
ascii = str(vector)
|
|
18
|
+
return ascii
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
class IndexClient:
|
|
6
22
|
|
|
7
23
|
def __init__(self, uri: str = '127.0.0.1', port: int = 8545, use_http: bool = True) -> None:
|
|
8
24
|
self.client = Client(uri=uri, port=port, use_http=use_http)
|
|
9
25
|
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
def add_one(self, label: int, vector: np.ndarray):
|
|
27
|
+
assert isinstance(label, int)
|
|
28
|
+
assert isinstance(vector, np.ndarray)
|
|
29
|
+
vector = vector.flatten()
|
|
30
|
+
ascii = _vector_to_ascii(vector)
|
|
31
|
+
if ascii:
|
|
32
|
+
self.client.add_ascii(label=label, string=ascii)
|
|
13
33
|
else:
|
|
14
|
-
|
|
34
|
+
self.client.add_one(label=label, vectors=vector)
|
|
35
|
+
|
|
36
|
+
def add_many(self, labels: np.ndarray, vectors: np.ndarray):
|
|
37
|
+
assert isinstance(labels, int)
|
|
38
|
+
assert isinstance(vectors, np.ndarray)
|
|
39
|
+
assert labels.ndim == 1 and vectors.ndim == 2
|
|
40
|
+
assert labels.shape[0] == vectors.shape[0]
|
|
41
|
+
self.client.add_many(labels=labels, vectors=vectors)
|
|
42
|
+
|
|
43
|
+
def add(self, labels: Union[np.ndarray, int], vectors: np.ndarray):
|
|
44
|
+
if isinstance(labels, int) or len(labels) == 1:
|
|
45
|
+
return self.add_one(labels, vectors)
|
|
46
|
+
else:
|
|
47
|
+
return self.add_many(labels, vectors)
|
|
48
|
+
|
|
49
|
+
def search_one(self, vector: np.ndarray, count: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
50
|
+
matches: list[dict] = []
|
|
51
|
+
vector = vector.flatten()
|
|
52
|
+
ascii = _vector_to_ascii(vector)
|
|
53
|
+
if ascii:
|
|
54
|
+
matches = self.client.search_ascii(string=ascii, count=count)
|
|
55
|
+
else:
|
|
56
|
+
matches = self.client.search_one(vector=vector, count=count)
|
|
57
|
+
|
|
58
|
+
print(matches.data)
|
|
59
|
+
matches = matches.json
|
|
60
|
+
|
|
61
|
+
labels = np.array((1, count), dtype=np.uint32)
|
|
62
|
+
distances = np.array((1, count), dtype=np.float32)
|
|
63
|
+
counts = np.array((1), dtype=np.uint32)
|
|
64
|
+
for col, result in enumerate(matches):
|
|
65
|
+
labels[0, col] = result['label']
|
|
66
|
+
distances[0, col] = result['distance']
|
|
67
|
+
counts[0] = len(matches)
|
|
15
68
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
69
|
+
return labels, distances, counts
|
|
70
|
+
|
|
71
|
+
def search_many(self, vectors: np.ndarray, count: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
72
|
+
batch_size: int = vectors.shape[0]
|
|
73
|
+
list_of_matches: list[list[dict]] = self.client.search_many(
|
|
74
|
+
vectors=vectors, count=count)
|
|
75
|
+
|
|
76
|
+
labels = np.array((batch_size, count), dtype=np.uint32)
|
|
77
|
+
distances = np.array((batch_size, count), dtype=np.float32)
|
|
78
|
+
counts = np.array((batch_size), dtype=np.uint32)
|
|
79
|
+
for row, matches in enumerate(list_of_matches):
|
|
80
|
+
for col, result in enumerate(matches):
|
|
81
|
+
labels[row, col] = result['label']
|
|
82
|
+
distances[row, col] = result['distance']
|
|
83
|
+
counts[row] = len(results)
|
|
84
|
+
|
|
85
|
+
return labels, distances, counts
|
|
86
|
+
|
|
87
|
+
def search(self, vectors: np.ndarray, count: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
88
|
+
if vectors.ndim == 1 or (vectors.ndim == 2 and vectors.shape[0] == 1):
|
|
89
|
+
return self.search_one(vectors, count)
|
|
90
|
+
else:
|
|
91
|
+
return self.search_many(vectors, count)
|
|
22
92
|
|
|
23
93
|
def __len__(self):
|
|
24
|
-
return self.client.size()
|
|
94
|
+
return self.client.size().json()
|
|
25
95
|
|
|
26
96
|
@property
|
|
27
97
|
def ndim(self):
|
|
28
|
-
return self.client.ndim()
|
|
98
|
+
return self.client.ndim().json()
|
|
29
99
|
|
|
30
100
|
def capacity(self):
|
|
31
|
-
return self.client.capacity()
|
|
101
|
+
return self.client.capacity().json()
|
|
32
102
|
|
|
33
103
|
def connectivity(self):
|
|
34
|
-
return self.client.connectivity()
|
|
104
|
+
return self.client.connectivity().json()
|
|
105
|
+
|
|
106
|
+
def load(self, path: str):
|
|
107
|
+
raise NotImplementedError()
|
|
108
|
+
|
|
109
|
+
def view(self, path: str):
|
|
110
|
+
raise NotImplementedError()
|
|
111
|
+
|
|
112
|
+
def save(self, path: str):
|
|
113
|
+
raise NotImplementedError()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == '__main__':
|
|
117
|
+
index = IndexClient()
|
|
118
|
+
index.add(42, np.array([0.4] * 256, dtype=np.float32))
|
|
119
|
+
results = index.search(np.array([0.4] * 256, dtype=np.float32), 10)
|
|
120
|
+
print(results)
|
package/python/usearch/server.py
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import os
|
|
5
|
+
import argparse
|
|
5
6
|
import numpy as np
|
|
6
|
-
from PIL import Image
|
|
7
7
|
|
|
8
|
+
from ucall.rich_posix import Server
|
|
9
|
+
from usearch.index import Index
|
|
8
10
|
|
|
9
|
-
def serve(ndim: int, metric: str = 'ip'):
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
def _results_to_json(results: tuple[np.ndarray, np.ndarray, np.ndarray], row: int) -> list[dict]:
|
|
13
|
+
count = results[2][row]
|
|
14
|
+
labels = results[0][row, :count]
|
|
15
|
+
distances = results[1][row, :count]
|
|
16
|
+
return [{'label': int(l), 'distance': float(d)} for l, d in zip(labels, distances)]
|
|
13
17
|
|
|
14
|
-
@server
|
|
15
|
-
def add_one(label: int, vector: np.array):
|
|
16
|
-
labels = np.array([label], dtype=np.longlong)
|
|
17
|
-
vectors = vector.reshape(vector.shape[0], 1)
|
|
18
|
-
index.add(labels, vectors, copy=True)
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
def _ascii_to_vector(string: str) -> np.ndarray:
|
|
20
|
+
"""
|
|
21
|
+
WARNING: A dirty performance hack!
|
|
22
|
+
Assuming the `f8` vectors in our implementations are just integers,
|
|
23
|
+
and generally contain scalars in the [0, 100] range, we can transmit
|
|
24
|
+
them as JSON-embedded strings. The only symbols we must avoid are
|
|
25
|
+
the double-quote '"' (code 22) and backslash '\' (code 60).
|
|
26
|
+
Printable ASCII characters are in [20, 126].
|
|
27
|
+
"""
|
|
28
|
+
vector = np.array(string, dtype=np.int8)
|
|
29
|
+
vector[vector == 124] = 60
|
|
30
|
+
vector -= 23
|
|
31
|
+
return vector
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
|
|
34
|
+
def serve(
|
|
35
|
+
ndim_: int, metric: str = 'ip',
|
|
36
|
+
port: int = 8545, threads: int = 1,
|
|
37
|
+
path: str = 'index.usearch', immutable: bool = False):
|
|
38
|
+
|
|
39
|
+
server = Server(port=port)
|
|
40
|
+
index = Index(ndim=ndim_, metric=metric)
|
|
41
|
+
|
|
42
|
+
if os.path.exists(path):
|
|
43
|
+
if immutable:
|
|
44
|
+
index.view(path)
|
|
45
|
+
else:
|
|
46
|
+
index.load(path)
|
|
30
47
|
|
|
31
48
|
@server
|
|
32
49
|
def size() -> int:
|
|
@@ -44,8 +61,72 @@ def serve(ndim: int, metric: str = 'ip'):
|
|
|
44
61
|
def connectivity() -> int:
|
|
45
62
|
return index.connectivity()
|
|
46
63
|
|
|
47
|
-
server
|
|
64
|
+
@server
|
|
65
|
+
def add_one(label: int, vector: np.ndarray):
|
|
66
|
+
print('adding', label, vector)
|
|
67
|
+
labels = np.array([label], dtype=np.longlong)
|
|
68
|
+
vectors = vector.flatten().reshape(vector.shape[0], 1)
|
|
69
|
+
index.add(labels, vectors)
|
|
70
|
+
|
|
71
|
+
@server
|
|
72
|
+
def add_many(labels: np.ndarray, vectors: np.ndarray):
|
|
73
|
+
labels = labels.astype(np.longlong)
|
|
74
|
+
index.add(labels, vectors, threads=threads)
|
|
75
|
+
|
|
76
|
+
@server
|
|
77
|
+
def search_one(vector: np.ndarray, count: int) -> list[dict]:
|
|
78
|
+
print('search', vector, count)
|
|
79
|
+
vectors = vector.reshape(vector.shape[0], 1)
|
|
80
|
+
results = index.search(vectors, count)
|
|
81
|
+
return _results_to_json(results, 0)
|
|
82
|
+
|
|
83
|
+
@server
|
|
84
|
+
def search_many(vectors: np.ndarray, count: int) -> list[list[dict]]:
|
|
85
|
+
results = index.search(vectors, count)
|
|
86
|
+
return [_results_to_json(results, i) for i in range(vectors.shape[0])]
|
|
87
|
+
|
|
88
|
+
@server
|
|
89
|
+
def add_ascii(label: int, string: str):
|
|
90
|
+
return add_one(label, _ascii_to_vector(string))
|
|
91
|
+
|
|
92
|
+
@server
|
|
93
|
+
def search_ascii(string: str, count: int):
|
|
94
|
+
return search_one(_ascii_to_vector(string), count)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
server.run()
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
if not immutable:
|
|
100
|
+
index.save(path)
|
|
48
101
|
|
|
49
102
|
|
|
50
103
|
if __name__ == '__main__':
|
|
51
|
-
|
|
104
|
+
|
|
105
|
+
parser = argparse.ArgumentParser()
|
|
106
|
+
parser.add_argument('-v', '--verbose', help='log server activity')
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
'--ndim', type=int,
|
|
109
|
+
help='dimensionality of the vectors')
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
'--immutable', type=bool, default=False,
|
|
112
|
+
help='the index can not be updated')
|
|
113
|
+
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
'--metric', type=str, default='ip', choices=['ip', 'cos', 'l2sq', 'haversine'],
|
|
116
|
+
help='distance function to compare vectors')
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
'-p', '--port', type=int, default=8545,
|
|
119
|
+
help='port to open for client connections')
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
'-j', '--threads', type=int, default=1,
|
|
122
|
+
help='number of CPU threads to use')
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
'--path', type=str, default='index.usearch',
|
|
125
|
+
help='where to store the index')
|
|
126
|
+
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
assert args.ndim is not None, 'Define the number of dimensions!'
|
|
129
|
+
serve(
|
|
130
|
+
ndim_=args.ndim, metric=args.metric,
|
|
131
|
+
threads=args.threads, port=args.port,
|
|
132
|
+
path=args.path, immutable=args.immutable)
|