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,493 @@
1
+ """Turn a job, what's in the database, and what's known about the model into a plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from dataclasses import dataclass
8
+
9
+ from vecshift.connectors.pgvector.inspect import MAX_INDEXABLE_DIMENSIONS
10
+ from vecshift.connectors.pgvector.target import TargetState, add_column_sql, index_sql
11
+ from vecshift.doctor.findings import Finding, Severity
12
+ from vecshift.doctor.profile import IndexProfile
13
+ from vecshift.embeddings.spec import ModelSpec, parse_spec
14
+ from vecshift.jobs.spec import IndexMethod, JobSpec
15
+ from vecshift.planning.plan import Change, Estimates, Plan, ProbeResult
16
+
17
+ CHARS_PER_TOKEN = 4.0
18
+ """Rough English average, used when there's no probe to measure the real ratio."""
19
+
20
+ HNSW_BYTES_PER_ELEMENT = 200
21
+ """Rough per-row overhead of an HNSW graph (m=16) on top of the vector itself."""
22
+
23
+ # Doctor findings that matter for a migration, carried into the plan unchanged.
24
+ # Apply keeps the new column in sync with a trigger, so doctor's change-tracking findings
25
+ # don't apply; row-level security still matters.
26
+ CARRIED = {"access.row_security"}
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SampleStats:
31
+ documents: int
32
+ """Sampled rows that have text."""
33
+ average_chars: float
34
+
35
+
36
+ def _bytes_per_vector(vector_type: str, dims: int) -> int:
37
+ return dims * (2 if vector_type == "halfvec" else 4) + 8
38
+
39
+
40
+ def _metric(job: JobSpec, profile: IndexProfile) -> str:
41
+ if job.target.metric:
42
+ return job.target.metric.value
43
+ for index in profile.ann_indexes:
44
+ if index.metric in {"cosine", "inner_product", "l2"}:
45
+ return index.metric
46
+ return "cosine"
47
+
48
+
49
+ def _resolve_dimensions(
50
+ spec: ModelSpec, probe: ProbeResult | None
51
+ ) -> tuple[int | None, str | None]:
52
+ if probe:
53
+ return probe.dimensions, "probe"
54
+ if spec.dimensions:
55
+ return spec.dimensions, "spec"
56
+ if spec.native_dimensions:
57
+ return spec.native_dimensions, "known"
58
+ return None, None
59
+
60
+
61
+ def _model_findings(spec: ModelSpec, findings: list[Finding]) -> None:
62
+ native = spec.native_dimensions
63
+ if (
64
+ spec.dimensions
65
+ and spec.provider == "openai"
66
+ and spec.base_model
67
+ not in {
68
+ "text-embedding-3-small",
69
+ "text-embedding-3-large",
70
+ }
71
+ ):
72
+ findings.append(
73
+ Finding(
74
+ "plan.dims_unsupported",
75
+ Severity.ERROR,
76
+ "This model can't shorten its vectors",
77
+ f"{spec.model} doesn't accept a dims= setting; only the text-embedding-3 "
78
+ "models do.",
79
+ hint="Remove dims= from the model spec.",
80
+ )
81
+ )
82
+ elif spec.dimensions and native and spec.dimensions > native:
83
+ findings.append(
84
+ Finding(
85
+ "plan.dims_too_large",
86
+ Severity.ERROR,
87
+ "dims= is larger than the model's output",
88
+ f"{spec.model} produces {native:,} dimensions, so it can't return "
89
+ f"{spec.dimensions:,}.",
90
+ )
91
+ )
92
+
93
+ if spec.provider == "hash":
94
+ findings.append(
95
+ Finding(
96
+ "plan.baseline_model",
97
+ Severity.WARNING,
98
+ "This is a test model",
99
+ f"{spec.name} is vecshift's built-in hashing baseline: it matches words, not "
100
+ "meaning. It's for trying vecshift out, not for real searches.",
101
+ hint="Use a real embedding model for production data.",
102
+ )
103
+ )
104
+
105
+ if spec.provider == "openai" and spec.key_env and not os.environ.get(spec.key_env):
106
+ findings.append(
107
+ Finding(
108
+ "plan.missing_key",
109
+ Severity.ERROR,
110
+ "No API key for the model",
111
+ f"{spec.key_env} isn't set, so apply couldn't call {spec.name}.",
112
+ hint=f"Export {spec.key_env} in the environment that runs vecshift.",
113
+ )
114
+ )
115
+
116
+
117
+ def build_plan(
118
+ job: JobSpec,
119
+ profile: IndexProfile,
120
+ target: TargetState,
121
+ sample: SampleStats,
122
+ probe: ProbeResult | None = None,
123
+ ) -> Plan:
124
+ spec = parse_spec(job.model)
125
+ vector_type = job.target.vector_type.value
126
+ dims, dims_source = _resolve_dimensions(spec, probe)
127
+ metric = _metric(job, profile)
128
+ findings: list[Finding] = []
129
+
130
+ old_size = profile.declared_dimensions
131
+ source_type = f"{profile.vector_type}({old_size})" if old_size else profile.vector_type
132
+ rows_known = f", ~{profile.estimated_rows:,} rows" if profile.estimated_rows is not None else ""
133
+ plan = Plan(
134
+ job=job.name,
135
+ source=f"{profile.target} {source_type}{rows_known}",
136
+ target_column=job.target.column,
137
+ model=spec.name,
138
+ dimensions=dims,
139
+ dimensions_source=dims_source,
140
+ vector_type=vector_type,
141
+ metric=metric,
142
+ )
143
+
144
+ # --- The source has to be re-embeddable and addressable.
145
+ if profile.text_field is None:
146
+ findings.append(
147
+ Finding(
148
+ "plan.no_text",
149
+ Severity.ERROR,
150
+ "No source text to embed",
151
+ "No text column was found next to the vectors, so there's nothing to re-embed.",
152
+ hint="Set source.text_column in the job file if the text has an unusual name.",
153
+ )
154
+ )
155
+ elif (
156
+ profile.sample.texts_present is not None
157
+ and profile.sample.texts_present < profile.sample.rows
158
+ ):
159
+ missing = profile.sample.rows - profile.sample.texts_present
160
+ findings.append(
161
+ Finding(
162
+ "plan.partial_text",
163
+ Severity.WARNING,
164
+ "Some rows have no text",
165
+ f"{missing:,} of {profile.sample.rows:,} sampled rows have no text in "
166
+ f"`{profile.text_field}`. Their new vector stays empty, so search won't find "
167
+ "them after cutover.",
168
+ )
169
+ )
170
+ if not profile.has_primary_key:
171
+ findings.append(
172
+ Finding(
173
+ "plan.no_primary_key",
174
+ Severity.ERROR,
175
+ "No primary key",
176
+ "Apply writes each new vector back to its row and resumes after "
177
+ "interruptions, which needs a stable ID per row.",
178
+ hint="Add a primary key to the table first.",
179
+ )
180
+ )
181
+
182
+ if profile.has_primary_key and len(target.primary_key) > 1:
183
+ findings.append(
184
+ Finding(
185
+ "plan.composite_primary_key",
186
+ Severity.ERROR,
187
+ "Composite primary key",
188
+ f"The primary key spans {', '.join(target.primary_key)}. Apply writes each "
189
+ "vector back by a single ID column.",
190
+ hint="Add a single-column unique ID, or migrate a view-free copy of the table.",
191
+ )
192
+ )
193
+
194
+ # --- The target column.
195
+ if not target.owner:
196
+ findings.append(
197
+ Finding(
198
+ "plan.not_owner",
199
+ Severity.ERROR,
200
+ "Can't add a column to this table",
201
+ "Adding the new vector column needs the table's owner (or a member of the "
202
+ "owning role), and the connecting role isn't.",
203
+ hint="Connect as the table owner. On Supabase that's usually postgres.",
204
+ )
205
+ )
206
+ if target.partitioned:
207
+ findings.append(
208
+ Finding(
209
+ "plan.partitioned",
210
+ Severity.ERROR,
211
+ "Partitioned tables aren't supported yet",
212
+ "PostgreSQL can't build an index concurrently on a partitioned table, and apply "
213
+ "only builds indexes that way so writes never block.",
214
+ hint="Migrate each partition as its own table for now.",
215
+ )
216
+ )
217
+ if target.row_security:
218
+ findings.append(
219
+ Finding(
220
+ "plan.row_security",
221
+ Severity.ERROR,
222
+ "Row-level security limits which rows apply can reach",
223
+ "The connecting role is subject to the table's policies (the table forces "
224
+ "row-level security, or the role isn't its owner), so apply could skip rows "
225
+ "it can't see and still report success.",
226
+ hint="Connect as a role with BYPASSRLS, or turn off FORCE ROW LEVEL SECURITY "
227
+ "for the migration.",
228
+ )
229
+ )
230
+ if target.previous_column_exists and not target.column_exists:
231
+ findings.append(
232
+ Finding(
233
+ "plan.cut_over",
234
+ Severity.ERROR,
235
+ "This table was already cut over",
236
+ f"`{target.source.column}_old` holds the vectors from before the last cutover.",
237
+ hint="Run vecshift rollback to undo it, or vecshift cleanup when you're sure, "
238
+ "before starting another migration.",
239
+ )
240
+ )
241
+ if target.column_exists:
242
+ same = target.column_type == vector_type and (
243
+ dims is None or target.column_dimensions == dims
244
+ )
245
+ if same:
246
+ findings.append(
247
+ Finding(
248
+ "plan.column_exists",
249
+ Severity.INFO,
250
+ "Target column already exists",
251
+ f"`{job.target.column}` is already a matching `{vector_type}` column, so "
252
+ "apply will fill in the rows it's missing.",
253
+ )
254
+ )
255
+ else:
256
+ have = f"{target.column_type}({target.column_dimensions or '?'})"
257
+ want = f"{vector_type}({dims or '?'})"
258
+ findings.append(
259
+ Finding(
260
+ "plan.column_conflict",
261
+ Severity.ERROR,
262
+ "Target column exists with a different type",
263
+ f"`{job.target.column}` is `{have}`, but this job needs `{want}`.",
264
+ hint="Pick another target.column, or drop the existing one if it's unused.",
265
+ )
266
+ )
267
+
268
+ # --- The model.
269
+ _model_findings(spec, findings)
270
+ if dims is None:
271
+ findings.append(
272
+ Finding(
273
+ "plan.dims_unknown",
274
+ Severity.WARNING,
275
+ "Vector size unknown",
276
+ f"vecshift doesn't know how many dimensions {spec.name} produces, so it can't "
277
+ "size the new column or estimate storage.",
278
+ hint="Add dims= to the model spec, or run plan with --probe to measure it.",
279
+ )
280
+ )
281
+ recorded = set(profile.sample.models)
282
+ lowered = {m.lower() for m in recorded}
283
+ tag_prefix = f"{spec.provider}/{spec.model}@".lower()
284
+ same = spec.model in recorded or spec.base_model in lowered
285
+ same = same or any(m.startswith(tag_prefix) for m in lowered)
286
+ if len(recorded) == 1 and same:
287
+ findings.append(
288
+ Finding(
289
+ "plan.same_model",
290
+ Severity.WARNING,
291
+ "The table already uses this model",
292
+ f"Rows record `{next(iter(recorded))}` in `{profile.model_field}`, the same "
293
+ "model this job would switch to.",
294
+ hint="Check the model in the job file. Re-embedding with the same model "
295
+ "only makes sense to change dims= or the prefixes.",
296
+ )
297
+ )
298
+
299
+ limit = MAX_INDEXABLE_DIMENSIONS.get(vector_type)
300
+ if dims and limit and dims > limit and job.target.index is not IndexMethod.NONE:
301
+ halfvec_ok = vector_type == "vector" and dims <= MAX_INDEXABLE_DIMENSIONS["halfvec"]
302
+ findings.append(
303
+ Finding(
304
+ "plan.too_large_to_index",
305
+ Severity.ERROR,
306
+ "Too many dimensions to index",
307
+ f"pgvector can index `{vector_type}` columns up to {limit:,} dimensions, and "
308
+ f"this model produces {dims:,}.",
309
+ hint="Set target.vector_type: halfvec (up to 4,000 dimensions, half the storage)."
310
+ if halfvec_ok
311
+ else "Add dims= to the model spec to shorten the vectors, if the model allows.",
312
+ )
313
+ )
314
+
315
+ # --- Doctor's view of change tracking and access.
316
+ from vecshift.doctor.checks import run_checks
317
+
318
+ for finding in run_checks(profile).findings:
319
+ if finding.id in CARRIED and not target.row_security:
320
+ findings.append(finding)
321
+
322
+ # --- Estimates.
323
+ est = Estimates(maintenance_work_mem=target.maintenance_work_mem)
324
+ share = (
325
+ (profile.sample.texts_present or 0) / profile.sample.rows if profile.sample.rows else 0.0
326
+ )
327
+ if profile.estimated_rows is not None:
328
+ est.rows = round(profile.estimated_rows * share)
329
+ est.rows_exact = profile.sample.method == "full table"
330
+ if est.rows is not None:
331
+ tokens_per_char = probe.tokens_per_char if probe else 1 / CHARS_PER_TOKEN
332
+ est.tokens = round(est.rows * sample.average_chars * tokens_per_char)
333
+ est.tokens_method = (
334
+ f"measured on {probe.documents} documents"
335
+ if probe
336
+ else f"about {CHARS_PER_TOKEN:g} characters per token"
337
+ )
338
+ est.requests = math.ceil(est.rows / spec.batch_size) if est.rows else 0
339
+ if spec.price is not None:
340
+ est.cost_usd = est.tokens * spec.price / 1_000_000
341
+
342
+ durations: list[tuple[float, str]] = []
343
+ if job.limits.tokens_per_minute:
344
+ durations.append(
345
+ (60 * est.tokens / job.limits.tokens_per_minute, "your tokens-per-minute limit")
346
+ )
347
+ if job.limits.requests_per_minute:
348
+ durations.append(
349
+ (
350
+ 60 * est.requests / job.limits.requests_per_minute,
351
+ "your requests-per-minute limit",
352
+ )
353
+ )
354
+ if probe and probe.docs_per_second > 0:
355
+ durations.append((est.rows / probe.docs_per_second, "speed measured by the probe"))
356
+ if durations:
357
+ est.seconds, est.seconds_method = max(durations)
358
+
359
+ if dims:
360
+ per_vector = _bytes_per_vector(vector_type, dims)
361
+ est.new_bytes = est.rows * per_vector
362
+ if job.target.index is IndexMethod.HNSW:
363
+ est.index_memory_bytes = est.rows * (per_vector + HNSW_BYTES_PER_ELEMENT)
364
+
365
+ old_dims = profile.declared_dimensions or (
366
+ profile.sample.dimensions.most_common(1)[0][0] if profile.sample.dimensions else None
367
+ )
368
+ if old_dims and profile.estimated_rows is not None:
369
+ with_vectors = profile.sample.vectors / profile.sample.rows if profile.sample.rows else 0
370
+ est.old_bytes = round(
371
+ profile.estimated_rows * with_vectors * _bytes_per_vector(profile.vector_type, old_dims)
372
+ )
373
+ plan.estimates = est
374
+
375
+ if job.limits.budget_usd is not None and est.cost_usd is not None:
376
+ if est.cost_usd > job.limits.budget_usd:
377
+ findings.append(
378
+ Finding(
379
+ "plan.over_budget",
380
+ Severity.ERROR,
381
+ "Estimated cost is over budget",
382
+ f"Embedding is estimated at ${est.cost_usd:,.2f}, above the "
383
+ f"${job.limits.budget_usd:,.2f} budget.",
384
+ hint="Raise limits.budget_usd, or use a cheaper model or fewer dims.",
385
+ )
386
+ )
387
+ elif est.cost_usd and job.limits.budget_usd is None:
388
+ findings.append(
389
+ Finding(
390
+ "plan.no_budget",
391
+ Severity.INFO,
392
+ "No budget set",
393
+ "Without limits.budget_usd, nothing stops a run that costs more than expected.",
394
+ hint=f"Add limits.budget_usd, for example {max(1, math.ceil(est.cost_usd * 1.5))}.",
395
+ )
396
+ )
397
+ if spec.price is None:
398
+ findings.append(
399
+ Finding(
400
+ "plan.cost_unknown",
401
+ Severity.INFO,
402
+ "Cost unknown",
403
+ f"There's no price for {spec.name}, so the plan can't estimate cost or "
404
+ "enforce a budget.",
405
+ hint="Add price= (USD per million tokens) to the model spec.",
406
+ )
407
+ )
408
+ if (
409
+ est.index_memory_bytes
410
+ and target.maintenance_work_mem
411
+ and est.index_memory_bytes > target.maintenance_work_mem
412
+ ):
413
+ need_mb = math.ceil(est.index_memory_bytes * 1.25 / 1024**2 / 64) * 64
414
+ need = f"{need_mb}MB" if need_mb < 1024 else f"{math.ceil(need_mb / 1024)}GB"
415
+ findings.append(
416
+ Finding(
417
+ "plan.index_memory",
418
+ Severity.WARNING,
419
+ "The index build won't fit in memory",
420
+ f"An HNSW index this size needs about {_size(est.index_memory_bytes)}, and "
421
+ f"maintenance_work_mem is {_size(target.maintenance_work_mem)}. pgvector builds "
422
+ "much more slowly once the graph no longer fits.",
423
+ hint=f"Raise it for the build, e.g. SET maintenance_work_mem = '{need}', if "
424
+ "the server has the memory to spare. In Docker, also give the container that "
425
+ "much shared memory (--shm-size), which parallel builds use.",
426
+ )
427
+ )
428
+
429
+ # --- What apply would do.
430
+ if dims and not target.column_exists:
431
+ plan.changes.append(
432
+ Change(
433
+ "add_column",
434
+ f"add column {target.source.schema}.{target.source.table}."
435
+ f"{job.target.column} {vector_type}({dims})",
436
+ add_column_sql(target, job.target.column, vector_type, dims),
437
+ "Instant for any table size: a nullable column without a default doesn't "
438
+ "rewrite rows. It takes a brief exclusive lock.",
439
+ )
440
+ )
441
+ if profile.text_field:
442
+ plan.changes.append(
443
+ Change(
444
+ "trigger",
445
+ f"add a trigger that clears {job.target.column} when {profile.text_field} changes",
446
+ note="So rows edited during the migration get re-embedded. Cutover moves it to "
447
+ "the old column, and cleanup removes it.",
448
+ )
449
+ )
450
+ rows_text = f"~{est.rows:,}" if est.rows is not None else "all"
451
+ plan.changes.append(
452
+ Change(
453
+ "embed",
454
+ f"embed {rows_text} rows from {profile.text_field or '?'} with {spec.name}",
455
+ note="In batches, resumable, without blocking reads or writes. Run apply again "
456
+ "any time to catch up rows added or changed since.",
457
+ )
458
+ )
459
+ if job.target.index is not IndexMethod.NONE and dims:
460
+ plan.changes.append(
461
+ Change(
462
+ "index",
463
+ f"build a {job.target.index.value} index ({metric}) on {job.target.column}",
464
+ index_sql(
465
+ target,
466
+ job.target.column,
467
+ vector_type,
468
+ job.target.index.value,
469
+ metric,
470
+ est.rows or 0,
471
+ ),
472
+ "Built after the backfill and concurrently, so writes continue.",
473
+ )
474
+ )
475
+ old = target.source.column
476
+ plan.changes.append(
477
+ Change(
478
+ "cutover",
479
+ f"later, with vecshift cutover: {old} → {old}_old, {job.target.column} → {old}",
480
+ note="Renames in one transaction, so searches switch atomically and your SQL keeps "
481
+ "working. Rollback renames them back; cleanup drops the old column.",
482
+ )
483
+ )
484
+
485
+ plan.findings = findings
486
+ return plan
487
+
488
+
489
+ def _size(n: int) -> str:
490
+ for unit, size in (("TB", 1024**4), ("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)):
491
+ if n >= size:
492
+ return f"{n / size:,.1f} {unit}"
493
+ return f"{n} B"
vecshift/py.typed ADDED
File without changes