kachedb 0.1.0__py3-none-any.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.
- kachedb/__init__.py +86 -0
- kachedb/_version.py +1 -0
- kachedb/async_client.py +588 -0
- kachedb/client.py +652 -0
- kachedb/connection.py +191 -0
- kachedb/descriptor.py +208 -0
- kachedb/dma.py +141 -0
- kachedb/exceptions.py +25 -0
- kachedb/integrations/__init__.py +17 -0
- kachedb/integrations/langchain.py +149 -0
- kachedb/integrations/llamaindex.py +89 -0
- kachedb/pipeline.py +371 -0
- kachedb/pool.py +294 -0
- kachedb/py.typed +0 -0
- kachedb/quantizer.py +73 -0
- kachedb/resp.py +252 -0
- kachedb/semantic/__init__.py +27 -0
- kachedb/semantic/cache.py +266 -0
- kachedb/semantic/embedders.py +261 -0
- kachedb/sglang/__init__.py +14 -0
- kachedb/sglang/connector.py +187 -0
- kachedb/sglang/radix_adapter.py +138 -0
- kachedb/tensor.py +206 -0
- kachedb/vllm/__init__.py +13 -0
- kachedb/vllm/connector.py +204 -0
- kachedb/vllm/dma.py +11 -0
- kachedb/vllm/prefix_cache.py +111 -0
- kachedb-0.1.0.dist-info/METADATA +309 -0
- kachedb-0.1.0.dist-info/RECORD +32 -0
- kachedb-0.1.0.dist-info/WHEEL +4 -0
- kachedb-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
- kachedb-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
kachedb/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KacheDB — Python client for the KacheDB zero-copy storage engine.
|
|
3
|
+
|
|
4
|
+
Install::
|
|
5
|
+
|
|
6
|
+
pip install kachedb
|
|
7
|
+
pip install kachedb[torch] # for PyTorch tensor support
|
|
8
|
+
|
|
9
|
+
Quickstart::
|
|
10
|
+
|
|
11
|
+
from kachedb import KacheClient
|
|
12
|
+
|
|
13
|
+
with KacheClient(host="127.0.0.1", port=6379) as client:
|
|
14
|
+
client.set("user:1", "alice", ex=3600)
|
|
15
|
+
print(client.get("user:1")) # b"alice"
|
|
16
|
+
|
|
17
|
+
Async::
|
|
18
|
+
|
|
19
|
+
from kachedb import AsyncKacheClient
|
|
20
|
+
|
|
21
|
+
async with AsyncKacheClient() as client:
|
|
22
|
+
await client.set("key", "value")
|
|
23
|
+
print(await client.get("key"))
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .async_client import AsyncKacheClient
|
|
28
|
+
from .client import KacheClient
|
|
29
|
+
from .connection import Connection
|
|
30
|
+
from .descriptor import (
|
|
31
|
+
TENSOR_DESCRIPTOR_MAGIC,
|
|
32
|
+
TensorBlockDescriptor,
|
|
33
|
+
TensorCodec,
|
|
34
|
+
TensorDType,
|
|
35
|
+
)
|
|
36
|
+
from .dma import KacheDBMemoryManager
|
|
37
|
+
from .exceptions import (
|
|
38
|
+
ConnectionError,
|
|
39
|
+
KacheDBError,
|
|
40
|
+
PoolExhaustedError,
|
|
41
|
+
ProtocolError,
|
|
42
|
+
ResponseError,
|
|
43
|
+
TimeoutError,
|
|
44
|
+
)
|
|
45
|
+
from .pipeline import AsyncPipeline, Pipeline
|
|
46
|
+
from .pool import AsyncConnectionPool, ConnectionPool
|
|
47
|
+
from .quantizer import sq8_decode, sq8_encode
|
|
48
|
+
from .semantic import AsyncSemanticCache, SearchResult, SemanticCache
|
|
49
|
+
from .sglang import KacheDBRadixAdapter, KacheDBSGLangConnector
|
|
50
|
+
from .tensor import attach_shm, detach_all, read_tensor, read_torch_tensor
|
|
51
|
+
from .vllm import KacheDBConnector, KacheDBPrefixCache
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"TENSOR_DESCRIPTOR_MAGIC",
|
|
55
|
+
"AsyncConnectionPool",
|
|
56
|
+
"AsyncKacheClient",
|
|
57
|
+
"AsyncPipeline",
|
|
58
|
+
"AsyncSemanticCache",
|
|
59
|
+
"Connection",
|
|
60
|
+
"ConnectionError",
|
|
61
|
+
"ConnectionPool",
|
|
62
|
+
"KacheClient",
|
|
63
|
+
"KacheDBConnector",
|
|
64
|
+
"KacheDBError",
|
|
65
|
+
"KacheDBMemoryManager",
|
|
66
|
+
"KacheDBPrefixCache",
|
|
67
|
+
"KacheDBRadixAdapter",
|
|
68
|
+
"KacheDBSGLangConnector",
|
|
69
|
+
"Pipeline",
|
|
70
|
+
"PoolExhaustedError",
|
|
71
|
+
"ProtocolError",
|
|
72
|
+
"ResponseError",
|
|
73
|
+
"SearchResult",
|
|
74
|
+
"SemanticCache",
|
|
75
|
+
"TensorBlockDescriptor",
|
|
76
|
+
"TensorCodec",
|
|
77
|
+
"TensorDType",
|
|
78
|
+
"TimeoutError",
|
|
79
|
+
"__version__",
|
|
80
|
+
"attach_shm",
|
|
81
|
+
"detach_all",
|
|
82
|
+
"read_tensor",
|
|
83
|
+
"read_torch_tensor",
|
|
84
|
+
"sq8_decode",
|
|
85
|
+
"sq8_encode",
|
|
86
|
+
]
|
kachedb/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
kachedb/async_client.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async KacheDB client using ``asyncio``.
|
|
3
|
+
|
|
4
|
+
Provides the same high-level API as :class:`~kachedb.client.KacheClient`
|
|
5
|
+
but uses non-blocking ``asyncio`` streams for use in async ML inference
|
|
6
|
+
pipelines (vLLM, SGLang, etc.).
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from kachedb import AsyncKacheClient
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
async with AsyncKacheClient() as client:
|
|
15
|
+
await client.set("user:1", "alice", ex=3600)
|
|
16
|
+
print(await client.get("user:1"))
|
|
17
|
+
|
|
18
|
+
asyncio.run(main())
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from .exceptions import ConnectionError
|
|
27
|
+
from .pipeline import AsyncPipeline
|
|
28
|
+
from .pool import AsyncConnectionPool
|
|
29
|
+
from .resp import AsyncRespReader, RespValue, encode_command
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import asyncio
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncKacheClient:
|
|
36
|
+
"""High-level async KacheDB client.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
host : str
|
|
41
|
+
Server hostname or IP address.
|
|
42
|
+
port : int
|
|
43
|
+
Server TCP port.
|
|
44
|
+
decode_responses : bool
|
|
45
|
+
If ``True``, decode byte responses to UTF-8 strings.
|
|
46
|
+
max_connections : int
|
|
47
|
+
Maximum async connection pool size.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
host: str = "127.0.0.1",
|
|
53
|
+
port: int = 6379,
|
|
54
|
+
*,
|
|
55
|
+
decode_responses: bool = False,
|
|
56
|
+
max_connections: int = 10,
|
|
57
|
+
password: str | None = None,
|
|
58
|
+
ssl: bool = False,
|
|
59
|
+
ssl_keyfile: str | None = None,
|
|
60
|
+
ssl_certfile: str | None = None,
|
|
61
|
+
ssl_ca_certs: str | None = None,
|
|
62
|
+
ssl_check_hostname: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.host = host
|
|
65
|
+
self.port = port
|
|
66
|
+
self.decode_responses = decode_responses
|
|
67
|
+
self.password = password
|
|
68
|
+
self.ssl = ssl
|
|
69
|
+
self._pool = AsyncConnectionPool(
|
|
70
|
+
host=host,
|
|
71
|
+
port=port,
|
|
72
|
+
max_connections=max_connections,
|
|
73
|
+
decode_responses=decode_responses,
|
|
74
|
+
password=password,
|
|
75
|
+
ssl=ssl,
|
|
76
|
+
ssl_keyfile=ssl_keyfile,
|
|
77
|
+
ssl_certfile=ssl_certfile,
|
|
78
|
+
ssl_ca_certs=ssl_ca_certs,
|
|
79
|
+
ssl_check_hostname=ssl_check_hostname,
|
|
80
|
+
)
|
|
81
|
+
self._reader: asyncio.StreamReader | None = None
|
|
82
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
83
|
+
self._resp_reader: AsyncRespReader | None = None
|
|
84
|
+
|
|
85
|
+
async def connect(self) -> AsyncKacheClient:
|
|
86
|
+
"""Establish a dedicated async connection."""
|
|
87
|
+
self._reader, self._writer, self._resp_reader = await self._pool.get_connection()
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
async def close(self) -> None:
|
|
91
|
+
"""Release the connection back to the pool."""
|
|
92
|
+
if self._writer is not None and self._reader is not None and self._resp_reader is not None:
|
|
93
|
+
await self._pool.release_connection(self._reader, self._writer, self._resp_reader)
|
|
94
|
+
self._reader = None
|
|
95
|
+
self._writer = None
|
|
96
|
+
self._resp_reader = None
|
|
97
|
+
|
|
98
|
+
async def disconnect_all(self) -> None:
|
|
99
|
+
"""Close all pooled connections."""
|
|
100
|
+
await self._pool.disconnect_all()
|
|
101
|
+
|
|
102
|
+
async def __aenter__(self) -> AsyncKacheClient:
|
|
103
|
+
return await self.connect()
|
|
104
|
+
|
|
105
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
106
|
+
await self.close()
|
|
107
|
+
|
|
108
|
+
# ── Internal Helpers ──────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async def _execute(self, *args: str | bytes) -> RespValue:
|
|
111
|
+
"""Execute a single command and return the response."""
|
|
112
|
+
if self._writer is None or self._resp_reader is None:
|
|
113
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
114
|
+
|
|
115
|
+
data = encode_command(list(args))
|
|
116
|
+
self._writer.write(data)
|
|
117
|
+
await self._writer.drain()
|
|
118
|
+
|
|
119
|
+
response = await self._resp_reader.read_response()
|
|
120
|
+
|
|
121
|
+
if self.decode_responses and isinstance(response, bytes):
|
|
122
|
+
return response.decode("utf-8")
|
|
123
|
+
|
|
124
|
+
return response
|
|
125
|
+
|
|
126
|
+
# ── Redis-Compatible Commands ─────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async def ping(self, message: str | None = None) -> str:
|
|
129
|
+
"""Send ``PING`` and return ``PONG`` or the echoed message."""
|
|
130
|
+
args: list[str | bytes] = ["PING"]
|
|
131
|
+
if message is not None:
|
|
132
|
+
args.append(message)
|
|
133
|
+
result = await self._execute(*args)
|
|
134
|
+
return str(result) if result is not None else "PONG"
|
|
135
|
+
|
|
136
|
+
async def get(self, key: str | bytes) -> bytes | str | None:
|
|
137
|
+
"""Retrieve the value for *key*."""
|
|
138
|
+
return await self._execute("GET", key) # type: ignore[return-value]
|
|
139
|
+
|
|
140
|
+
async def set(
|
|
141
|
+
self,
|
|
142
|
+
key: str | bytes,
|
|
143
|
+
value: str | bytes,
|
|
144
|
+
*,
|
|
145
|
+
ex: int | None = None,
|
|
146
|
+
px: int | None = None,
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""Store *value* under *key* with an optional TTL."""
|
|
149
|
+
args: list[str | bytes] = ["SET", key, value]
|
|
150
|
+
if ex is not None:
|
|
151
|
+
args.extend(["EX", str(ex)])
|
|
152
|
+
elif px is not None:
|
|
153
|
+
args.extend(["PX", str(px)])
|
|
154
|
+
result = await self._execute(*args)
|
|
155
|
+
return result == "OK"
|
|
156
|
+
|
|
157
|
+
async def mget(self, *keys: str | bytes) -> list[bytes | str | None]:
|
|
158
|
+
"""Batch-retrieve values for multiple keys."""
|
|
159
|
+
if not keys:
|
|
160
|
+
return []
|
|
161
|
+
result = await self._execute("MGET", *keys)
|
|
162
|
+
return result if isinstance(result, list) else [] # type: ignore[return-value]
|
|
163
|
+
|
|
164
|
+
async def delete(self, *keys: str | bytes) -> int:
|
|
165
|
+
"""Delete one or more keys."""
|
|
166
|
+
if not keys:
|
|
167
|
+
return 0
|
|
168
|
+
result = await self._execute("DEL", *keys)
|
|
169
|
+
return int(result) if isinstance(result, int) else 0
|
|
170
|
+
|
|
171
|
+
async def exists(self, *keys: str | bytes) -> int:
|
|
172
|
+
"""Check existence of one or more keys."""
|
|
173
|
+
if not keys:
|
|
174
|
+
return 0
|
|
175
|
+
result = await self._execute("EXISTS", *keys)
|
|
176
|
+
return int(result) if isinstance(result, int) else 0
|
|
177
|
+
|
|
178
|
+
async def mset(self, mapping: dict[str | bytes, str | bytes]) -> bool:
|
|
179
|
+
"""Set multiple keys to their respective values atomically."""
|
|
180
|
+
if not mapping:
|
|
181
|
+
return True
|
|
182
|
+
args: list[str | bytes] = ["MSET"]
|
|
183
|
+
for k, v in mapping.items():
|
|
184
|
+
args.extend([k, v])
|
|
185
|
+
result = await self._execute(*args)
|
|
186
|
+
return result == "OK"
|
|
187
|
+
|
|
188
|
+
async def incr(self, key: str | bytes, amount: int = 1) -> int:
|
|
189
|
+
"""Increment the integer value of *key* by *amount* (default 1)."""
|
|
190
|
+
if amount == 1:
|
|
191
|
+
result = await self._execute("INCR", key)
|
|
192
|
+
else:
|
|
193
|
+
result = await self._execute("INCRBY", key, str(amount))
|
|
194
|
+
return int(result) if isinstance(result, int) else 0
|
|
195
|
+
|
|
196
|
+
async def incrby(self, key: str | bytes, amount: int) -> int:
|
|
197
|
+
"""Increment the integer value of *key* by *amount*."""
|
|
198
|
+
return await self.incr(key, amount)
|
|
199
|
+
|
|
200
|
+
async def decr(self, key: str | bytes, amount: int = 1) -> int:
|
|
201
|
+
"""Decrement the integer value of *key* by *amount* (default 1)."""
|
|
202
|
+
if amount == 1:
|
|
203
|
+
result = await self._execute("DECR", key)
|
|
204
|
+
else:
|
|
205
|
+
result = await self._execute("DECRBY", key, str(amount))
|
|
206
|
+
return int(result) if isinstance(result, int) else 0
|
|
207
|
+
|
|
208
|
+
async def decrby(self, key: str | bytes, amount: int) -> int:
|
|
209
|
+
"""Decrement the integer value of *key* by *amount*."""
|
|
210
|
+
return await self.decr(key, amount)
|
|
211
|
+
|
|
212
|
+
async def append(self, key: str | bytes, value: str | bytes) -> int:
|
|
213
|
+
"""Append *value* to *key*. Returns the new byte length of the value."""
|
|
214
|
+
result = await self._execute("APPEND", key, value)
|
|
215
|
+
return int(result) if isinstance(result, int) else 0
|
|
216
|
+
|
|
217
|
+
async def strlen(self, key: str | bytes) -> int:
|
|
218
|
+
"""Return the byte length of the value stored at *key*."""
|
|
219
|
+
result = await self._execute("STRLEN", key)
|
|
220
|
+
return int(result) if isinstance(result, int) else 0
|
|
221
|
+
|
|
222
|
+
async def expire(self, key: str | bytes, seconds: int) -> bool:
|
|
223
|
+
"""Set a timeout on *key* in seconds."""
|
|
224
|
+
result = await self._execute("EXPIRE", key, str(seconds))
|
|
225
|
+
return result == 1
|
|
226
|
+
|
|
227
|
+
async def pexpire(self, key: str | bytes, milliseconds: int) -> bool:
|
|
228
|
+
"""Set a timeout on *key* in milliseconds."""
|
|
229
|
+
result = await self._execute("PEXPIRE", key, str(milliseconds))
|
|
230
|
+
return result == 1
|
|
231
|
+
|
|
232
|
+
async def expireat(self, key: str | bytes, timestamp: int) -> bool:
|
|
233
|
+
"""Set an expiration deadline on *key* as a Unix timestamp (seconds)."""
|
|
234
|
+
result = await self._execute("EXPIREAT", key, str(timestamp))
|
|
235
|
+
return result == 1
|
|
236
|
+
|
|
237
|
+
async def pexpireat(self, key: str | bytes, timestamp_ms: int) -> bool:
|
|
238
|
+
"""Set an expiration deadline on *key* as a Unix timestamp (milliseconds)."""
|
|
239
|
+
result = await self._execute("PEXPIREAT", key, str(timestamp_ms))
|
|
240
|
+
return result == 1
|
|
241
|
+
|
|
242
|
+
async def ttl(self, key: str | bytes) -> int:
|
|
243
|
+
"""Return remaining time-to-live in seconds (-1 if no TTL, -2 if missing)."""
|
|
244
|
+
result = await self._execute("TTL", key)
|
|
245
|
+
return int(result) if isinstance(result, int) else -2
|
|
246
|
+
|
|
247
|
+
async def pttl(self, key: str | bytes) -> int:
|
|
248
|
+
"""Return remaining time-to-live in milliseconds (-1 if no TTL, -2 if missing)."""
|
|
249
|
+
result = await self._execute("PTTL", key)
|
|
250
|
+
return int(result) if isinstance(result, int) else -2
|
|
251
|
+
|
|
252
|
+
async def persist(self, key: str | bytes) -> bool:
|
|
253
|
+
"""Remove the existing timeout on *key*, persisting it indefinitely."""
|
|
254
|
+
result = await self._execute("PERSIST", key)
|
|
255
|
+
return result == 1
|
|
256
|
+
|
|
257
|
+
async def info(self, section: str | None = None) -> str:
|
|
258
|
+
"""Return server information and runtime statistics."""
|
|
259
|
+
args: list[str | bytes] = ["INFO"]
|
|
260
|
+
if section is not None:
|
|
261
|
+
args.append(section)
|
|
262
|
+
result = await self._execute(*args)
|
|
263
|
+
if isinstance(result, bytes):
|
|
264
|
+
return result.decode("utf-8", errors="replace")
|
|
265
|
+
return str(result) if result is not None else ""
|
|
266
|
+
|
|
267
|
+
async def dbsize(self) -> int:
|
|
268
|
+
"""Return the total number of keys stored in the database."""
|
|
269
|
+
result = await self._execute("DBSIZE")
|
|
270
|
+
return int(result) if isinstance(result, int) else 0
|
|
271
|
+
|
|
272
|
+
async def type(self, key: str | bytes) -> str:
|
|
273
|
+
"""Return the string representation of the type of the value stored at *key*."""
|
|
274
|
+
result = await self._execute("TYPE", key)
|
|
275
|
+
if isinstance(result, bytes):
|
|
276
|
+
return result.decode("utf-8", errors="replace")
|
|
277
|
+
return str(result) if result is not None else "none"
|
|
278
|
+
|
|
279
|
+
async def flushdb(self) -> bool:
|
|
280
|
+
"""Delete all keys in the current database."""
|
|
281
|
+
result = await self._execute("FLUSHDB")
|
|
282
|
+
return result in ("OK", b"OK", True)
|
|
283
|
+
|
|
284
|
+
async def flushall(self) -> bool:
|
|
285
|
+
"""Delete all keys in all databases."""
|
|
286
|
+
result = await self._execute("FLUSHALL")
|
|
287
|
+
return result in ("OK", b"OK", True)
|
|
288
|
+
|
|
289
|
+
# ── Pipeline ──────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
def pipeline(self) -> AsyncPipeline:
|
|
292
|
+
"""Create an async pipeline for batching commands."""
|
|
293
|
+
if self._writer is None or self._resp_reader is None:
|
|
294
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
295
|
+
return AsyncPipeline(self._writer, self._resp_reader)
|
|
296
|
+
|
|
297
|
+
# ── Vector Search & Semantic Cache Commands ───────────────────────────
|
|
298
|
+
|
|
299
|
+
async def vadd(
|
|
300
|
+
self,
|
|
301
|
+
index: str | bytes,
|
|
302
|
+
item_id: str | bytes,
|
|
303
|
+
vector: bytes | list[float] | tuple[float, ...],
|
|
304
|
+
*,
|
|
305
|
+
payload: str | bytes | None = None,
|
|
306
|
+
ex: int | None = None,
|
|
307
|
+
) -> bool:
|
|
308
|
+
"""Store a vector embedding in a named vector index asynchronously."""
|
|
309
|
+
import struct
|
|
310
|
+
|
|
311
|
+
if isinstance(vector, (list, tuple)):
|
|
312
|
+
dim = len(vector)
|
|
313
|
+
vector_bytes = struct.pack(f"<{dim}f", *vector)
|
|
314
|
+
elif isinstance(vector, (bytes, bytearray)):
|
|
315
|
+
vector_bytes = bytes(vector)
|
|
316
|
+
dim = len(vector_bytes) // 4
|
|
317
|
+
else:
|
|
318
|
+
raise TypeError(f"Unsupported vector type: {type(vector)}")
|
|
319
|
+
|
|
320
|
+
args: list[str | bytes] = ["VADD", index, item_id, str(dim), vector_bytes]
|
|
321
|
+
if payload is not None:
|
|
322
|
+
args.extend(["PAYLOAD", payload])
|
|
323
|
+
if ex is not None:
|
|
324
|
+
args.extend(["EX", str(ex)])
|
|
325
|
+
|
|
326
|
+
result = await self._execute(*args)
|
|
327
|
+
return result == 1 or result == "OK"
|
|
328
|
+
|
|
329
|
+
async def vsearch(
|
|
330
|
+
self,
|
|
331
|
+
index: str | bytes,
|
|
332
|
+
query_vector: bytes | list[float] | tuple[float, ...],
|
|
333
|
+
*,
|
|
334
|
+
top_k: int = 1,
|
|
335
|
+
threshold: float = 0.0,
|
|
336
|
+
) -> list[tuple[str | bytes, float, str | bytes | None]]:
|
|
337
|
+
"""Search for nearest semantic vectors in a named index asynchronously."""
|
|
338
|
+
import struct
|
|
339
|
+
|
|
340
|
+
if isinstance(query_vector, (list, tuple)):
|
|
341
|
+
query_bytes = struct.pack(f"<{len(query_vector)}f", *query_vector)
|
|
342
|
+
elif isinstance(query_vector, (bytes, bytearray)):
|
|
343
|
+
query_bytes = bytes(query_vector)
|
|
344
|
+
else:
|
|
345
|
+
raise TypeError(f"Unsupported vector type: {type(query_vector)}")
|
|
346
|
+
|
|
347
|
+
args: list[str | bytes] = [
|
|
348
|
+
"VSEARCH",
|
|
349
|
+
index,
|
|
350
|
+
query_bytes,
|
|
351
|
+
"TOPK",
|
|
352
|
+
str(top_k),
|
|
353
|
+
"THRESHOLD",
|
|
354
|
+
str(threshold),
|
|
355
|
+
]
|
|
356
|
+
raw_results = await self._execute(*args)
|
|
357
|
+
if not isinstance(raw_results, list):
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
results: list[tuple[str | bytes, float, str | bytes | None]] = []
|
|
361
|
+
for item in raw_results:
|
|
362
|
+
if isinstance(item, list) and len(item) >= 2:
|
|
363
|
+
raw_id = item[0]
|
|
364
|
+
item_id: str | bytes = raw_id if isinstance(raw_id, (str, bytes)) else str(raw_id)
|
|
365
|
+
raw_score = item[1]
|
|
366
|
+
try:
|
|
367
|
+
if isinstance(raw_score, bytes):
|
|
368
|
+
score = float(raw_score.decode())
|
|
369
|
+
elif isinstance(raw_score, (int, float, str)):
|
|
370
|
+
score = float(raw_score)
|
|
371
|
+
else:
|
|
372
|
+
score = 0.0
|
|
373
|
+
except Exception:
|
|
374
|
+
score = 0.0
|
|
375
|
+
raw_payload = item[2] if len(item) > 2 else None
|
|
376
|
+
payload: str | bytes | None = (
|
|
377
|
+
raw_payload
|
|
378
|
+
if isinstance(raw_payload, (str, bytes)) or raw_payload is None
|
|
379
|
+
else str(raw_payload)
|
|
380
|
+
)
|
|
381
|
+
results.append((item_id, score, payload))
|
|
382
|
+
return results
|
|
383
|
+
|
|
384
|
+
async def vdel(self, index: str | bytes, item_id: str | bytes) -> bool:
|
|
385
|
+
"""Delete a vector from a named index asynchronously."""
|
|
386
|
+
result = await self._execute("VDEL", index, item_id)
|
|
387
|
+
return result == 1
|
|
388
|
+
|
|
389
|
+
async def vadd_batch(
|
|
390
|
+
self,
|
|
391
|
+
index: str | bytes,
|
|
392
|
+
items: list[
|
|
393
|
+
tuple[str | bytes, bytes | list[float] | tuple[float, ...], str | bytes | None]
|
|
394
|
+
],
|
|
395
|
+
*,
|
|
396
|
+
ex: int | None = None,
|
|
397
|
+
) -> int:
|
|
398
|
+
"""Add multiple vector items in a single batch command asynchronously."""
|
|
399
|
+
if not items:
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
import struct
|
|
403
|
+
|
|
404
|
+
args: list[str | bytes] = ["VADD_BATCH", index]
|
|
405
|
+
for item_id, vector, payload in items:
|
|
406
|
+
if isinstance(vector, (list, tuple)):
|
|
407
|
+
dim = len(vector)
|
|
408
|
+
vector_bytes = struct.pack(f"<{dim}f", *vector)
|
|
409
|
+
elif isinstance(vector, (bytes, bytearray)):
|
|
410
|
+
vector_bytes = bytes(vector)
|
|
411
|
+
else:
|
|
412
|
+
raise TypeError(f"Unsupported vector type: {type(vector)}")
|
|
413
|
+
|
|
414
|
+
p_val = payload if payload is not None else "-"
|
|
415
|
+
args.extend([item_id, vector_bytes, p_val])
|
|
416
|
+
if ex is not None:
|
|
417
|
+
args.extend(["EX", str(ex)])
|
|
418
|
+
|
|
419
|
+
result = await self._execute(*args)
|
|
420
|
+
return int(result) if isinstance(result, int) else 0
|
|
421
|
+
|
|
422
|
+
async def vsearch_batch(
|
|
423
|
+
self,
|
|
424
|
+
index: str | bytes,
|
|
425
|
+
query_vectors: list[bytes | list[float] | tuple[float, ...]],
|
|
426
|
+
*,
|
|
427
|
+
top_k: int = 1,
|
|
428
|
+
threshold: float = 0.0,
|
|
429
|
+
) -> list[list[tuple[str | bytes, float, str | bytes | None]]]:
|
|
430
|
+
"""Search multiple query vectors in a single batch command asynchronously."""
|
|
431
|
+
if not query_vectors:
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
import struct
|
|
435
|
+
|
|
436
|
+
args: list[str | bytes] = ["VSEARCH_BATCH", index]
|
|
437
|
+
for q in query_vectors:
|
|
438
|
+
if isinstance(q, (list, tuple)):
|
|
439
|
+
q_bytes = struct.pack(f"<{len(q)}f", *q)
|
|
440
|
+
elif isinstance(q, (bytes, bytearray)):
|
|
441
|
+
q_bytes = bytes(q)
|
|
442
|
+
else:
|
|
443
|
+
raise TypeError(f"Unsupported vector type: {type(q)}")
|
|
444
|
+
args.append(q_bytes)
|
|
445
|
+
|
|
446
|
+
args.extend(["TOPK", str(top_k), "THRESHOLD", str(threshold)])
|
|
447
|
+
raw_batch = await self._execute(*args)
|
|
448
|
+
if not isinstance(raw_batch, list):
|
|
449
|
+
return []
|
|
450
|
+
|
|
451
|
+
all_results: list[list[tuple[str | bytes, float, str | bytes | None]]] = []
|
|
452
|
+
for raw_results in raw_batch:
|
|
453
|
+
if not isinstance(raw_results, list):
|
|
454
|
+
all_results.append([])
|
|
455
|
+
continue
|
|
456
|
+
query_res: list[tuple[str | bytes, float, str | bytes | None]] = []
|
|
457
|
+
for item in raw_results:
|
|
458
|
+
if isinstance(item, list) and len(item) >= 2:
|
|
459
|
+
raw_id = item[0]
|
|
460
|
+
item_id = raw_id if isinstance(raw_id, (str, bytes)) else str(raw_id)
|
|
461
|
+
raw_score = item[1]
|
|
462
|
+
try:
|
|
463
|
+
score = float(
|
|
464
|
+
raw_score.decode()
|
|
465
|
+
if isinstance(raw_score, bytes)
|
|
466
|
+
else float(str(raw_score))
|
|
467
|
+
)
|
|
468
|
+
except Exception:
|
|
469
|
+
score = 0.0
|
|
470
|
+
raw_payload = item[2] if len(item) > 2 else None
|
|
471
|
+
payload = (
|
|
472
|
+
raw_payload
|
|
473
|
+
if isinstance(raw_payload, (str, bytes)) or raw_payload is None
|
|
474
|
+
else str(raw_payload)
|
|
475
|
+
)
|
|
476
|
+
query_res.append((item_id, score, payload))
|
|
477
|
+
all_results.append(query_res)
|
|
478
|
+
return all_results
|
|
479
|
+
|
|
480
|
+
async def vstats(self, index: str | bytes) -> dict[str, Any] | None:
|
|
481
|
+
"""Get statistics for a named vector index asynchronously."""
|
|
482
|
+
raw = await self._execute("VSTATS", index)
|
|
483
|
+
if not isinstance(raw, list):
|
|
484
|
+
return None
|
|
485
|
+
stats: dict[str, Any] = {}
|
|
486
|
+
for i in range(0, len(raw) - 1, 2):
|
|
487
|
+
raw_k = raw[i]
|
|
488
|
+
k = raw_k.decode() if isinstance(raw_k, bytes) else str(raw_k)
|
|
489
|
+
v = raw[i + 1]
|
|
490
|
+
stats[k] = v
|
|
491
|
+
return stats
|
|
492
|
+
|
|
493
|
+
async def auth(self, password: str, username: str | None = None) -> bool:
|
|
494
|
+
"""Authenticate with the KacheDB server asynchronously.
|
|
495
|
+
|
|
496
|
+
Parameters
|
|
497
|
+
----------
|
|
498
|
+
password : str
|
|
499
|
+
The authentication password.
|
|
500
|
+
username : str | None
|
|
501
|
+
Optional ACL username.
|
|
502
|
+
"""
|
|
503
|
+
args: list[str | bytes] = ["AUTH"]
|
|
504
|
+
if username is not None:
|
|
505
|
+
args.append(username)
|
|
506
|
+
args.append(password)
|
|
507
|
+
result = await self._execute(*args)
|
|
508
|
+
return result == b"OK" or result == "OK"
|
|
509
|
+
|
|
510
|
+
async def bgrewriteaof(self) -> str:
|
|
511
|
+
"""Rewrite the append-only file in the background asynchronously."""
|
|
512
|
+
result = await self._execute("BGREWRITEAOF")
|
|
513
|
+
if isinstance(result, bytes):
|
|
514
|
+
return result.decode("utf-8", errors="replace")
|
|
515
|
+
return str(result)
|
|
516
|
+
|
|
517
|
+
async def vindex_create(
|
|
518
|
+
self,
|
|
519
|
+
name: str | bytes,
|
|
520
|
+
dim: int,
|
|
521
|
+
m: int = 16,
|
|
522
|
+
ef_construction: int = 200,
|
|
523
|
+
ef_search: int = 50,
|
|
524
|
+
metric: str = "COSINE",
|
|
525
|
+
quantization: str = "SQ8",
|
|
526
|
+
) -> bool:
|
|
527
|
+
"""Create a new HNSW vector index asynchronously.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
name : str | bytes
|
|
532
|
+
Index name.
|
|
533
|
+
dim : int
|
|
534
|
+
Vector dimension.
|
|
535
|
+
m : int
|
|
536
|
+
Max bidirectional links per node (default: 16).
|
|
537
|
+
ef_construction : int
|
|
538
|
+
Size of dynamic candidate list during build (default: 200).
|
|
539
|
+
ef_search : int
|
|
540
|
+
Size of dynamic candidate list during search (default: 50).
|
|
541
|
+
metric : str
|
|
542
|
+
Distance metric ("COSINE", "L2", "IP").
|
|
543
|
+
quantization : str
|
|
544
|
+
Quantization type ("SQ8" or "NONE").
|
|
545
|
+
"""
|
|
546
|
+
args: list[str | bytes] = [
|
|
547
|
+
"VINDEX",
|
|
548
|
+
"CREATE",
|
|
549
|
+
name,
|
|
550
|
+
"DIM",
|
|
551
|
+
str(dim),
|
|
552
|
+
"M",
|
|
553
|
+
str(m),
|
|
554
|
+
"EF_CONSTRUCTION",
|
|
555
|
+
str(ef_construction),
|
|
556
|
+
"EF_SEARCH",
|
|
557
|
+
str(ef_search),
|
|
558
|
+
"METRIC",
|
|
559
|
+
metric,
|
|
560
|
+
"QUANTIZATION",
|
|
561
|
+
quantization,
|
|
562
|
+
]
|
|
563
|
+
result = await self._execute(*args)
|
|
564
|
+
return result == b"OK" or result == "OK"
|
|
565
|
+
|
|
566
|
+
async def vindex_drop(self, name: str | bytes) -> bool:
|
|
567
|
+
"""Drop a vector index asynchronously.
|
|
568
|
+
|
|
569
|
+
Returns ``True`` if the index was found and deleted, ``False`` otherwise.
|
|
570
|
+
"""
|
|
571
|
+
result = await self._execute("VINDEX", "DROP", name)
|
|
572
|
+
return bool(result == 1)
|
|
573
|
+
|
|
574
|
+
async def vindex_info(self, name: str | bytes) -> dict[str, Any] | None:
|
|
575
|
+
"""Get detailed metadata and statistics for a vector index asynchronously."""
|
|
576
|
+
raw = await self._execute("VINDEX", "INFO", name)
|
|
577
|
+
if not isinstance(raw, list):
|
|
578
|
+
return None
|
|
579
|
+
info: dict[str, Any] = {}
|
|
580
|
+
for i in range(0, len(raw) - 1, 2):
|
|
581
|
+
raw_k = raw[i]
|
|
582
|
+
k = raw_k.decode() if isinstance(raw_k, bytes) else str(raw_k)
|
|
583
|
+
v = raw[i + 1]
|
|
584
|
+
if isinstance(v, bytes):
|
|
585
|
+
with contextlib.suppress(UnicodeDecodeError):
|
|
586
|
+
v = v.decode("utf-8")
|
|
587
|
+
info[k] = v
|
|
588
|
+
return info
|