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/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
+ )