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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. 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]