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,427 @@
|
|
|
1
|
+
"""Read-only inspection of a pgvector column for ``vecshift doctor``.
|
|
2
|
+
|
|
3
|
+
Only aggregates leave the database: per-row dimensions, norms, and hashes are computed
|
|
4
|
+
in SQL, so inspecting a table never downloads its vectors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import psycopg
|
|
14
|
+
from psycopg import sql
|
|
15
|
+
|
|
16
|
+
from vecshift.doctor.profile import AnnIndex, IndexProfile, Sample
|
|
17
|
+
|
|
18
|
+
VECTOR_TYPES = ("vector", "halfvec")
|
|
19
|
+
|
|
20
|
+
# pgvector's HNSW and IVFFlat limits per type.
|
|
21
|
+
MAX_INDEXABLE_DIMENSIONS = {"vector": 2000, "halfvec": 4000}
|
|
22
|
+
|
|
23
|
+
# Schemas that never hold user vector tables, including Supabase's internal ones.
|
|
24
|
+
SYSTEM_SCHEMAS = (
|
|
25
|
+
"pg_catalog",
|
|
26
|
+
"information_schema",
|
|
27
|
+
"pg_toast",
|
|
28
|
+
"auth",
|
|
29
|
+
"storage",
|
|
30
|
+
"realtime",
|
|
31
|
+
"_realtime",
|
|
32
|
+
"supabase_functions",
|
|
33
|
+
"supabase_migrations",
|
|
34
|
+
"extensions",
|
|
35
|
+
"graphql",
|
|
36
|
+
"graphql_public",
|
|
37
|
+
"pgsodium",
|
|
38
|
+
"pgsodium_masks",
|
|
39
|
+
"vault",
|
|
40
|
+
"net",
|
|
41
|
+
"cron",
|
|
42
|
+
"pgbouncer",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Common names, most likely first. Covers Supabase and LangChain (content, document),
|
|
46
|
+
# LlamaIndex (text), and others.
|
|
47
|
+
TEXT_COLUMNS = ("content", "text", "page_content", "document", "chunk", "chunk_text", "body")
|
|
48
|
+
MODEL_COLUMNS = ("model_tag", "embedding_model", "model", "model_name")
|
|
49
|
+
MODEL_METADATA_KEYS = ("model_tag", "embedding_model", "model", "model_name")
|
|
50
|
+
METADATA_COLUMNS = ("metadata", "cmetadata", "meta", "metadata_")
|
|
51
|
+
UPDATED_AT_COLUMNS = (
|
|
52
|
+
"updated_at",
|
|
53
|
+
"modified_at",
|
|
54
|
+
"last_modified",
|
|
55
|
+
"last_modified_at",
|
|
56
|
+
"changed_at",
|
|
57
|
+
"updated",
|
|
58
|
+
"modified",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
TEXT_TYPES = ("text", "varchar", "bpchar")
|
|
62
|
+
JSON_TYPES = ("jsonb", "json")
|
|
63
|
+
TIMESTAMP_TYPES = ("timestamptz", "timestamp")
|
|
64
|
+
|
|
65
|
+
_METRICS = (
|
|
66
|
+
("cosine", "cosine"),
|
|
67
|
+
("_ip_", "inner_product"),
|
|
68
|
+
("l2", "l2"),
|
|
69
|
+
("l1", "l1"),
|
|
70
|
+
("hamming", "hamming"),
|
|
71
|
+
("jaccard", "jaccard"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TargetSelectionError(Exception):
|
|
76
|
+
"""The vector column to inspect couldn't be determined."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, candidates: list[VectorColumn] | None = None) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.candidates = candidates or []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class VectorColumn:
|
|
85
|
+
schema: str
|
|
86
|
+
table: str
|
|
87
|
+
column: str
|
|
88
|
+
type: str
|
|
89
|
+
dimensions: int | None
|
|
90
|
+
relid: int
|
|
91
|
+
attnum: int
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def qualified(self) -> str:
|
|
95
|
+
return f"{self.schema}.{self.table}.{self.column}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_vector_columns(conn: psycopg.Connection) -> list[VectorColumn]:
|
|
99
|
+
"""Every vector or halfvec column in a user table, view, or materialized view."""
|
|
100
|
+
rows = conn.execute(
|
|
101
|
+
"""
|
|
102
|
+
SELECT n.nspname, c.relname, a.attname, t.typname, a.atttypmod, c.oid, a.attnum
|
|
103
|
+
FROM pg_attribute a
|
|
104
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
105
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
106
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
107
|
+
WHERE t.typname = ANY(%s)
|
|
108
|
+
AND c.relkind IN ('r', 'p', 'm')
|
|
109
|
+
AND NOT c.relispartition
|
|
110
|
+
AND a.attnum > 0
|
|
111
|
+
AND NOT a.attisdropped
|
|
112
|
+
AND n.nspname <> ALL(%s)
|
|
113
|
+
AND n.nspname NOT LIKE 'pg\\_temp\\_%%'
|
|
114
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
115
|
+
""",
|
|
116
|
+
(list(VECTOR_TYPES), list(SYSTEM_SCHEMAS)),
|
|
117
|
+
).fetchall()
|
|
118
|
+
return [
|
|
119
|
+
VectorColumn(
|
|
120
|
+
schema=r[0],
|
|
121
|
+
table=r[1],
|
|
122
|
+
column=r[2],
|
|
123
|
+
type=r[3],
|
|
124
|
+
dimensions=r[4] if r[4] > 0 else None,
|
|
125
|
+
relid=r[5],
|
|
126
|
+
attnum=r[6],
|
|
127
|
+
)
|
|
128
|
+
for r in rows
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def select_column(
|
|
133
|
+
columns: list[VectorColumn], table: str | None, column: str | None
|
|
134
|
+
) -> VectorColumn:
|
|
135
|
+
"""Pick the column to inspect from ``--table`` and ``--column``, if given."""
|
|
136
|
+
matches = columns
|
|
137
|
+
if table:
|
|
138
|
+
schema, _, name = table.rpartition(".")
|
|
139
|
+
matches = [c for c in matches if c.table == name and (not schema or c.schema == schema)]
|
|
140
|
+
if not matches:
|
|
141
|
+
raise TargetSelectionError(
|
|
142
|
+
f"No vector columns found in table {table!r}.", candidates=columns
|
|
143
|
+
)
|
|
144
|
+
if column:
|
|
145
|
+
matches = [c for c in matches if c.column == column]
|
|
146
|
+
if not matches:
|
|
147
|
+
raise TargetSelectionError(f"No vector column named {column!r}.", candidates=columns)
|
|
148
|
+
|
|
149
|
+
if not matches:
|
|
150
|
+
raise TargetSelectionError(
|
|
151
|
+
"No pgvector columns found. Is the vector extension installed, and can this "
|
|
152
|
+
"role see the tables?"
|
|
153
|
+
)
|
|
154
|
+
if len(matches) > 1:
|
|
155
|
+
raise TargetSelectionError(
|
|
156
|
+
"Found more than one vector column. Choose one with --table (and --column).",
|
|
157
|
+
candidates=matches,
|
|
158
|
+
)
|
|
159
|
+
return matches[0]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extension_schema(conn: psycopg.Connection) -> str:
|
|
163
|
+
# Supabase installs pgvector in "extensions", which isn't always on the search path,
|
|
164
|
+
# so every pgvector function call is schema-qualified.
|
|
165
|
+
row = conn.execute(
|
|
166
|
+
"SELECT n.nspname FROM pg_extension e "
|
|
167
|
+
"JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'vector'"
|
|
168
|
+
).fetchone()
|
|
169
|
+
if row is None:
|
|
170
|
+
raise TargetSelectionError("The pgvector extension isn't installed in this database.")
|
|
171
|
+
return str(row[0])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _columns(conn: psycopg.Connection, relid: int) -> dict[str, str]:
|
|
175
|
+
rows = conn.execute(
|
|
176
|
+
"""
|
|
177
|
+
SELECT a.attname, t.typname
|
|
178
|
+
FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
|
|
179
|
+
WHERE a.attrelid = %s AND a.attnum > 0 AND NOT a.attisdropped
|
|
180
|
+
ORDER BY a.attnum
|
|
181
|
+
""",
|
|
182
|
+
(relid,),
|
|
183
|
+
).fetchall()
|
|
184
|
+
return {r[0]: r[1] for r in rows}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _pick(columns: dict[str, str], names: tuple[str, ...], types: tuple[str, ...]) -> str | None:
|
|
188
|
+
lowered = {name.lower(): name for name in columns}
|
|
189
|
+
for candidate in names:
|
|
190
|
+
actual = lowered.get(candidate)
|
|
191
|
+
if actual is not None and columns[actual] in types:
|
|
192
|
+
return actual
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ann_indexes(conn: psycopg.Connection, col: VectorColumn) -> list[AnnIndex]:
|
|
197
|
+
rows = conn.execute(
|
|
198
|
+
"""
|
|
199
|
+
SELECT ic.relname, am.amname, opc.opcname
|
|
200
|
+
FROM pg_index i
|
|
201
|
+
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
202
|
+
JOIN pg_am am ON am.oid = ic.relam
|
|
203
|
+
CROSS JOIN LATERAL unnest(i.indkey::int2[], i.indclass::oid[]) AS k(attnum, opclass)
|
|
204
|
+
JOIN pg_opclass opc ON opc.oid = k.opclass
|
|
205
|
+
WHERE i.indrelid = %s
|
|
206
|
+
AND am.amname IN ('hnsw', 'ivfflat')
|
|
207
|
+
AND (
|
|
208
|
+
k.attnum = %s
|
|
209
|
+
-- Expression indexes, such as an HNSW index over embedding::halfvec(3072).
|
|
210
|
+
OR (k.attnum = 0 AND pg_get_indexdef(i.indexrelid) LIKE '%%' || quote_ident(%s) || '%%')
|
|
211
|
+
)
|
|
212
|
+
ORDER BY ic.relname
|
|
213
|
+
""",
|
|
214
|
+
(col.relid, col.attnum, col.column),
|
|
215
|
+
).fetchall()
|
|
216
|
+
indexes = []
|
|
217
|
+
for name, method, opclass in rows:
|
|
218
|
+
metric = next((m for key, m in _METRICS if key in opclass), None)
|
|
219
|
+
indexes.append(AnnIndex(name=name, method=method, metric=metric))
|
|
220
|
+
return indexes
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _rows_hidden(conn: psycopg.Connection, relid: int) -> bool:
|
|
224
|
+
row = conn.execute(
|
|
225
|
+
"""
|
|
226
|
+
SELECT c.relrowsecurity, c.relforcerowsecurity,
|
|
227
|
+
pg_has_role(current_user, c.relowner, 'USAGE'),
|
|
228
|
+
r.rolbypassrls OR r.rolsuper
|
|
229
|
+
FROM pg_class c, pg_roles r
|
|
230
|
+
WHERE c.oid = %s AND r.rolname = current_user
|
|
231
|
+
""",
|
|
232
|
+
(relid,),
|
|
233
|
+
).fetchone()
|
|
234
|
+
if row is None:
|
|
235
|
+
return False
|
|
236
|
+
enabled, forced, owner, bypass = row
|
|
237
|
+
if not enabled or bypass or (owner and not forced):
|
|
238
|
+
return False
|
|
239
|
+
# A permissive "using (true)" read policy for this role shows every row, unless a
|
|
240
|
+
# restrictive policy narrows it again.
|
|
241
|
+
sees_all = conn.execute(
|
|
242
|
+
"""
|
|
243
|
+
WITH applicable AS (
|
|
244
|
+
SELECT p.polpermissive, pg_get_expr(p.polqual, p.polrelid) AS qual
|
|
245
|
+
FROM pg_policy p
|
|
246
|
+
WHERE p.polrelid = %s
|
|
247
|
+
AND p.polcmd IN ('r', '*')
|
|
248
|
+
AND (
|
|
249
|
+
0 = ANY(p.polroles)
|
|
250
|
+
OR EXISTS (
|
|
251
|
+
SELECT 1 FROM unnest(p.polroles) AS r(oid)
|
|
252
|
+
WHERE pg_has_role(current_user, r.oid, 'MEMBER')
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
SELECT EXISTS (SELECT 1 FROM applicable WHERE polpermissive AND qual = 'true')
|
|
257
|
+
AND NOT EXISTS (SELECT 1 FROM applicable WHERE NOT polpermissive)
|
|
258
|
+
""",
|
|
259
|
+
(relid,),
|
|
260
|
+
).fetchone()
|
|
261
|
+
return not (sees_all and sees_all[0])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _sample(
|
|
265
|
+
conn: psycopg.Connection,
|
|
266
|
+
col: VectorColumn,
|
|
267
|
+
ext: str,
|
|
268
|
+
estimated_rows: int | None,
|
|
269
|
+
size: int,
|
|
270
|
+
text_field: str | None,
|
|
271
|
+
model_expr: sql.Composable,
|
|
272
|
+
) -> Sample:
|
|
273
|
+
vec = sql.Identifier(col.column)
|
|
274
|
+
fn = sql.Identifier(ext, "vector_dims")
|
|
275
|
+
norm = sql.Identifier(ext, "vector_norm" if col.type == "vector" else "l2_norm")
|
|
276
|
+
has_text: sql.Composable
|
|
277
|
+
text_hash: sql.Composable
|
|
278
|
+
if text_field:
|
|
279
|
+
text = sql.Identifier(text_field)
|
|
280
|
+
has_text = sql.SQL("({t} IS NOT NULL AND btrim({t}::text) <> '')").format(t=text)
|
|
281
|
+
text_hash = sql.SQL("md5({t}::text)").format(t=text)
|
|
282
|
+
else:
|
|
283
|
+
has_text = sql.SQL("NULL::boolean")
|
|
284
|
+
text_hash = sql.SQL("NULL::text")
|
|
285
|
+
|
|
286
|
+
def query(tablesample: sql.Composable, spread: bool) -> list[tuple[Any, ...]]:
|
|
287
|
+
# Rows come back in physical order, so a bare LIMIT keeps only the start of the
|
|
288
|
+
# table. Ordering by a hash of each row's location keeps a spread-out subset
|
|
289
|
+
# instead, and the same one on every run.
|
|
290
|
+
order = sql.SQL("ORDER BY md5(ctid::text)") if spread else sql.SQL("")
|
|
291
|
+
q = sql.SQL(
|
|
292
|
+
"SELECT {vec} IS NULL, {fn}({vec}), {norm}({vec}), md5({vec}::text), "
|
|
293
|
+
"{has_text}, {text_hash}, {model} "
|
|
294
|
+
"FROM {table} {tablesample} {order} LIMIT {limit}"
|
|
295
|
+
).format(
|
|
296
|
+
vec=vec,
|
|
297
|
+
fn=fn,
|
|
298
|
+
norm=norm,
|
|
299
|
+
has_text=has_text,
|
|
300
|
+
text_hash=text_hash,
|
|
301
|
+
model=model_expr,
|
|
302
|
+
table=sql.Identifier(col.schema, col.table),
|
|
303
|
+
tablesample=tablesample,
|
|
304
|
+
order=order,
|
|
305
|
+
limit=sql.Literal(size),
|
|
306
|
+
)
|
|
307
|
+
return conn.execute(q).fetchall()
|
|
308
|
+
|
|
309
|
+
rows: list[tuple[Any, ...]] = []
|
|
310
|
+
method = "table sample"
|
|
311
|
+
if estimated_rows and estimated_rows > size * 2:
|
|
312
|
+
# Big table: read a random ~1.5x share of its pages. SYSTEM sampling skips the
|
|
313
|
+
# other pages entirely, so this stays cheap however large the table is.
|
|
314
|
+
percent = min(100.0, 100.0 * size * 1.5 / estimated_rows)
|
|
315
|
+
tablesample = sql.SQL("TABLESAMPLE SYSTEM ({}) REPEATABLE (7)").format(sql.Literal(percent))
|
|
316
|
+
rows = query(tablesample, spread=True)
|
|
317
|
+
if len(rows) < size // 2: # statistics overstate the size; read it whole below
|
|
318
|
+
rows = []
|
|
319
|
+
if not rows:
|
|
320
|
+
# Statistics say the table is small, so read it all and keep a spread-out subset.
|
|
321
|
+
# Without statistics it could be huge, so read only the first rows.
|
|
322
|
+
known = estimated_rows is not None
|
|
323
|
+
rows = query(sql.SQL(""), spread=known)
|
|
324
|
+
if len(rows) < size:
|
|
325
|
+
method = "full table"
|
|
326
|
+
else:
|
|
327
|
+
method = "spread across the table" if known else "first rows"
|
|
328
|
+
|
|
329
|
+
sample = Sample(rows=len(rows), method=method)
|
|
330
|
+
sample.texts_present = 0 if text_field else None
|
|
331
|
+
vector_hashes: Counter[str] = Counter()
|
|
332
|
+
text_hashes: Counter[str] = Counter()
|
|
333
|
+
model_sources: Counter[str] = Counter()
|
|
334
|
+
for is_null, dims, norm_value, vhash, text_ok, thash, model in rows:
|
|
335
|
+
if is_null:
|
|
336
|
+
sample.null_vectors += 1
|
|
337
|
+
else:
|
|
338
|
+
sample.dimensions[int(dims)] += 1
|
|
339
|
+
sample.norms.append(float(norm_value))
|
|
340
|
+
vector_hashes[vhash] += 1
|
|
341
|
+
if text_ok:
|
|
342
|
+
sample.texts_present = (sample.texts_present or 0) + 1
|
|
343
|
+
text_hashes[thash] += 1
|
|
344
|
+
if model is not None and not is_null:
|
|
345
|
+
source, _, value = str(model).partition("\t")
|
|
346
|
+
sample.models[value] += 1
|
|
347
|
+
model_sources[source] += 1
|
|
348
|
+
sample.duplicate_vectors = sum(n - 1 for n in vector_hashes.values())
|
|
349
|
+
sample.duplicate_texts = sum(n - 1 for n in text_hashes.values())
|
|
350
|
+
sample.model_source = model_sources.most_common(1)[0][0] if model_sources else None
|
|
351
|
+
return sample
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _model_source(columns: dict[str, str]) -> tuple[str | None, sql.Composable]:
|
|
355
|
+
"""Where each row records its model, as a label and an expression yielding
|
|
356
|
+
``label<TAB>model`` (so rows can say which metadata key they used)."""
|
|
357
|
+
column = _pick(columns, MODEL_COLUMNS, TEXT_TYPES)
|
|
358
|
+
if column:
|
|
359
|
+
return column, sql.SQL("{} || E'\\t' || {}::text").format(
|
|
360
|
+
sql.Literal(column), sql.Identifier(column)
|
|
361
|
+
)
|
|
362
|
+
metadata = _pick(columns, METADATA_COLUMNS, JSON_TYPES)
|
|
363
|
+
if metadata:
|
|
364
|
+
meta = sql.Identifier(metadata)
|
|
365
|
+
keys = sql.SQL(", ").join(
|
|
366
|
+
sql.SQL("{} || E'\\t' || ({} ->> {})").format(
|
|
367
|
+
sql.Literal(f"{metadata}->>'{k}'"), meta, sql.Literal(k)
|
|
368
|
+
)
|
|
369
|
+
for k in MODEL_METADATA_KEYS
|
|
370
|
+
)
|
|
371
|
+
return metadata, sql.SQL("COALESCE({})").format(keys)
|
|
372
|
+
return None, sql.SQL("NULL::text")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def inspect(
|
|
376
|
+
conn: psycopg.Connection,
|
|
377
|
+
table: str | None = None,
|
|
378
|
+
column: str | None = None,
|
|
379
|
+
text_column: str | None = None,
|
|
380
|
+
sample_size: int = 2000,
|
|
381
|
+
) -> IndexProfile:
|
|
382
|
+
"""Profile one vector column. Read-only; run it on a read-only connection."""
|
|
383
|
+
ext = _extension_schema(conn)
|
|
384
|
+
col = select_column(find_vector_columns(conn), table, column)
|
|
385
|
+
columns = _columns(conn, col.relid)
|
|
386
|
+
|
|
387
|
+
if text_column is not None:
|
|
388
|
+
if text_column not in columns:
|
|
389
|
+
raise TargetSelectionError(
|
|
390
|
+
f"Column {text_column!r} doesn't exist in {col.schema}.{col.table}."
|
|
391
|
+
)
|
|
392
|
+
text_field: str | None = text_column
|
|
393
|
+
else:
|
|
394
|
+
text_field = _pick(columns, TEXT_COLUMNS, TEXT_TYPES)
|
|
395
|
+
model_field, model_expr = _model_source(columns)
|
|
396
|
+
|
|
397
|
+
reltuples = conn.execute(
|
|
398
|
+
"SELECT reltuples::bigint FROM pg_class WHERE oid = %s", (col.relid,)
|
|
399
|
+
).fetchone()
|
|
400
|
+
estimated = int(reltuples[0]) if reltuples and reltuples[0] >= 0 else None
|
|
401
|
+
|
|
402
|
+
sample = _sample(conn, col, ext, estimated, sample_size, text_field, model_expr)
|
|
403
|
+
if sample.method == "full table":
|
|
404
|
+
estimated = sample.rows
|
|
405
|
+
|
|
406
|
+
pk = conn.execute(
|
|
407
|
+
"SELECT EXISTS (SELECT 1 FROM pg_index WHERE indrelid = %s AND indisprimary)",
|
|
408
|
+
(col.relid,),
|
|
409
|
+
).fetchone()
|
|
410
|
+
wal = conn.execute("SELECT current_setting('wal_level')").fetchone()
|
|
411
|
+
|
|
412
|
+
return IndexProfile(
|
|
413
|
+
store="pgvector",
|
|
414
|
+
target=col.qualified,
|
|
415
|
+
vector_type=col.type,
|
|
416
|
+
declared_dimensions=col.dimensions,
|
|
417
|
+
estimated_rows=estimated,
|
|
418
|
+
sample=sample,
|
|
419
|
+
text_field=text_field,
|
|
420
|
+
model_field=sample.model_source or model_field,
|
|
421
|
+
updated_at_field=_pick(columns, UPDATED_AT_COLUMNS, TIMESTAMP_TYPES),
|
|
422
|
+
has_primary_key=bool(pk and pk[0]),
|
|
423
|
+
logical_replication=bool(wal and wal[0] == "logical"),
|
|
424
|
+
ann_indexes=_ann_indexes(conn, col),
|
|
425
|
+
max_indexable_dimensions=MAX_INDEXABLE_DIMENSIONS.get(col.type),
|
|
426
|
+
rows_hidden_by_access_rules=_rows_hidden(conn, col.relid),
|
|
427
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Read-only vector searches over a migration's old and new columns, for ``vecshift eval``.
|
|
2
|
+
|
|
3
|
+
Each search runs in its own read-only transaction with a statement timeout, and settings
|
|
4
|
+
such as ``hnsw.ef_search`` are set for that transaction only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import psycopg
|
|
13
|
+
from psycopg import sql
|
|
14
|
+
|
|
15
|
+
from vecshift.connectors.pgvector.target import OPCLASS_SUFFIX
|
|
16
|
+
|
|
17
|
+
Side = Literal["old", "new"]
|
|
18
|
+
|
|
19
|
+
OPERATORS = {"cosine": "<=>", "l2": "<->", "inner_product": "<#>"}
|
|
20
|
+
SETTINGS = {"hnsw": "hnsw.ef_search", "ivfflat": "ivfflat.probes"}
|
|
21
|
+
DEFAULTS = {"hnsw": 40, "ivfflat": 1}
|
|
22
|
+
SWEEP = {"hnsw": (40, 100, 200), "ivfflat": (1, 5, 10, 20)}
|
|
23
|
+
TIMEOUT_MS = 60_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PgSearcher:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
conn: psycopg.Connection,
|
|
30
|
+
*,
|
|
31
|
+
schema: str,
|
|
32
|
+
table: str,
|
|
33
|
+
pk: str,
|
|
34
|
+
columns: Mapping[Side, str],
|
|
35
|
+
types: Mapping[Side, str],
|
|
36
|
+
extension_schema: str,
|
|
37
|
+
metric: str,
|
|
38
|
+
partial: bool = False,
|
|
39
|
+
group: sql.Composable | None = None,
|
|
40
|
+
timeout_ms: int = TIMEOUT_MS,
|
|
41
|
+
) -> None:
|
|
42
|
+
if metric not in OPERATORS:
|
|
43
|
+
raise ValueError(f"unsupported metric {metric!r}")
|
|
44
|
+
if not set(types.values()) <= {"vector", "halfvec"}:
|
|
45
|
+
raise ValueError("unsupported vector type")
|
|
46
|
+
self.conn = conn
|
|
47
|
+
self.table = sql.Identifier(schema, table)
|
|
48
|
+
self.pk = sql.Identifier(pk)
|
|
49
|
+
self.columns = {side: sql.Identifier(name) for side, name in columns.items()}
|
|
50
|
+
self.names = dict(columns)
|
|
51
|
+
self.types = {side: sql.Identifier(extension_schema, kind) for side, kind in types.items()}
|
|
52
|
+
self.operator = sql.SQL("OPERATOR({}.{})").format(
|
|
53
|
+
sql.Identifier(extension_schema), sql.SQL(OPERATORS[metric])
|
|
54
|
+
)
|
|
55
|
+
self.metric = metric
|
|
56
|
+
self.partial = partial
|
|
57
|
+
self.group = group if group is not None else sql.SQL("NULL")
|
|
58
|
+
self.timeout_ms = int(timeout_ms)
|
|
59
|
+
self._indexes: dict[Side, tuple[str | None, int | None, list[int]]] = {}
|
|
60
|
+
|
|
61
|
+
# --- plumbing
|
|
62
|
+
|
|
63
|
+
def _run(
|
|
64
|
+
self, query: sql.SQL | sql.Composed, params: Sequence[Any], settings: Mapping[str, str]
|
|
65
|
+
) -> list[tuple[Any, ...]]:
|
|
66
|
+
try:
|
|
67
|
+
self.conn.execute(
|
|
68
|
+
"SELECT set_config('statement_timeout', %s, true)", (str(self.timeout_ms),)
|
|
69
|
+
)
|
|
70
|
+
for name, value in settings.items():
|
|
71
|
+
self.conn.execute("SELECT set_config(%s, %s, true)", (name, value))
|
|
72
|
+
return self.conn.execute(query, params).fetchall()
|
|
73
|
+
finally:
|
|
74
|
+
self.conn.rollback()
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _literal(vector: Sequence[float]) -> str:
|
|
78
|
+
return "[" + ",".join(repr(float(x)) for x in vector) + "]"
|
|
79
|
+
|
|
80
|
+
def _filter(self, side: Side) -> sql.Composable:
|
|
81
|
+
if self.partial:
|
|
82
|
+
return sql.SQL("{} IS NOT NULL AND {} IS NOT NULL").format(
|
|
83
|
+
self.columns["old"], self.columns["new"]
|
|
84
|
+
)
|
|
85
|
+
return sql.SQL("{} IS NOT NULL").format(self.columns[side])
|
|
86
|
+
|
|
87
|
+
def _query(
|
|
88
|
+
self, side: Side, *, where: sql.Composable | None, select: sql.Composable
|
|
89
|
+
) -> sql.Composed:
|
|
90
|
+
clause = sql.SQL(" WHERE {}").format(where) if where is not None else sql.SQL("")
|
|
91
|
+
return sql.SQL(
|
|
92
|
+
"SELECT {select} FROM {table}{where} ORDER BY {col} {op} %s::{type} LIMIT %s"
|
|
93
|
+
).format(
|
|
94
|
+
select=select,
|
|
95
|
+
table=self.table,
|
|
96
|
+
where=clause,
|
|
97
|
+
col=self.columns[side],
|
|
98
|
+
op=self.operator,
|
|
99
|
+
type=self.types[side],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# --- rows
|
|
103
|
+
|
|
104
|
+
def sample(self, text: str, size: int) -> list[tuple[str, str]]:
|
|
105
|
+
"""Up to ``size`` (key, text) rows spread across the table, with both vectors."""
|
|
106
|
+
text_id = sql.Identifier(text)
|
|
107
|
+
estimate = self._run(
|
|
108
|
+
sql.SQL("SELECT reltuples::bigint FROM pg_class WHERE oid = %s::regclass"),
|
|
109
|
+
(self.table.as_string(self.conn),),
|
|
110
|
+
{},
|
|
111
|
+
)
|
|
112
|
+
rows_estimate = int(estimate[0][0]) if estimate and estimate[0][0] > 0 else 0
|
|
113
|
+
tablesample: sql.Composable = sql.SQL("")
|
|
114
|
+
if rows_estimate > size * 4:
|
|
115
|
+
percent = min(100.0, 100.0 * size * 4 / rows_estimate)
|
|
116
|
+
tablesample = sql.SQL(" TABLESAMPLE SYSTEM ({}) REPEATABLE (7)").format(
|
|
117
|
+
sql.Literal(percent)
|
|
118
|
+
)
|
|
119
|
+
query = sql.SQL(
|
|
120
|
+
"SELECT {pk}::text, {t}::text FROM {table}{ts} WHERE {both} AND {t} IS NOT NULL "
|
|
121
|
+
"AND btrim({t}::text) <> '' ORDER BY md5({pk}::text) LIMIT %s"
|
|
122
|
+
).format(
|
|
123
|
+
pk=self.pk,
|
|
124
|
+
t=text_id,
|
|
125
|
+
table=self.table,
|
|
126
|
+
ts=tablesample,
|
|
127
|
+
both=sql.SQL("{} IS NOT NULL AND {} IS NOT NULL").format(
|
|
128
|
+
self.columns["old"], self.columns["new"]
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
return [(str(k), str(t)) for k, t in self._run(query, (size,), {})]
|
|
132
|
+
|
|
133
|
+
def texts(self, text: str, keys: Sequence[str]) -> dict[str, str]:
|
|
134
|
+
"""The text of the given rows, by key."""
|
|
135
|
+
if not keys:
|
|
136
|
+
return {}
|
|
137
|
+
query = sql.SQL(
|
|
138
|
+
"SELECT {pk}::text, {t}::text FROM {table} WHERE {pk}::text = ANY(%s)"
|
|
139
|
+
).format(pk=self.pk, t=sql.Identifier(text), table=self.table)
|
|
140
|
+
return {str(k): str(t or "") for k, t in self._run(query, (list(keys),), {})}
|
|
141
|
+
|
|
142
|
+
# --- searches
|
|
143
|
+
|
|
144
|
+
def _select(self, side: Side) -> sql.Composable:
|
|
145
|
+
return sql.SQL("{pk}::text, ({col} {op} %s::{type})::float8").format(
|
|
146
|
+
pk=self.pk, col=self.columns[side], op=self.operator, type=self.types[side]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def search(
|
|
150
|
+
self, side: Side, vector: Sequence[float], k: int, setting: int | None = None
|
|
151
|
+
) -> list[tuple[str, float]]:
|
|
152
|
+
"""Top ``k`` (key, distance) through the index, as the application's queries run."""
|
|
153
|
+
settings = {}
|
|
154
|
+
method = self.index(side)[0]
|
|
155
|
+
if setting is not None and method:
|
|
156
|
+
settings[SETTINGS[method]] = str(int(setting))
|
|
157
|
+
literal = self._literal(vector)
|
|
158
|
+
query = self._query(side, where=None, select=self._select(side))
|
|
159
|
+
rows = self._run(query, (literal, literal, k), settings)
|
|
160
|
+
return [(str(r[0]), float(r[1])) for r in rows]
|
|
161
|
+
|
|
162
|
+
def exact(self, side: Side, vector: Sequence[float], k: int) -> list[tuple[str, float]]:
|
|
163
|
+
"""Top ``k`` (key, distance) by scanning every row: what an index approximates."""
|
|
164
|
+
literal = self._literal(vector)
|
|
165
|
+
query = self._query(side, where=self._filter(side), select=self._select(side))
|
|
166
|
+
settings = {"enable_indexscan": "off", "enable_bitmapscan": "off"}
|
|
167
|
+
rows = self._run(query, (literal, literal, k), settings)
|
|
168
|
+
return [(str(r[0]), float(r[1])) for r in rows]
|
|
169
|
+
|
|
170
|
+
def index(self, side: Side) -> tuple[str | None, int | None, list[int]]:
|
|
171
|
+
"""The ANN index on a side's column, its current search setting, and a sweep."""
|
|
172
|
+
if side in self._indexes:
|
|
173
|
+
return self._indexes[side]
|
|
174
|
+
rows = self._run(
|
|
175
|
+
sql.SQL(
|
|
176
|
+
"""
|
|
177
|
+
SELECT am.amname, opc.opcname FROM pg_index i
|
|
178
|
+
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
179
|
+
JOIN pg_am am ON am.oid = ic.relam
|
|
180
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
181
|
+
JOIN pg_opclass opc ON opc.oid = i.indclass[0]
|
|
182
|
+
WHERE i.indrelid = %s::regclass AND a.attname = %s AND i.indisvalid
|
|
183
|
+
AND am.amname IN ('hnsw', 'ivfflat')
|
|
184
|
+
"""
|
|
185
|
+
),
|
|
186
|
+
(self.table.as_string(self.conn), self.names[side]),
|
|
187
|
+
{},
|
|
188
|
+
)
|
|
189
|
+
suffix = OPCLASS_SUFFIX[self.metric]
|
|
190
|
+
method = next((str(r[0]) for r in rows if str(r[1]).endswith(suffix)), None)
|
|
191
|
+
if method is None:
|
|
192
|
+
self._indexes[side] = (None, None, [])
|
|
193
|
+
return self._indexes[side]
|
|
194
|
+
current_row = self._run(
|
|
195
|
+
sql.SQL("SELECT current_setting(%s, true)"), (SETTINGS[method],), {}
|
|
196
|
+
)
|
|
197
|
+
raw = current_row[0][0] if current_row else None
|
|
198
|
+
current = int(raw) if raw and str(raw).isdigit() else DEFAULTS[method]
|
|
199
|
+
sweep = sorted({current, *SWEEP[method]})
|
|
200
|
+
self._indexes[side] = (method, current, sweep)
|
|
201
|
+
return self._indexes[side]
|
|
202
|
+
|
|
203
|
+
def neighbours(self, side: Side, key: str, k: int) -> tuple[str | None, list[tuple[str, str]]]:
|
|
204
|
+
"""A row's own group and its ``k`` nearest other rows, using that row's stored vector."""
|
|
205
|
+
own = self._run(
|
|
206
|
+
sql.SQL("SELECT {col}::text, ({group})::text FROM {table} WHERE {pk} = %s").format(
|
|
207
|
+
col=self.columns[side], group=self.group, table=self.table, pk=self.pk
|
|
208
|
+
),
|
|
209
|
+
(key,),
|
|
210
|
+
{},
|
|
211
|
+
)
|
|
212
|
+
if not own or own[0][0] is None:
|
|
213
|
+
return None, []
|
|
214
|
+
vector, group = own[0]
|
|
215
|
+
where = sql.SQL("{} <> %s AND {}").format(self.pk, self._filter(side))
|
|
216
|
+
query = sql.SQL(
|
|
217
|
+
"SELECT {pk}::text, ({group})::text FROM {table} WHERE {where} "
|
|
218
|
+
"ORDER BY {col} {op} %s::{type} LIMIT %s"
|
|
219
|
+
).format(
|
|
220
|
+
pk=self.pk,
|
|
221
|
+
group=self.group,
|
|
222
|
+
table=self.table,
|
|
223
|
+
where=where,
|
|
224
|
+
col=self.columns[side],
|
|
225
|
+
op=self.operator,
|
|
226
|
+
type=self.types[side],
|
|
227
|
+
)
|
|
228
|
+
settings = {"enable_indexscan": "off", "enable_bitmapscan": "off"} if self.partial else {}
|
|
229
|
+
rows = self._run(query, (key, vector, k), settings)
|
|
230
|
+
return group, [(str(r[0]), r[1]) for r in rows]
|
|
231
|
+
|
|
232
|
+
def vectors(self, side: Side, keys: Sequence[str]) -> list[list[float]]:
|
|
233
|
+
"""Stored vectors of the given rows."""
|
|
234
|
+
if not keys:
|
|
235
|
+
return []
|
|
236
|
+
query = sql.SQL(
|
|
237
|
+
"SELECT {col}::text FROM {table} WHERE {pk}::text = ANY(%s) AND {col} IS NOT NULL"
|
|
238
|
+
).format(col=self.columns[side], table=self.table, pk=self.pk)
|
|
239
|
+
rows = self._run(query, (list(keys),), {})
|
|
240
|
+
return [[float(x) for x in str(r[0]).strip("[]").split(",")] for r in rows]
|