asyncpg-vector 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/LICENSE +1 -1
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/PKG-INFO +1 -1
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector/__init__.py +144 -42
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/PKG-INFO +1 -1
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/SOURCES.txt +2 -1
- asyncpg_vector-0.1.1/test/test_vector.py +118 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/README.md +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector/py.typed +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/dependency_links.txt +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/requires.txt +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/top_level.txt +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/asyncpg_vector.egg-info/zip-safe +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/pyproject.toml +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/setup.cfg +0 -0
- {asyncpg_vector-0.1.0 → asyncpg_vector-0.1.1}/setup.py +0 -0
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
2
|
PostgreSQL vector support for asyncpg.
|
|
3
3
|
|
|
4
|
-
Registers data types `vector` and `
|
|
5
|
-
client `asyncpg`, and marshals vector data to and from PostgreSQL database tables.
|
|
4
|
+
Registers data types `vector`, `halfvec` and `sparsevec` from the PostgreSQL extension `vector` to the asynchronous
|
|
5
|
+
PostgreSQL client `asyncpg`, and marshals vector data to and from PostgreSQL database tables.
|
|
6
|
+
|
|
7
|
+
Copyright 2025-2026, Levente Hunyadi
|
|
6
8
|
|
|
7
9
|
:see: https://github.com/hunyadi/asyncpg_vector
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
import base64
|
|
11
|
-
import struct
|
|
12
13
|
import sys
|
|
13
14
|
from abc import ABC, abstractmethod
|
|
14
15
|
from collections.abc import Sequence
|
|
15
|
-
from struct import pack
|
|
16
|
+
from struct import pack, unpack
|
|
16
17
|
from typing import ClassVar
|
|
17
18
|
|
|
18
19
|
import asyncpg
|
|
@@ -27,9 +28,9 @@ if sys.version_info >= (3, 12):
|
|
|
27
28
|
else:
|
|
28
29
|
from typing_extensions import override
|
|
29
30
|
|
|
30
|
-
__version__ = "0.1.
|
|
31
|
+
__version__ = "0.1.1"
|
|
31
32
|
__author__ = "Levente Hunyadi"
|
|
32
|
-
__copyright__ = "Copyright 2025, Levente Hunyadi"
|
|
33
|
+
__copyright__ = "Copyright 2025-2026, Levente Hunyadi"
|
|
33
34
|
__license__ = "MIT"
|
|
34
35
|
__maintainer__ = "Levente Hunyadi"
|
|
35
36
|
__status__ = "Production"
|
|
@@ -38,32 +39,10 @@ __status__ = "Production"
|
|
|
38
39
|
class BasicVector(ABC):
|
|
39
40
|
"Base class for PostgreSQL vector types."
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
_data: bytes
|
|
44
|
-
|
|
45
|
-
def __init__(self, data: bytes) -> None:
|
|
46
|
-
self._data = data
|
|
47
|
-
|
|
48
|
-
def __eq__(self, value: object) -> bool:
|
|
49
|
-
if not isinstance(value, self.__class__):
|
|
50
|
-
return False
|
|
51
|
-
|
|
52
|
-
return self._data == value._data
|
|
53
|
-
|
|
54
|
-
def __repr__(self) -> str:
|
|
55
|
-
return f"{type(self).__name__}({self._data!r})"
|
|
56
|
-
|
|
42
|
+
@abstractmethod
|
|
57
43
|
def size(self) -> int:
|
|
58
44
|
"Number of vector dimensions."
|
|
59
45
|
|
|
60
|
-
return len(self._data) // self.bytes_per_item()
|
|
61
|
-
|
|
62
|
-
@classmethod
|
|
63
|
-
@abstractmethod
|
|
64
|
-
def bytes_per_item(cls) -> int:
|
|
65
|
-
"Number of bytes per item in the vector."
|
|
66
|
-
|
|
67
46
|
...
|
|
68
47
|
|
|
69
48
|
@abstractmethod
|
|
@@ -88,22 +67,21 @@ class BasicVector(ABC):
|
|
|
88
67
|
"""
|
|
89
68
|
|
|
90
69
|
decoded_bytes = base64.b64decode(base64_encoded)
|
|
91
|
-
float_tuple =
|
|
70
|
+
float_tuple = unpack(f"{len(decoded_bytes) // 4}f", decoded_bytes)
|
|
92
71
|
return cls.from_float_list(float_tuple)
|
|
93
72
|
|
|
73
|
+
@abstractmethod
|
|
94
74
|
def to_database_binary(self) -> bytes:
|
|
95
75
|
"Writes the data into the PostgreSQL data transfer representation."
|
|
96
76
|
|
|
97
|
-
|
|
77
|
+
...
|
|
98
78
|
|
|
99
79
|
@classmethod
|
|
80
|
+
@abstractmethod
|
|
100
81
|
def from_database_binary(cls, data: bytes) -> Self:
|
|
101
82
|
"Reads the data from the PostgreSQL data transfer representation."
|
|
102
83
|
|
|
103
|
-
|
|
104
|
-
if len(data) != 4 + cls.bytes_per_item() * size:
|
|
105
|
-
raise ValueError(f"expected size: {cls.bytes_per_item()} * {size}; got {len(data) - 4} bytes")
|
|
106
|
-
return cls(data[4:])
|
|
84
|
+
...
|
|
107
85
|
|
|
108
86
|
@classmethod
|
|
109
87
|
def _to_database_binary(cls, value: Self | list[float] | None) -> bytes | None:
|
|
@@ -123,7 +101,7 @@ class BasicVector(ABC):
|
|
|
123
101
|
|
|
124
102
|
obj = cls.from_float_list(value)
|
|
125
103
|
else:
|
|
126
|
-
obj = cls(
|
|
104
|
+
obj = cls()
|
|
127
105
|
case _:
|
|
128
106
|
raise ValueError(f"unsupported type: {type(value).__name__}")
|
|
129
107
|
|
|
@@ -139,7 +117,56 @@ class BasicVector(ABC):
|
|
|
139
117
|
return cls.from_database_binary(value)
|
|
140
118
|
|
|
141
119
|
|
|
142
|
-
class
|
|
120
|
+
class DenseVector(BasicVector):
|
|
121
|
+
"Base class for PostgreSQL dense vector types."
|
|
122
|
+
|
|
123
|
+
__slots__ = ("_data",)
|
|
124
|
+
|
|
125
|
+
_data: bytes
|
|
126
|
+
|
|
127
|
+
def __init__(self, data: bytes | None = None) -> None:
|
|
128
|
+
if data is not None:
|
|
129
|
+
self._data = data
|
|
130
|
+
else:
|
|
131
|
+
self._data = bytes()
|
|
132
|
+
|
|
133
|
+
def __eq__(self, value: object) -> bool:
|
|
134
|
+
if not isinstance(value, self.__class__):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
return self._data == value._data
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
return f"{type(self).__name__}(size={self.size()})"
|
|
141
|
+
|
|
142
|
+
def __repr__(self) -> str:
|
|
143
|
+
return f"{type(self).__name__}({self._data!r})"
|
|
144
|
+
|
|
145
|
+
@override
|
|
146
|
+
def size(self) -> int:
|
|
147
|
+
return len(self._data) // self.bytes_per_item()
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def bytes_per_item(cls) -> int:
|
|
152
|
+
"Number of bytes per item in the vector."
|
|
153
|
+
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def to_database_binary(self) -> bytes:
|
|
158
|
+
return pack(">HH", self.size(), 0) + self._data
|
|
159
|
+
|
|
160
|
+
@override
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_database_binary(cls, data: bytes) -> Self:
|
|
163
|
+
size, _unused = unpack(">HH", data[0:4])
|
|
164
|
+
if len(data) != 4 + cls.bytes_per_item() * size:
|
|
165
|
+
raise ValueError(f"expected size: {cls.bytes_per_item()} * {size}; got {len(data) - 4} bytes")
|
|
166
|
+
return cls(data[4:])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class HalfVector(DenseVector):
|
|
143
170
|
"Implements the PostgreSQL extension type `halfvec`."
|
|
144
171
|
|
|
145
172
|
type_name: ClassVar[str] = "halfvec"
|
|
@@ -152,15 +179,15 @@ class HalfVector(BasicVector):
|
|
|
152
179
|
|
|
153
180
|
@override
|
|
154
181
|
def to_float_list(self) -> list[float]:
|
|
155
|
-
return list(
|
|
182
|
+
return list(unpack(f">{self.size()}e", self._data))
|
|
156
183
|
|
|
157
184
|
@override
|
|
158
185
|
@classmethod
|
|
159
186
|
def from_float_list(cls, vec: Sequence[float]) -> Self:
|
|
160
|
-
return cls(
|
|
187
|
+
return cls(pack(f">{len(vec)}e", *vec))
|
|
161
188
|
|
|
162
189
|
|
|
163
|
-
class Vector(
|
|
190
|
+
class Vector(DenseVector):
|
|
164
191
|
"Implements the PostgreSQL extension type `vector`."
|
|
165
192
|
|
|
166
193
|
type_name: ClassVar[str] = "vector"
|
|
@@ -173,12 +200,12 @@ class Vector(BasicVector):
|
|
|
173
200
|
|
|
174
201
|
@override
|
|
175
202
|
def to_float_list(self) -> list[float]:
|
|
176
|
-
return list(
|
|
203
|
+
return list(unpack(f">{self.size()}f", self._data))
|
|
177
204
|
|
|
178
205
|
@override
|
|
179
206
|
@classmethod
|
|
180
207
|
def from_float_list(cls, vec: Sequence[float]) -> Self:
|
|
181
|
-
return cls(
|
|
208
|
+
return cls(pack(f">{len(vec)}f", *vec))
|
|
182
209
|
|
|
183
210
|
@override
|
|
184
211
|
@classmethod
|
|
@@ -186,8 +213,83 @@ class Vector(BasicVector):
|
|
|
186
213
|
return cls(base64.b64decode(base64_encoded))
|
|
187
214
|
|
|
188
215
|
|
|
216
|
+
class SparseVector(BasicVector):
|
|
217
|
+
"Implements the PostgreSQL extension type `sparsevec`."
|
|
218
|
+
|
|
219
|
+
__slots__ = ("_size", "_indices", "_values")
|
|
220
|
+
|
|
221
|
+
_size: int
|
|
222
|
+
_indices: bytes
|
|
223
|
+
_values: bytes
|
|
224
|
+
|
|
225
|
+
type_name: ClassVar[str] = "sparsevec"
|
|
226
|
+
cosine_similarity: ClassVar[str] = "sparsevec_cosine_ops"
|
|
227
|
+
|
|
228
|
+
def __init__(self, size: int | None = None, indices: bytes | None = None, values: bytes | None = None) -> None:
|
|
229
|
+
if size is not None and indices is not None and values is not None:
|
|
230
|
+
self._size = size
|
|
231
|
+
self._indices = indices
|
|
232
|
+
self._values = values
|
|
233
|
+
if len(self._indices) != len(self._values):
|
|
234
|
+
raise ValueError(f"expected: `indices` (of len {len(self._indices)}) and `values` (of len {len(self._values)}) to match in size")
|
|
235
|
+
elif size is None and indices is None and values is None:
|
|
236
|
+
self._size = 0
|
|
237
|
+
self._indices = bytes()
|
|
238
|
+
self._values = bytes()
|
|
239
|
+
else:
|
|
240
|
+
raise ValueError("expected: either all of `size`, `indices` and `values`, or neither")
|
|
241
|
+
|
|
242
|
+
def __eq__(self, value: object) -> bool:
|
|
243
|
+
if not isinstance(value, self.__class__):
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
return self._size == value._size and self._indices == value._indices and self._values == value._values
|
|
247
|
+
|
|
248
|
+
def __str__(self) -> str:
|
|
249
|
+
return f"{type(self).__name__}(size={self.size()}, nnz={self.nnz()})"
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str:
|
|
252
|
+
return f"{type(self).__name__}(size={self.size()}, indices={self._indices!r}, values={self._values!r})"
|
|
253
|
+
|
|
254
|
+
@override
|
|
255
|
+
def size(self) -> int:
|
|
256
|
+
return self._size
|
|
257
|
+
|
|
258
|
+
def nnz(self) -> int:
|
|
259
|
+
return len(self._indices) // 4
|
|
260
|
+
|
|
261
|
+
@override
|
|
262
|
+
def to_float_list(self) -> list[float]:
|
|
263
|
+
count = self.nnz()
|
|
264
|
+
indices = unpack(f">{count}i", self._indices)
|
|
265
|
+
values = unpack(f">{count}f", self._values)
|
|
266
|
+
items = [0.0 for _ in range(self._size)]
|
|
267
|
+
for index, value in zip(indices, values, strict=True):
|
|
268
|
+
items[index] = value
|
|
269
|
+
return items
|
|
270
|
+
|
|
271
|
+
@override
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_float_list(cls, vec: Sequence[float]) -> Self:
|
|
274
|
+
indices = [index for index, value in enumerate(vec) if value != 0]
|
|
275
|
+
values = [vec[index] for index in indices]
|
|
276
|
+
count = len(indices)
|
|
277
|
+
return cls(len(vec), pack(f">{count}i", *indices), pack(f">{count}f", *values))
|
|
278
|
+
|
|
279
|
+
@override
|
|
280
|
+
def to_database_binary(self) -> bytes:
|
|
281
|
+
return pack(">iii", self._size, self.nnz(), 0) + self._indices + self._values
|
|
282
|
+
|
|
283
|
+
@override
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_database_binary(cls, data: bytes) -> Self:
|
|
286
|
+
size, count, _ = unpack(">iii", data[0:12])
|
|
287
|
+
return cls(size, data[12 : 12 + 4 * count], data[12 + 4 * count :])
|
|
288
|
+
|
|
289
|
+
|
|
189
290
|
async def register_vector(conn: asyncpg.Connection, schema: str = "public") -> None:
|
|
190
291
|
"Registers `vector` extension types with Python module `asyncpg`."
|
|
191
292
|
|
|
192
293
|
await conn.set_type_codec("vector", schema=schema, encoder=Vector._to_database_binary, decoder=Vector._from_database_binary, format="binary") # pyright: ignore[reportPrivateUsage]
|
|
193
294
|
await conn.set_type_codec("halfvec", schema=schema, encoder=HalfVector._to_database_binary, decoder=HalfVector._from_database_binary, format="binary") # pyright: ignore[reportPrivateUsage]
|
|
295
|
+
await conn.set_type_codec("sparsevec", schema=schema, encoder=SparseVector._to_database_binary, decoder=SparseVector._from_database_binary, format="binary") # pyright: ignore[reportPrivateUsage]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from collections.abc import AsyncIterator, Sequence
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from random import random
|
|
5
|
+
from struct import pack, unpack
|
|
6
|
+
|
|
7
|
+
import asyncpg
|
|
8
|
+
|
|
9
|
+
from asyncpg_vector import HalfVector, SparseVector, Vector, register_vector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@asynccontextmanager
|
|
13
|
+
async def get_connection() -> AsyncIterator[asyncpg.Connection]:
|
|
14
|
+
conn = await asyncpg.connect(host="localhost", port=5432, user="postgres", password="postgres")
|
|
15
|
+
try:
|
|
16
|
+
yield conn
|
|
17
|
+
finally:
|
|
18
|
+
await conn.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_float16(values: Sequence[float]) -> list[float]:
|
|
22
|
+
"Truncates a list of floating-point values to 16-bit width."
|
|
23
|
+
|
|
24
|
+
return list(unpack(f"{len(values)}e", pack(f"{len(values)}e", *values)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_float32(values: Sequence[float]) -> list[float]:
|
|
28
|
+
"Truncates a list of floating-point values to 32-bit width."
|
|
29
|
+
|
|
30
|
+
return list(unpack(f"{len(values)}f", pack(f"{len(values)}f", *values)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def random_dense() -> list[float]:
|
|
34
|
+
"Generates a dense random vector."
|
|
35
|
+
|
|
36
|
+
return [random() for _ in range(1536)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def random_sparse() -> list[float]:
|
|
40
|
+
"Generates a sparse random vector with many zeros."
|
|
41
|
+
|
|
42
|
+
return [(random() if random() > 0.9 else 0.0) for _ in range(1536)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestVector(unittest.IsolatedAsyncioTestCase):
|
|
46
|
+
async def test_types(self) -> None:
|
|
47
|
+
f32d_vector = to_float32(random_dense())
|
|
48
|
+
f16d_vector = to_float16(random_dense())
|
|
49
|
+
f32s_vector = to_float32(random_sparse())
|
|
50
|
+
|
|
51
|
+
# empty vectors
|
|
52
|
+
self.assertEqual(Vector().size(), 0)
|
|
53
|
+
self.assertEqual(HalfVector().size(), 0)
|
|
54
|
+
self.assertEqual(SparseVector().size(), 0)
|
|
55
|
+
|
|
56
|
+
# str() yields compact representation
|
|
57
|
+
self.assertLess(len(str(Vector.from_float_list(f32d_vector))), 64)
|
|
58
|
+
self.assertLess(len(str(HalfVector.from_float_list(f16d_vector))), 64)
|
|
59
|
+
self.assertLess(len(str(SparseVector.from_float_list(f32s_vector))), 64)
|
|
60
|
+
|
|
61
|
+
# repr() yields full representation
|
|
62
|
+
self.assertGreater(len(repr(Vector.from_float_list(f32d_vector))), 1000)
|
|
63
|
+
self.assertGreater(len(repr(HalfVector.from_float_list(f16d_vector))), 1000)
|
|
64
|
+
self.assertGreater(len(repr(SparseVector.from_float_list(f32s_vector))), 1000)
|
|
65
|
+
|
|
66
|
+
# round trip for list[float]
|
|
67
|
+
self.assertEqual(Vector.from_float_list(f32d_vector).to_float_list(), f32d_vector)
|
|
68
|
+
self.assertEqual(HalfVector.from_float_list(f16d_vector).to_float_list(), f16d_vector)
|
|
69
|
+
self.assertEqual(SparseVector.from_float_list(f32s_vector).to_float_list(), f32s_vector)
|
|
70
|
+
|
|
71
|
+
async def test_connection(self) -> None:
|
|
72
|
+
create_sql = """
|
|
73
|
+
--sql
|
|
74
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
75
|
+
|
|
76
|
+
--sql
|
|
77
|
+
CREATE TEMPORARY TABLE vector_types(
|
|
78
|
+
id bigint GENERATED ALWAYS AS IDENTITY,
|
|
79
|
+
embedding vector(1536) NOT NULL,
|
|
80
|
+
half_embedding halfvec(1536) NOT NULL,
|
|
81
|
+
sparse_embedding sparsevec(1536) NOT NULL,
|
|
82
|
+
CONSTRAINT pk_vector_types PRIMARY KEY (id)
|
|
83
|
+
);
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
insert_sql = """
|
|
87
|
+
--sql
|
|
88
|
+
INSERT INTO vector_types (embedding, half_embedding, sparse_embedding)
|
|
89
|
+
VALUES ($1, $2, $3);
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
select_sql = """
|
|
93
|
+
--sql
|
|
94
|
+
SELECT embedding, half_embedding, sparse_embedding
|
|
95
|
+
FROM vector_types
|
|
96
|
+
ORDER BY id;
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
async with get_connection() as conn:
|
|
100
|
+
await conn.execute(create_sql)
|
|
101
|
+
await register_vector(conn)
|
|
102
|
+
|
|
103
|
+
records = [
|
|
104
|
+
(
|
|
105
|
+
Vector.from_float_list(to_float32(random_dense())),
|
|
106
|
+
HalfVector.from_float_list(to_float16(random_dense())),
|
|
107
|
+
SparseVector.from_float_list(to_float32(random_sparse())),
|
|
108
|
+
)
|
|
109
|
+
for _ in range(1)
|
|
110
|
+
]
|
|
111
|
+
await conn.executemany(insert_sql, records)
|
|
112
|
+
rows = await conn.fetch(select_sql)
|
|
113
|
+
for row, record in zip(rows, records, strict=True):
|
|
114
|
+
self.assertEqual(tuple(row), record)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|