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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Levente Hunyadi
3
+ Copyright (c) 2025-2026 Levente Hunyadi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncpg_vector
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: PostgreSQL vector support for asyncpg
5
5
  Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
6
  Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
@@ -1,18 +1,19 @@
1
1
  """
2
2
  PostgreSQL vector support for asyncpg.
3
3
 
4
- Registers data types `vector` and `halfvec` from the PostgreSQL extension `vector` to the asynchronous PostgreSQL
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.0"
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
- __slots__ = ("_data",)
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 = struct.unpack(f"{len(decoded_bytes) // 4}f", decoded_bytes)
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
- return pack(">HH", self.size(), 0) + self._data
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
- size, _unused = struct.unpack(">HH", data[0:4])
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(bytes())
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 HalfVector(BasicVector):
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(struct.unpack(f">{self.size()}e", self._data))
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(struct.pack(f">{len(vec)}e", *vec))
187
+ return cls(pack(f">{len(vec)}e", *vec))
161
188
 
162
189
 
163
- class Vector(BasicVector):
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(struct.unpack(f">{self.size()}f", self._data))
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(struct.pack(f">{len(vec)}f", *vec))
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncpg_vector
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: PostgreSQL vector support for asyncpg
5
5
  Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
6
  Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
@@ -9,4 +9,5 @@ asyncpg_vector.egg-info/SOURCES.txt
9
9
  asyncpg_vector.egg-info/dependency_links.txt
10
10
  asyncpg_vector.egg-info/requires.txt
11
11
  asyncpg_vector.egg-info/top_level.txt
12
- asyncpg_vector.egg-info/zip-safe
12
+ asyncpg_vector.egg-info/zip-safe
13
+ test/test_vector.py
@@ -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