vecshift 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.
- vecshift/__init__.py +15 -0
- vecshift/assets/bench.css +39 -0
- vecshift/assets/eval.css +91 -0
- vecshift/assets/report.css +252 -0
- vecshift/assets/report.js +50 -0
- vecshift/bench/__init__.py +18 -0
- vecshift/bench/corpus.py +175 -0
- vecshift/bench/generate.py +106 -0
- vecshift/bench/html.py +325 -0
- vecshift/bench/metrics.py +50 -0
- vecshift/bench/runner.py +183 -0
- vecshift/cli.py +236 -0
- vecshift/cli_apply.py +283 -0
- vecshift/cli_bench.py +354 -0
- vecshift/cli_cutover.py +431 -0
- vecshift/cli_eval.py +591 -0
- vecshift/cli_plan.py +335 -0
- vecshift/cli_style.py +57 -0
- vecshift/connectors/__init__.py +1 -0
- vecshift/connectors/pgvector/__init__.py +29 -0
- vecshift/connectors/pgvector/connection.py +155 -0
- vecshift/connectors/pgvector/documents.py +96 -0
- vecshift/connectors/pgvector/inspect.py +427 -0
- vecshift/connectors/pgvector/search.py +240 -0
- vecshift/connectors/pgvector/switch.py +481 -0
- vecshift/connectors/pgvector/target.py +195 -0
- vecshift/connectors/pgvector/writer.py +431 -0
- vecshift/core/__init__.py +4 -0
- vecshift/core/capabilities.py +33 -0
- vecshift/core/contracts.py +57 -0
- vecshift/core/fingerprint.py +57 -0
- vecshift/core/record.py +75 -0
- vecshift/doctor/__init__.py +15 -0
- vecshift/doctor/checks.py +490 -0
- vecshift/doctor/findings.py +78 -0
- vecshift/doctor/html.py +493 -0
- vecshift/doctor/profile.py +69 -0
- vecshift/embeddings/__init__.py +22 -0
- vecshift/embeddings/cache.py +86 -0
- vecshift/embeddings/providers.py +244 -0
- vecshift/embeddings/spec.py +240 -0
- vecshift/eval/__init__.py +20 -0
- vecshift/eval/html.py +444 -0
- vecshift/eval/metrics.py +81 -0
- vecshift/eval/queries.py +97 -0
- vecshift/eval/runner.py +394 -0
- vecshift/html_kit.py +143 -0
- vecshift/jobs/__init__.py +5 -0
- vecshift/jobs/spec.py +202 -0
- vecshift/migrate/__init__.py +6 -0
- vecshift/migrate/engine.py +272 -0
- vecshift/migrate/state.py +50 -0
- vecshift/planning/__init__.py +14 -0
- vecshift/planning/plan.py +87 -0
- vecshift/planning/planner.py +493 -0
- vecshift/py.typed +0 -0
- vecshift-0.1.0.dist-info/METADATA +264 -0
- vecshift-0.1.0.dist-info/RECORD +62 -0
- vecshift-0.1.0.dist-info/WHEEL +4 -0
- vecshift-0.1.0.dist-info/entry_points.txt +2 -0
- vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
- vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""The statements ``vecshift apply`` runs against PostgreSQL.
|
|
2
|
+
|
|
3
|
+
Everything here writes, so every statement is composed with ``psycopg.sql`` and runs under
|
|
4
|
+
a lock timeout: if a lock isn't free within a few seconds the statement gives up and retries
|
|
5
|
+
later, instead of queueing behind application queries and blocking them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
from psycopg import errors, sql
|
|
19
|
+
|
|
20
|
+
from vecshift.connectors.pgvector.target import OPCLASS_SUFFIX
|
|
21
|
+
|
|
22
|
+
LOCK_TIMEOUT_MS = 2_000
|
|
23
|
+
"""How long a schema change waits for its lock. Application writes queue behind a waiting
|
|
24
|
+
lock request, so this is also the longest they can stall; retries with backoff cover a
|
|
25
|
+
table that stays busy."""
|
|
26
|
+
STATEMENT_TIMEOUT_MS = 120_000
|
|
27
|
+
SCAN_TIMEOUT_MS = 30 * 60_000
|
|
28
|
+
"""For full-table counts, which can take a while on large tables."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Busy(Exception):
|
|
32
|
+
"""A lock couldn't be taken in time, even after retries."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AlreadyRunning(Exception):
|
|
36
|
+
"""Another apply holds the lock for this table and column."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _limits(
|
|
40
|
+
executor: psycopg.Connection | psycopg.Cursor[Any],
|
|
41
|
+
statement_ms: int,
|
|
42
|
+
lock_ms: int | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Set timeouts for the current transaction only (like ``SET LOCAL``), as parameters."""
|
|
45
|
+
executor.execute("SELECT set_config('statement_timeout', %s, true)", (str(int(statement_ms)),))
|
|
46
|
+
if lock_ms is not None:
|
|
47
|
+
executor.execute("SELECT set_config('lock_timeout', %s, true)", (str(int(lock_ms)),))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Layout:
|
|
52
|
+
"""Where things are. Names are raw identifiers; they're quoted when composed."""
|
|
53
|
+
|
|
54
|
+
schema: str
|
|
55
|
+
table: str
|
|
56
|
+
pk: str
|
|
57
|
+
text: str
|
|
58
|
+
target: str
|
|
59
|
+
vector_type: str
|
|
60
|
+
dims: int
|
|
61
|
+
extension_schema: str
|
|
62
|
+
live: str = ""
|
|
63
|
+
"""The column the application searches. Cutover gives the new vectors this name."""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def qualified(self) -> sql.Identifier:
|
|
67
|
+
return sql.Identifier(self.schema, self.table)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def previous(self) -> str:
|
|
71
|
+
"""Where cutover keeps the old vectors, for rollback."""
|
|
72
|
+
return f"{self.live}_old"[:63]
|
|
73
|
+
|
|
74
|
+
def trigger_name(self, column: str | None = None) -> str:
|
|
75
|
+
return f"vecshift_sync_{column or self.target}"[:63]
|
|
76
|
+
|
|
77
|
+
def function_name(self, column: str | None = None) -> str:
|
|
78
|
+
return f"vecshift_sync_{self.table}_{column or self.target}"[:63]
|
|
79
|
+
|
|
80
|
+
def index_name(self, method: str, column: str | None = None) -> str:
|
|
81
|
+
return f"{self.table}_{column or self.target}_{method}_idx"[:63]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def lock_key(self) -> int:
|
|
85
|
+
digest = hashlib.sha256(f"vecshift:{self.schema}.{self.table}.{self.target}".encode())
|
|
86
|
+
return int.from_bytes(digest.digest()[:8], "big", signed=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Row:
|
|
91
|
+
key: Any
|
|
92
|
+
"""The primary key value, as the database returned it."""
|
|
93
|
+
text: str
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def id(self) -> str:
|
|
97
|
+
return str(self.key)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PgWriter:
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
conn: psycopg.Connection,
|
|
104
|
+
layout: Layout,
|
|
105
|
+
*,
|
|
106
|
+
lock_retries: int = 6,
|
|
107
|
+
lock_timeout_ms: int = LOCK_TIMEOUT_MS,
|
|
108
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
109
|
+
) -> None:
|
|
110
|
+
if not conn.autocommit:
|
|
111
|
+
raise ValueError("PgWriter needs an autocommit connection")
|
|
112
|
+
if layout.vector_type not in {"vector", "halfvec"}:
|
|
113
|
+
raise ValueError(f"unsupported vector type {layout.vector_type!r}")
|
|
114
|
+
self.conn = conn
|
|
115
|
+
self.layout = layout
|
|
116
|
+
self.lock_retries = lock_retries
|
|
117
|
+
self.lock_timeout_ms = int(lock_timeout_ms)
|
|
118
|
+
self._sleep = sleep
|
|
119
|
+
|
|
120
|
+
# --- plumbing
|
|
121
|
+
|
|
122
|
+
def _locked(self, statements: Sequence[sql.Composed | sql.SQL], what: str) -> None:
|
|
123
|
+
"""Run statements in one transaction under a lock timeout, retrying when busy."""
|
|
124
|
+
delay = 1.0
|
|
125
|
+
for attempt in range(1, self.lock_retries + 1):
|
|
126
|
+
try:
|
|
127
|
+
with self.conn.transaction():
|
|
128
|
+
_limits(self.conn, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
|
|
129
|
+
for statement in statements:
|
|
130
|
+
self.conn.execute(statement)
|
|
131
|
+
return
|
|
132
|
+
except errors.LockNotAvailable as exc:
|
|
133
|
+
if attempt == self.lock_retries:
|
|
134
|
+
raise Busy(
|
|
135
|
+
f"Couldn't {what}: the table stayed locked by other sessions. "
|
|
136
|
+
"Try again when it's quieter."
|
|
137
|
+
) from exc
|
|
138
|
+
self._sleep(delay)
|
|
139
|
+
delay = min(delay * 2, 30)
|
|
140
|
+
|
|
141
|
+
def _type(self) -> sql.Composable:
|
|
142
|
+
lay = self.layout
|
|
143
|
+
return sql.SQL("{}({})").format(
|
|
144
|
+
sql.Identifier(lay.extension_schema, lay.vector_type), sql.Literal(lay.dims)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _pending_filter(self) -> sql.Composable:
|
|
148
|
+
lay = self.layout
|
|
149
|
+
return sql.SQL(
|
|
150
|
+
"{target} IS NULL AND {text} IS NOT NULL AND btrim({text}::text) <> ''"
|
|
151
|
+
).format(target=sql.Identifier(lay.target), text=sql.Identifier(lay.text))
|
|
152
|
+
|
|
153
|
+
# --- concurrency guard
|
|
154
|
+
|
|
155
|
+
def acquire(self) -> None:
|
|
156
|
+
"""Make sure no other apply works on the same column at the same time.
|
|
157
|
+
|
|
158
|
+
Session-level advisory locks need a real session, so apply can't run through a
|
|
159
|
+
transaction pooler.
|
|
160
|
+
"""
|
|
161
|
+
row = self.conn.execute(
|
|
162
|
+
"SELECT pg_try_advisory_lock(%s)", (self.layout.lock_key,)
|
|
163
|
+
).fetchone()
|
|
164
|
+
if not row or not row[0]:
|
|
165
|
+
raise AlreadyRunning(
|
|
166
|
+
f"Another vecshift apply is already working on {self.layout.table}."
|
|
167
|
+
f"{self.layout.target}."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def release(self) -> None:
|
|
171
|
+
self.conn.execute("SELECT pg_advisory_unlock(%s)", (self.layout.lock_key,))
|
|
172
|
+
|
|
173
|
+
# --- schema changes
|
|
174
|
+
|
|
175
|
+
def column_exists(self) -> bool:
|
|
176
|
+
row = self.conn.execute(
|
|
177
|
+
"""
|
|
178
|
+
SELECT 1 FROM pg_attribute
|
|
179
|
+
WHERE attrelid = %s::regclass AND attname = %s AND attnum > 0 AND NOT attisdropped
|
|
180
|
+
""",
|
|
181
|
+
(self._regclass(), self.layout.target),
|
|
182
|
+
).fetchone()
|
|
183
|
+
return row is not None
|
|
184
|
+
|
|
185
|
+
def _regclass(self) -> str:
|
|
186
|
+
return sql.Identifier(self.layout.schema, self.layout.table).as_string(self.conn)
|
|
187
|
+
|
|
188
|
+
def ensure_column(self) -> bool:
|
|
189
|
+
"""Add the target column if it's missing. Returns whether it was added."""
|
|
190
|
+
if self.column_exists():
|
|
191
|
+
return False
|
|
192
|
+
lay = self.layout
|
|
193
|
+
statement = sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {type}").format(
|
|
194
|
+
table=lay.qualified, col=sql.Identifier(lay.target), type=self._type()
|
|
195
|
+
)
|
|
196
|
+
self._locked([statement], "add the new column")
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
def trigger_exists(self, column: str | None = None) -> bool:
|
|
200
|
+
row = self.conn.execute(
|
|
201
|
+
"SELECT 1 FROM pg_trigger WHERE tgrelid = %s::regclass AND tgname = %s",
|
|
202
|
+
(self._regclass(), self.layout.trigger_name(column)),
|
|
203
|
+
).fetchone()
|
|
204
|
+
return row is not None
|
|
205
|
+
|
|
206
|
+
def sync_function_sql(self, column: str | None = None) -> sql.Composed:
|
|
207
|
+
"""A trigger function that clears ``column`` when a row's text changes.
|
|
208
|
+
|
|
209
|
+
If the same update also writes ``column`` itself (an application already writing
|
|
210
|
+
the new model's vectors), that value is kept.
|
|
211
|
+
"""
|
|
212
|
+
lay = self.layout
|
|
213
|
+
col = sql.Identifier(column or lay.target)
|
|
214
|
+
tag = sql.SQL("$vs_" + secrets.token_hex(4) + "$")
|
|
215
|
+
body = sql.SQL(
|
|
216
|
+
"BEGIN IF NEW.{text} IS DISTINCT FROM OLD.{text} "
|
|
217
|
+
"AND NEW.{col} IS NOT DISTINCT FROM OLD.{col} THEN NEW.{col} := NULL; END IF; "
|
|
218
|
+
"RETURN NEW; END"
|
|
219
|
+
).format(text=sql.Identifier(lay.text), col=col)
|
|
220
|
+
# A fixed search path finds pgvector's operators wherever the extension lives, and
|
|
221
|
+
# stops other schemas from shadowing them.
|
|
222
|
+
return sql.SQL(
|
|
223
|
+
"CREATE OR REPLACE FUNCTION {fn}() RETURNS trigger LANGUAGE plpgsql "
|
|
224
|
+
"SET search_path = pg_catalog, {ext} AS {tag}{body}{tag}"
|
|
225
|
+
).format(
|
|
226
|
+
fn=sql.Identifier(lay.schema, lay.function_name(column)),
|
|
227
|
+
ext=sql.Identifier(lay.extension_schema),
|
|
228
|
+
tag=tag,
|
|
229
|
+
body=body,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def sync_trigger_sql(self, column: str | None = None) -> sql.Composed:
|
|
233
|
+
# Listing the guarded column makes the trigger depend on it, so dropping the column
|
|
234
|
+
# by hand fails clearly instead of leaving a trigger that breaks every update.
|
|
235
|
+
lay = self.layout
|
|
236
|
+
return sql.SQL(
|
|
237
|
+
"CREATE TRIGGER {name} BEFORE UPDATE OF {text}, {col} ON {table} "
|
|
238
|
+
"FOR EACH ROW EXECUTE FUNCTION {fn}()"
|
|
239
|
+
).format(
|
|
240
|
+
name=sql.Identifier(lay.trigger_name(column)),
|
|
241
|
+
text=sql.Identifier(lay.text),
|
|
242
|
+
col=sql.Identifier(column or lay.target),
|
|
243
|
+
table=lay.qualified,
|
|
244
|
+
fn=sql.Identifier(lay.schema, lay.function_name(column)),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def drop_sync_sql(self, column: str | None = None) -> list[sql.Composed]:
|
|
248
|
+
lay = self.layout
|
|
249
|
+
return [
|
|
250
|
+
sql.SQL("DROP TRIGGER IF EXISTS {name} ON {table}").format(
|
|
251
|
+
name=sql.Identifier(lay.trigger_name(column)), table=lay.qualified
|
|
252
|
+
),
|
|
253
|
+
sql.SQL("DROP FUNCTION IF EXISTS {fn}()").format(
|
|
254
|
+
fn=sql.Identifier(lay.schema, lay.function_name(column))
|
|
255
|
+
),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
def ensure_trigger(self) -> bool:
|
|
259
|
+
"""Clear a row's new vector whenever its text changes, so it gets re-embedded.
|
|
260
|
+
|
|
261
|
+
Returns whether the trigger was created. The function is always replaced, which
|
|
262
|
+
needs no lock on the table, so older installs pick up fixes.
|
|
263
|
+
"""
|
|
264
|
+
if self.trigger_exists():
|
|
265
|
+
with self.conn.transaction():
|
|
266
|
+
_limits(self.conn, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
|
|
267
|
+
self.conn.execute(self.sync_function_sql())
|
|
268
|
+
return False
|
|
269
|
+
self._locked([self.sync_function_sql(), self.sync_trigger_sql()], "add the sync trigger")
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
# --- backfill
|
|
273
|
+
|
|
274
|
+
def total_count(self) -> int:
|
|
275
|
+
"""Rows with text, which should all end up with a new vector."""
|
|
276
|
+
lay = self.layout
|
|
277
|
+
query = sql.SQL(
|
|
278
|
+
"SELECT count(*) FROM {table} WHERE {text} IS NOT NULL AND btrim({text}::text) <> ''"
|
|
279
|
+
).format(table=lay.qualified, text=sql.Identifier(lay.text))
|
|
280
|
+
with self.conn.transaction():
|
|
281
|
+
_limits(self.conn, SCAN_TIMEOUT_MS)
|
|
282
|
+
row = self.conn.execute(query).fetchone()
|
|
283
|
+
return int(row[0]) if row else 0
|
|
284
|
+
|
|
285
|
+
def pending_count(self, exclude: Sequence[str] = ()) -> int:
|
|
286
|
+
lay = self.layout
|
|
287
|
+
query = sql.SQL("SELECT count(*) FROM {table} WHERE {pending}").format(
|
|
288
|
+
table=lay.qualified, pending=self._pending_filter()
|
|
289
|
+
)
|
|
290
|
+
params: tuple[Any, ...] = ()
|
|
291
|
+
if exclude:
|
|
292
|
+
query += sql.SQL(" AND NOT ({pk}::text = ANY(%s))").format(pk=sql.Identifier(lay.pk))
|
|
293
|
+
params = (list(exclude),)
|
|
294
|
+
with self.conn.transaction():
|
|
295
|
+
_limits(self.conn, SCAN_TIMEOUT_MS)
|
|
296
|
+
row = self.conn.execute(query, params).fetchone()
|
|
297
|
+
return int(row[0]) if row else 0
|
|
298
|
+
|
|
299
|
+
def fetch(self, after: Any, limit: int, exclude: Sequence[str] = ()) -> list[Row]:
|
|
300
|
+
"""The next rows that need a vector, in primary-key order after ``after``."""
|
|
301
|
+
lay = self.layout
|
|
302
|
+
pk = sql.Identifier(lay.pk)
|
|
303
|
+
query = sql.SQL("SELECT {pk}, {text}::text FROM {table} WHERE {pending}").format(
|
|
304
|
+
pk=pk,
|
|
305
|
+
text=sql.Identifier(lay.text),
|
|
306
|
+
table=lay.qualified,
|
|
307
|
+
pending=self._pending_filter(),
|
|
308
|
+
)
|
|
309
|
+
params: list[Any] = []
|
|
310
|
+
if after is not None:
|
|
311
|
+
query += sql.SQL(" AND {pk} > %s").format(pk=pk)
|
|
312
|
+
params.append(after)
|
|
313
|
+
if exclude:
|
|
314
|
+
query += sql.SQL(" AND NOT ({pk}::text = ANY(%s))").format(pk=pk)
|
|
315
|
+
params.append(list(exclude))
|
|
316
|
+
query += sql.SQL(" ORDER BY {pk} LIMIT %s").format(pk=pk)
|
|
317
|
+
params.append(limit)
|
|
318
|
+
with self.conn.transaction():
|
|
319
|
+
_limits(self.conn, STATEMENT_TIMEOUT_MS)
|
|
320
|
+
rows = self.conn.execute(query, params).fetchall()
|
|
321
|
+
return [Row(key, text) for key, text in rows]
|
|
322
|
+
|
|
323
|
+
def write(self, items: Sequence[tuple[Row, Sequence[float]]]) -> int:
|
|
324
|
+
"""Store vectors, but only where the row still needs one and its text is unchanged.
|
|
325
|
+
|
|
326
|
+
Returns how many rows were written. A row whose text changed after it was read is
|
|
327
|
+
left empty, so a later pass embeds the new text.
|
|
328
|
+
"""
|
|
329
|
+
if not items:
|
|
330
|
+
return 0
|
|
331
|
+
lay = self.layout
|
|
332
|
+
statement = sql.SQL(
|
|
333
|
+
"UPDATE {table} SET {target} = %s::{type} "
|
|
334
|
+
"WHERE {pk} = %s AND {target} IS NULL AND {text}::text = %s"
|
|
335
|
+
).format(
|
|
336
|
+
table=lay.qualified,
|
|
337
|
+
target=sql.Identifier(lay.target),
|
|
338
|
+
type=self._type(),
|
|
339
|
+
pk=sql.Identifier(lay.pk),
|
|
340
|
+
text=sql.Identifier(lay.text),
|
|
341
|
+
)
|
|
342
|
+
params = [
|
|
343
|
+
("[" + ",".join(repr(float(x)) for x in vector) + "]", row.key, row.text)
|
|
344
|
+
for row, vector in items
|
|
345
|
+
]
|
|
346
|
+
written = 0
|
|
347
|
+
delay = 1.0
|
|
348
|
+
for attempt in range(1, self.lock_retries + 1):
|
|
349
|
+
try:
|
|
350
|
+
with self.conn.transaction(), self.conn.cursor() as cur:
|
|
351
|
+
_limits(cur, STATEMENT_TIMEOUT_MS, self.lock_timeout_ms)
|
|
352
|
+
written = 0
|
|
353
|
+
for p in params:
|
|
354
|
+
cur.execute(statement, p)
|
|
355
|
+
written += cur.rowcount
|
|
356
|
+
return written
|
|
357
|
+
except errors.LockNotAvailable as exc:
|
|
358
|
+
if attempt == self.lock_retries:
|
|
359
|
+
raise Busy(
|
|
360
|
+
"Couldn't write vectors: rows stayed locked by other sessions."
|
|
361
|
+
) from exc
|
|
362
|
+
self._sleep(delay)
|
|
363
|
+
delay = min(delay * 2, 30)
|
|
364
|
+
return written # pragma: no cover
|
|
365
|
+
|
|
366
|
+
# --- index
|
|
367
|
+
|
|
368
|
+
def index_state(self, method: str) -> str | None:
|
|
369
|
+
"""``valid``, ``invalid`` (a failed concurrent build), or ``None`` when absent."""
|
|
370
|
+
row = self.conn.execute(
|
|
371
|
+
"""
|
|
372
|
+
SELECT i.indisvalid FROM pg_index i
|
|
373
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
374
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
375
|
+
WHERE i.indrelid = %s::regclass AND c.relname = %s AND a.attname = %s
|
|
376
|
+
""",
|
|
377
|
+
(self._regclass(), self.layout.index_name(method), self.layout.target),
|
|
378
|
+
).fetchone()
|
|
379
|
+
if row is None:
|
|
380
|
+
return None
|
|
381
|
+
return "valid" if row[0] else "invalid"
|
|
382
|
+
|
|
383
|
+
def build_index(self, method: str, metric: str, rows: int) -> str:
|
|
384
|
+
"""Build the vector index concurrently, so writes continue. Returns what happened."""
|
|
385
|
+
if method not in {"hnsw", "ivfflat"} or metric not in OPCLASS_SUFFIX:
|
|
386
|
+
raise ValueError(f"unsupported index {method!r} / {metric!r}")
|
|
387
|
+
lay = self.layout
|
|
388
|
+
name = sql.Identifier(lay.schema, lay.index_name(method))
|
|
389
|
+
state = self.index_state(method)
|
|
390
|
+
if state == "valid":
|
|
391
|
+
return "exists"
|
|
392
|
+
# Concurrent builds can't run in a transaction and can take a long time, so the
|
|
393
|
+
# session's timeouts are lifted for these statements only.
|
|
394
|
+
self.conn.execute("SET statement_timeout = 0")
|
|
395
|
+
self.conn.execute("SET lock_timeout = 0")
|
|
396
|
+
try:
|
|
397
|
+
if state == "invalid":
|
|
398
|
+
self.conn.execute(sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}").format(name))
|
|
399
|
+
statement = sql.SQL(
|
|
400
|
+
"CREATE INDEX CONCURRENTLY {name} ON {table} USING {method} ({col} {ops})"
|
|
401
|
+
).format(
|
|
402
|
+
name=sql.Identifier(lay.index_name(method)),
|
|
403
|
+
table=lay.qualified,
|
|
404
|
+
method=sql.SQL(method),
|
|
405
|
+
col=sql.Identifier(lay.target),
|
|
406
|
+
ops=sql.Identifier(
|
|
407
|
+
lay.extension_schema, f"{lay.vector_type}_{OPCLASS_SUFFIX[metric]}"
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
if method == "ivfflat":
|
|
411
|
+
lists = max(1, rows // 1000 if rows <= 1_000_000 else int(rows**0.5))
|
|
412
|
+
statement += sql.SQL(" WITH (lists = {})").format(sql.Literal(lists))
|
|
413
|
+
self.conn.execute(statement)
|
|
414
|
+
finally:
|
|
415
|
+
self.conn.execute("RESET statement_timeout")
|
|
416
|
+
self.conn.execute("RESET lock_timeout")
|
|
417
|
+
return "rebuilt" if state == "invalid" else "built"
|
|
418
|
+
|
|
419
|
+
# --- verification
|
|
420
|
+
|
|
421
|
+
def dimensions_in_use(self) -> set[int]:
|
|
422
|
+
lay = self.layout
|
|
423
|
+
query = sql.SQL(
|
|
424
|
+
"SELECT DISTINCT {fn}({target}) FROM (SELECT {target} FROM {table} "
|
|
425
|
+
"WHERE {target} IS NOT NULL LIMIT 1000) s"
|
|
426
|
+
).format(
|
|
427
|
+
fn=sql.Identifier(lay.extension_schema, "vector_dims"),
|
|
428
|
+
target=sql.Identifier(lay.target),
|
|
429
|
+
table=lay.qualified,
|
|
430
|
+
)
|
|
431
|
+
return {int(r[0]) for r in self.conn.execute(query).fetchall()}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Capability flags connectors declare, so the planner can choose a strategy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Capability(StrEnum):
|
|
9
|
+
"""Something a connector can do.
|
|
10
|
+
|
|
11
|
+
The planner never assumes a capability a connector hasn't declared.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
ALIAS = "alias"
|
|
15
|
+
"""Atomic cutover by repointing an alias (OpenSearch, Elasticsearch, Qdrant)."""
|
|
16
|
+
|
|
17
|
+
NAMED_VECTORS = "named_vectors"
|
|
18
|
+
"""Several vectors per record side by side (Qdrant)."""
|
|
19
|
+
|
|
20
|
+
CHANGE_FEED = "change_feed"
|
|
21
|
+
"""Stream inserts, updates, and deletes (Postgres logical replication)."""
|
|
22
|
+
|
|
23
|
+
SCROLL = "scroll"
|
|
24
|
+
"""Resumable full scans with a cursor."""
|
|
25
|
+
|
|
26
|
+
SPARSE = "sparse"
|
|
27
|
+
"""Sparse vectors, for hybrid search."""
|
|
28
|
+
|
|
29
|
+
DELETE_DETECTION = "delete_detection"
|
|
30
|
+
"""Report deletes directly rather than by diffing."""
|
|
31
|
+
|
|
32
|
+
STORES_TEXT = "stores_text"
|
|
33
|
+
"""Original text is kept alongside vectors, so records can be re-embedded."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Plugin contracts. Third-party connectors and providers implement these.
|
|
2
|
+
|
|
3
|
+
Plugins register under the ``vecshift.plugins`` entry-point group.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Sequence
|
|
9
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from vecshift.core.capabilities import Capability
|
|
12
|
+
from vecshift.core.fingerprint import EmbeddingFingerprint
|
|
13
|
+
from vecshift.core.record import Record
|
|
14
|
+
|
|
15
|
+
EmbedMode = Literal["document", "query"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class SourceConnector(Protocol):
|
|
20
|
+
"""Reads records from a store."""
|
|
21
|
+
|
|
22
|
+
def capabilities(self) -> frozenset[Capability]: ...
|
|
23
|
+
|
|
24
|
+
async def count(self) -> int | None:
|
|
25
|
+
"""Total records, or ``None`` if the store can't count cheaply."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def read(
|
|
29
|
+
self, batch_size: int, cursor: str | None = None
|
|
30
|
+
) -> AsyncIterator[tuple[Sequence[Record], str | None]]:
|
|
31
|
+
"""Yield batches with the cursor to resume after each one."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class TargetConnector(Protocol):
|
|
37
|
+
"""Writes records to a store."""
|
|
38
|
+
|
|
39
|
+
def capabilities(self) -> frozenset[Capability]: ...
|
|
40
|
+
|
|
41
|
+
async def upsert(self, records: Sequence[Record]) -> None:
|
|
42
|
+
"""Idempotently write records, honouring tombstones and ``updated_at``."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
async def count(self) -> int | None: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class EmbeddingProvider(Protocol):
|
|
50
|
+
"""Turns text into vectors."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def fingerprint(self) -> EmbeddingFingerprint: ...
|
|
54
|
+
|
|
55
|
+
async def embed(
|
|
56
|
+
self, texts: Sequence[str], mode: EmbedMode = "document"
|
|
57
|
+
) -> list[list[float]]: ...
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Fingerprints that identify which vector space a vector belongs to.
|
|
2
|
+
|
|
3
|
+
Two vectors are only comparable if they were produced by the same model,
|
|
4
|
+
with the same dimensions, task mode, prefix, and normalization. A change to
|
|
5
|
+
any of those is a different vector space, and mixing spaces in one index
|
|
6
|
+
silently degrades retrieval. The fingerprint makes that provenance explicit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
|
|
15
|
+
_HASH_LENGTH = 12
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class EmbeddingFingerprint:
|
|
20
|
+
"""Everything that determines an embedding's vector space."""
|
|
21
|
+
|
|
22
|
+
provider: str
|
|
23
|
+
model: str
|
|
24
|
+
dimensions: int
|
|
25
|
+
version: str | None = None
|
|
26
|
+
task: str | None = None
|
|
27
|
+
prefix: str | None = None
|
|
28
|
+
normalized: bool = True
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not self.provider.strip():
|
|
32
|
+
raise ValueError("provider must not be empty")
|
|
33
|
+
if not self.model.strip():
|
|
34
|
+
raise ValueError("model must not be empty")
|
|
35
|
+
if self.dimensions <= 0:
|
|
36
|
+
raise ValueError(f"dimensions must be positive, got {self.dimensions}")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def digest(self) -> str:
|
|
40
|
+
"""A stable hash over every field, insensitive to provider/model casing."""
|
|
41
|
+
fields = asdict(self)
|
|
42
|
+
fields["provider"] = self.provider.strip().lower()
|
|
43
|
+
fields["model"] = self.model.strip().lower()
|
|
44
|
+
canonical = json.dumps(fields, sort_keys=True, separators=(",", ":"))
|
|
45
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:_HASH_LENGTH]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def model_tag(self) -> str:
|
|
49
|
+
"""A readable, unique tag such as ``openai/text-embedding-3-small@1536#a1b2c3d4e5f6``."""
|
|
50
|
+
return (
|
|
51
|
+
f"{self.provider.strip().lower()}/{self.model.strip().lower()}"
|
|
52
|
+
f"@{self.dimensions}#{self.digest}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def is_compatible_with(self, other: EmbeddingFingerprint) -> bool:
|
|
56
|
+
"""Whether vectors from ``self`` and ``other`` share one vector space."""
|
|
57
|
+
return self.digest == other.digest
|
vecshift/core/record.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The canonical record every connector reads into and writes from.
|
|
2
|
+
|
|
3
|
+
Connectors convert between their store's native format and :class:`Record`,
|
|
4
|
+
so supporting N stores needs N connectors rather than N x N pairwise bridges.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class SparseVector:
|
|
16
|
+
"""A sparse vector as parallel index/value arrays."""
|
|
17
|
+
|
|
18
|
+
indices: tuple[int, ...]
|
|
19
|
+
values: tuple[float, ...]
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if len(self.indices) != len(self.values):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"indices and values must have equal length "
|
|
25
|
+
f"({len(self.indices)} != {len(self.values)})"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class Record:
|
|
31
|
+
"""One document or chunk moving through a migration.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
id: Stable identifier from the source store.
|
|
35
|
+
text: Original text. Required for re-embedding; ``None`` when the
|
|
36
|
+
store holds vectors only.
|
|
37
|
+
vectors: Dense vectors keyed by name (``"default"`` for single-vector stores).
|
|
38
|
+
sparse: Sparse vectors keyed by name, for hybrid search.
|
|
39
|
+
metadata: Arbitrary payload carried across unchanged unless remapped.
|
|
40
|
+
updated_at: Last-modified time, used to stop backfill from overwriting
|
|
41
|
+
newer writes that arrived through a change feed.
|
|
42
|
+
model_tag: Fingerprint of the vector space the vectors belong to.
|
|
43
|
+
deleted: Tombstone marker for deletes observed through a change feed.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
text: str | None = None
|
|
48
|
+
vectors: dict[str, list[float]] = field(default_factory=dict)
|
|
49
|
+
sparse: dict[str, SparseVector] = field(default_factory=dict)
|
|
50
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
updated_at: datetime | None = None
|
|
52
|
+
model_tag: str | None = None
|
|
53
|
+
deleted: bool = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def can_reembed(self) -> bool:
|
|
57
|
+
"""Whether this record carries the text needed to embed it again."""
|
|
58
|
+
return bool(self.text)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def tombstone(cls, id: str, updated_at: datetime | None = None) -> Record:
|
|
62
|
+
"""A delete marker for ``id``."""
|
|
63
|
+
return cls(id=id, updated_at=updated_at, deleted=True)
|
|
64
|
+
|
|
65
|
+
def is_newer_than(self, other: Record) -> bool:
|
|
66
|
+
"""Whether this record should win an upsert race against ``other``.
|
|
67
|
+
|
|
68
|
+
A record without ``updated_at`` never beats one that has it, so a
|
|
69
|
+
timestamp-less backfill can't overwrite a timestamped live write.
|
|
70
|
+
"""
|
|
71
|
+
if self.updated_at is None:
|
|
72
|
+
return False
|
|
73
|
+
if other.updated_at is None:
|
|
74
|
+
return True
|
|
75
|
+
return self.updated_at > other.updated_at
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""``vecshift doctor``: read-only diagnosis of an existing vector index."""
|
|
2
|
+
|
|
3
|
+
from vecshift.doctor.checks import run_checks
|
|
4
|
+
from vecshift.doctor.findings import Finding, Report, Severity
|
|
5
|
+
from vecshift.doctor.profile import AnnIndex, IndexProfile, Sample
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AnnIndex",
|
|
9
|
+
"Finding",
|
|
10
|
+
"IndexProfile",
|
|
11
|
+
"Report",
|
|
12
|
+
"Sample",
|
|
13
|
+
"Severity",
|
|
14
|
+
"run_checks",
|
|
15
|
+
]
|