usearch 2.23.0__cp314-cp314t-macosx_11_0_arm64.whl
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.
- usearch/__init__.py +152 -0
- usearch/client.py +120 -0
- usearch/compiled.cpython-314t-darwin.so +0 -0
- usearch/eval.py +512 -0
- usearch/index.py +1721 -0
- usearch/io.py +138 -0
- usearch/numba.py +110 -0
- usearch/py.typed +0 -0
- usearch/server.py +131 -0
- usearch-2.23.0.dist-info/METADATA +602 -0
- usearch-2.23.0.dist-info/RECORD +14 -0
- usearch-2.23.0.dist-info/WHEEL +6 -0
- usearch-2.23.0.dist-info/licenses/LICENSE +201 -0
- usearch-2.23.0.dist-info/top_level.txt +1 -0
usearch/io.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import struct
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def numpy_scalar_size(dtype) -> int:
|
|
9
|
+
return {
|
|
10
|
+
np.float64: 8,
|
|
11
|
+
np.int64: 8,
|
|
12
|
+
np.uint64: 8,
|
|
13
|
+
np.float32: 4,
|
|
14
|
+
np.int32: 4,
|
|
15
|
+
np.uint32: 4,
|
|
16
|
+
np.float16: 2,
|
|
17
|
+
np.int16: 2,
|
|
18
|
+
np.uint16: 2,
|
|
19
|
+
np.int8: 1,
|
|
20
|
+
np.uint8: 1,
|
|
21
|
+
}[dtype]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def guess_numpy_dtype_from_filename(filename) -> typing.Optional[type]:
|
|
25
|
+
if filename.endswith(".fbin"):
|
|
26
|
+
return np.float32
|
|
27
|
+
elif filename.endswith(".dbin"):
|
|
28
|
+
return np.float64
|
|
29
|
+
elif filename.endswith(".hbin"):
|
|
30
|
+
return np.float16
|
|
31
|
+
elif filename.endswith(".ibin"):
|
|
32
|
+
return np.int32
|
|
33
|
+
elif filename.endswith(".bbin"):
|
|
34
|
+
return np.uint8
|
|
35
|
+
elif filename.endswith(".i8bin"):
|
|
36
|
+
return np.int8
|
|
37
|
+
elif filename.endswith(".i32bin"):
|
|
38
|
+
return np.int32
|
|
39
|
+
elif filename.endswith(".f32bin"):
|
|
40
|
+
return np.float32
|
|
41
|
+
else:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_matrix(
|
|
46
|
+
filename: str,
|
|
47
|
+
start_row: int = 0,
|
|
48
|
+
count_rows: int = None,
|
|
49
|
+
view: bool = False,
|
|
50
|
+
dtype: typing.Optional[type] = None,
|
|
51
|
+
) -> typing.Optional[np.ndarray]:
|
|
52
|
+
"""Read *.ibin, *.bbib, *.hbin, *.fbin, *.dbin, *.i8bin, *.i32bin files with matrices.
|
|
53
|
+
|
|
54
|
+
:param filename: path to the matrix file
|
|
55
|
+
:param start_row: start reading vectors from this index
|
|
56
|
+
:param count_rows: number of vectors to read. If None, read all vectors
|
|
57
|
+
:param view: set to `True` to memory-map the file instead of loading to RAM
|
|
58
|
+
|
|
59
|
+
:return: parsed matrix
|
|
60
|
+
:rtype: numpy.ndarray
|
|
61
|
+
"""
|
|
62
|
+
if dtype is None:
|
|
63
|
+
dtype = guess_numpy_dtype_from_filename(filename)
|
|
64
|
+
if dtype is None:
|
|
65
|
+
raise Exception("Unknown file type")
|
|
66
|
+
scalar_size = numpy_scalar_size(dtype)
|
|
67
|
+
|
|
68
|
+
if not os.path.exists(filename):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
with open(filename, "rb") as f:
|
|
72
|
+
rows, cols = np.fromfile(f, count=2, dtype=np.int32).astype(np.uint64)
|
|
73
|
+
|
|
74
|
+
# Validate file size matches expected data size
|
|
75
|
+
f.seek(0, 2) # Go to end
|
|
76
|
+
file_size = f.tell()
|
|
77
|
+
expected_size = 8 + (rows * cols * scalar_size) # Header + data
|
|
78
|
+
|
|
79
|
+
if file_size != expected_size:
|
|
80
|
+
if file_size < expected_size:
|
|
81
|
+
raise ValueError(f"File {filename} is truncated. Expected {expected_size:,} bytes, got {file_size:,} bytes")
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"File {filename} is larger than expected. Expected {expected_size:,} bytes, got {file_size:,} bytes")
|
|
84
|
+
|
|
85
|
+
f.seek(8) # Back to start of data
|
|
86
|
+
rows = (rows - start_row) if count_rows is None else count_rows
|
|
87
|
+
row_offset = start_row * scalar_size * cols
|
|
88
|
+
|
|
89
|
+
if view:
|
|
90
|
+
return np.memmap(
|
|
91
|
+
f,
|
|
92
|
+
dtype=dtype,
|
|
93
|
+
mode="r",
|
|
94
|
+
offset=8 + row_offset,
|
|
95
|
+
shape=(rows, cols),
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
return np.fromfile(
|
|
99
|
+
f,
|
|
100
|
+
count=rows * cols,
|
|
101
|
+
dtype=dtype,
|
|
102
|
+
offset=row_offset,
|
|
103
|
+
).reshape(rows, cols)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_matrix(vectors: np.ndarray, filename: str):
|
|
107
|
+
"""Write *.ibin, *.bbib, *.hbin, *.fbin, *.dbin, *.i8bin, *.i32bin, *.f32bin files with matrices.
|
|
108
|
+
|
|
109
|
+
:param vectors: the matrix to serialize
|
|
110
|
+
:type vectors: numpy.ndarray
|
|
111
|
+
:param filename: path to the matrix file
|
|
112
|
+
:type filename: str
|
|
113
|
+
"""
|
|
114
|
+
if filename.endswith(".fbin"):
|
|
115
|
+
dtype = np.float32
|
|
116
|
+
elif filename.endswith(".dbin"):
|
|
117
|
+
dtype = np.float64
|
|
118
|
+
elif filename.endswith(".hbin"):
|
|
119
|
+
dtype = np.float16
|
|
120
|
+
elif filename.endswith(".ibin"):
|
|
121
|
+
dtype = np.int32
|
|
122
|
+
elif filename.endswith(".bbin"):
|
|
123
|
+
dtype = np.uint8
|
|
124
|
+
elif filename.endswith(".i8bin"):
|
|
125
|
+
dtype = np.int8
|
|
126
|
+
elif filename.endswith(".i32bin"):
|
|
127
|
+
dtype = np.int32
|
|
128
|
+
elif filename.endswith(".f32bin"):
|
|
129
|
+
dtype = np.float32
|
|
130
|
+
else:
|
|
131
|
+
dtype = vectors.dtype
|
|
132
|
+
|
|
133
|
+
assert len(vectors.shape) == 2, "Input array must have 2 dimensions"
|
|
134
|
+
with open(filename, "wb") as f:
|
|
135
|
+
count, dim = vectors.shape
|
|
136
|
+
f.write(struct.pack("<i", count))
|
|
137
|
+
f.write(struct.pack("<i", dim))
|
|
138
|
+
vectors.astype(dtype).flatten().tofile(f)
|
usearch/numba.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# The purpose of this file is to provide Pythonic wrapper on top
|
|
2
|
+
# the native precompiled CPython module. It improves compatibility
|
|
3
|
+
# Python tooling, linters, and static analyzers. It also embeds JIT
|
|
4
|
+
# into the primary `Index` class, connecting USearch with Numba.
|
|
5
|
+
from math import sqrt
|
|
6
|
+
|
|
7
|
+
from usearch.index import MetricKind, ScalarKind, MetricSignature, CompiledMetric
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def jit(
|
|
11
|
+
ndim: int,
|
|
12
|
+
metric: MetricKind = MetricKind.Cos,
|
|
13
|
+
dtype: ScalarKind = ScalarKind.F32,
|
|
14
|
+
) -> CompiledMetric:
|
|
15
|
+
"""JIT-compiles the metric for target hardware and number of dimensions.
|
|
16
|
+
|
|
17
|
+
This can result in up-to 3x performance difference on very large vectors
|
|
18
|
+
and very recent hardware, as the Python module is compiled with high
|
|
19
|
+
compatibility in mind and avoids very fancy assembly instructions.
|
|
20
|
+
|
|
21
|
+
Uses Numba `cfunc` functionality, annotating it with Numba `types` instead
|
|
22
|
+
of `ctypes` to support half-precision.
|
|
23
|
+
https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
|
|
24
|
+
"""
|
|
25
|
+
assert isinstance(metric, MetricKind)
|
|
26
|
+
assert isinstance(dtype, ScalarKind)
|
|
27
|
+
|
|
28
|
+
from numba import cfunc, types, carray
|
|
29
|
+
|
|
30
|
+
signature_i8args = types.float32(types.CPointer(types.int8), types.CPointer(types.int8))
|
|
31
|
+
signature_f16args = types.float32(types.CPointer(types.float16), types.CPointer(types.float16))
|
|
32
|
+
signature_f32args = types.float32(types.CPointer(types.float32), types.CPointer(types.float32))
|
|
33
|
+
signature_f64args = types.float32(types.CPointer(types.float64), types.CPointer(types.float64))
|
|
34
|
+
|
|
35
|
+
numba_supported_types = (
|
|
36
|
+
ScalarKind.I8,
|
|
37
|
+
# Half-precision is still unsupported
|
|
38
|
+
# https://github.com/numba/numba/issues/4402
|
|
39
|
+
# ScalarKind.F16: np.float16,
|
|
40
|
+
ScalarKind.F32,
|
|
41
|
+
ScalarKind.F64,
|
|
42
|
+
)
|
|
43
|
+
if dtype not in numba_supported_types:
|
|
44
|
+
return metric
|
|
45
|
+
|
|
46
|
+
scalar_kind_to_accumulator_type = {
|
|
47
|
+
ScalarKind.I8: types.int32,
|
|
48
|
+
ScalarKind.F16: types.float16,
|
|
49
|
+
ScalarKind.F32: types.float32,
|
|
50
|
+
ScalarKind.F64: types.float64,
|
|
51
|
+
}
|
|
52
|
+
accumulator = scalar_kind_to_accumulator_type[dtype]
|
|
53
|
+
|
|
54
|
+
def numba_ip(a, b):
|
|
55
|
+
a_array = carray(a, ndim)
|
|
56
|
+
b_array = carray(b, ndim)
|
|
57
|
+
ab = accumulator(0)
|
|
58
|
+
for i in range(ndim):
|
|
59
|
+
ab += a_array[i] * b_array[i]
|
|
60
|
+
return types.float32(1 - ab)
|
|
61
|
+
|
|
62
|
+
def numba_cos(a, b):
|
|
63
|
+
a_array = carray(a, ndim)
|
|
64
|
+
b_array = carray(b, ndim)
|
|
65
|
+
ab = accumulator(0)
|
|
66
|
+
a_sq = accumulator(0)
|
|
67
|
+
b_sq = accumulator(0)
|
|
68
|
+
for i in range(ndim):
|
|
69
|
+
ab += a_array[i] * b_array[i]
|
|
70
|
+
a_sq += a_array[i] * a_array[i]
|
|
71
|
+
b_sq += b_array[i] * b_array[i]
|
|
72
|
+
a_norm = sqrt(a_sq)
|
|
73
|
+
b_norm = sqrt(b_sq)
|
|
74
|
+
if a_norm == 0 and b_norm == 0:
|
|
75
|
+
return types.float32(0)
|
|
76
|
+
elif a_norm == 0 or b_norm == 0 or ab == 0:
|
|
77
|
+
return types.float32(1)
|
|
78
|
+
else:
|
|
79
|
+
return types.float32(1 - ab / (a_norm * b_norm))
|
|
80
|
+
|
|
81
|
+
def numba_l2sq(a, b):
|
|
82
|
+
a_array = carray(a, ndim)
|
|
83
|
+
b_array = carray(b, ndim)
|
|
84
|
+
ab_delta_sq = accumulator(0)
|
|
85
|
+
for i in range(ndim):
|
|
86
|
+
ab_delta_sq += (a_array[i] - b_array[i]) * (a_array[i] - b_array[i])
|
|
87
|
+
return types.float32(ab_delta_sq)
|
|
88
|
+
|
|
89
|
+
scalar_kind_to_signature = {
|
|
90
|
+
ScalarKind.I8: signature_i8args,
|
|
91
|
+
ScalarKind.F16: signature_f16args,
|
|
92
|
+
ScalarKind.F32: signature_f32args,
|
|
93
|
+
ScalarKind.F64: signature_f64args,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
metric_kind_to_function = {
|
|
97
|
+
MetricKind.IP: numba_ip,
|
|
98
|
+
MetricKind.Cos: numba_cos,
|
|
99
|
+
MetricKind.L2sq: numba_l2sq,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if dtype == ScalarKind.I8 and metric == MetricKind.IP:
|
|
103
|
+
metric = MetricKind.Cos
|
|
104
|
+
|
|
105
|
+
pointer = cfunc(scalar_kind_to_signature[dtype])(metric_kind_to_function[metric])
|
|
106
|
+
return CompiledMetric(
|
|
107
|
+
pointer=pointer.address,
|
|
108
|
+
kind=metric,
|
|
109
|
+
signature=MetricSignature.ArrayArray,
|
|
110
|
+
)
|
usearch/py.typed
ADDED
|
File without changes
|
usearch/server.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import argparse
|
|
6
|
+
import numpy as np
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from ucall.rich_posix import Server
|
|
10
|
+
from usearch.index import Index, Matches, Key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ascii_to_vector(string: str) -> np.ndarray:
|
|
14
|
+
"""
|
|
15
|
+
WARNING: A dirty performance hack!
|
|
16
|
+
Assuming the `i8` vectors in our implementations are just integers,
|
|
17
|
+
and generally contain scalars in the [0, 100] range, we can transmit
|
|
18
|
+
them as JSON-embedded strings. The only symbols we must avoid are
|
|
19
|
+
the double-quote '"' (code 22) and backslash '\' (code 60).
|
|
20
|
+
Printable ASCII characters are in [20, 126].
|
|
21
|
+
"""
|
|
22
|
+
vector = np.array(string, dtype=np.int8)
|
|
23
|
+
vector[vector == 124] = 60
|
|
24
|
+
vector -= 23
|
|
25
|
+
return vector
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def serve(
|
|
29
|
+
ndim_: int,
|
|
30
|
+
metric: str = "ip",
|
|
31
|
+
port: int = 8545,
|
|
32
|
+
threads: int = 1,
|
|
33
|
+
path: str = "index.usearch",
|
|
34
|
+
immutable: bool = False,
|
|
35
|
+
):
|
|
36
|
+
server = Server(port=port)
|
|
37
|
+
index = Index(ndim=ndim_, metric=metric)
|
|
38
|
+
|
|
39
|
+
if os.path.exists(path):
|
|
40
|
+
if immutable:
|
|
41
|
+
index.view(path)
|
|
42
|
+
else:
|
|
43
|
+
index.load(path)
|
|
44
|
+
|
|
45
|
+
@server
|
|
46
|
+
def size() -> int:
|
|
47
|
+
return len(index)
|
|
48
|
+
|
|
49
|
+
@server
|
|
50
|
+
def ndim() -> int:
|
|
51
|
+
return index.ndim
|
|
52
|
+
|
|
53
|
+
@server
|
|
54
|
+
def capacity() -> int:
|
|
55
|
+
return index.capacity()
|
|
56
|
+
|
|
57
|
+
@server
|
|
58
|
+
def connectivity() -> int:
|
|
59
|
+
return index.connectivity()
|
|
60
|
+
|
|
61
|
+
@server
|
|
62
|
+
def add_one(key: int, vector: np.ndarray):
|
|
63
|
+
print("adding", key, vector)
|
|
64
|
+
keys = np.array([key], dtype=Key)
|
|
65
|
+
vectors = vector.flatten().reshape(vector.shape[0], 1)
|
|
66
|
+
index.add(keys, vectors)
|
|
67
|
+
|
|
68
|
+
@server
|
|
69
|
+
def add_many(keys: np.ndarray, vectors: np.ndarray):
|
|
70
|
+
index.add(keys, vectors, threads=threads)
|
|
71
|
+
|
|
72
|
+
@server
|
|
73
|
+
def search_one(vector: np.ndarray, count: int) -> List[dict]:
|
|
74
|
+
print("search", vector, count)
|
|
75
|
+
vectors = vector.reshape(vector.shape[0], 1)
|
|
76
|
+
results: Matches = index.search(vectors, count)
|
|
77
|
+
return results.to_list()
|
|
78
|
+
|
|
79
|
+
@server
|
|
80
|
+
def search_many(vectors: np.ndarray, count: int) -> List[List[dict]]:
|
|
81
|
+
results: Matches = index.search(vectors, count)
|
|
82
|
+
return results.to_list()
|
|
83
|
+
|
|
84
|
+
@server
|
|
85
|
+
def add_ascii(key: int, string: str):
|
|
86
|
+
return add_one(key, _ascii_to_vector(string))
|
|
87
|
+
|
|
88
|
+
@server
|
|
89
|
+
def search_ascii(string: str, count: int):
|
|
90
|
+
return search_one(_ascii_to_vector(string), count)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
server.run()
|
|
94
|
+
except KeyboardInterrupt:
|
|
95
|
+
if not immutable:
|
|
96
|
+
index.save(path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
parser = argparse.ArgumentParser()
|
|
101
|
+
parser.add_argument("-v", "--verbose", help="log server activity")
|
|
102
|
+
parser.add_argument("--ndim", type=int, help="dimensionality of the vectors")
|
|
103
|
+
parser.add_argument("--immutable", type=bool, default=False, help="the index can not be updated")
|
|
104
|
+
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--metric",
|
|
107
|
+
type=str,
|
|
108
|
+
default="ip",
|
|
109
|
+
choices=["ip", "cos", "l2sq", "haversine"],
|
|
110
|
+
help="distance function to compare vectors",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"-p",
|
|
114
|
+
"--port",
|
|
115
|
+
type=int,
|
|
116
|
+
default=8545,
|
|
117
|
+
help="port to open for client connections",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument("-j", "--threads", type=int, default=1, help="number of CPU threads to use")
|
|
120
|
+
parser.add_argument("--path", type=str, default="index.usearch", help="where to store the index")
|
|
121
|
+
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
assert args.ndim is not None, "Define the number of dimensions!"
|
|
124
|
+
serve(
|
|
125
|
+
ndim_=args.ndim,
|
|
126
|
+
metric=args.metric,
|
|
127
|
+
threads=args.threads,
|
|
128
|
+
port=args.port,
|
|
129
|
+
path=args.path,
|
|
130
|
+
immutable=args.immutable,
|
|
131
|
+
)
|