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
vecshift/cli_bench.py ADDED
@@ -0,0 +1,354 @@
1
+ """The ``vecshift bench`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Annotated
10
+
11
+ import typer
12
+
13
+ from vecshift import __version__
14
+ from vecshift.cli_style import warn_if_password_on_command_line
15
+
16
+ if TYPE_CHECKING:
17
+ from vecshift.bench import BenchResult, PlanItem
18
+ from vecshift.embeddings import ModelSpec
19
+
20
+ DEFAULT_MODELS = ["hash/256", "hash/1024"]
21
+
22
+
23
+ def _fail(message: str, hint: str | None = None) -> typer.Exit:
24
+ typer.secho(message, err=True, fg=typer.colors.RED)
25
+ if hint:
26
+ typer.echo(f"→ {hint}", err=True)
27
+ return typer.Exit(2)
28
+
29
+
30
+ def _note(message: str) -> None:
31
+ typer.secho(message, err=True, dim=True)
32
+
33
+
34
+ def money(value: float | None) -> str:
35
+ if value is None:
36
+ return "—"
37
+ if value == 0:
38
+ return "free"
39
+ if value < 0.01:
40
+ return "<$0.01"
41
+ return f"${value:,.2f}" if value < 1000 else f"${value:,.0f}"
42
+
43
+
44
+ def _ms(value: float | None) -> str:
45
+ if value is None:
46
+ return "—"
47
+ return f"{value:.2f} ms" if value < 1 else f"{value:,.1f} ms"
48
+
49
+
50
+ def _confirm(items: list[PlanItem], generator: ModelSpec | None, n_docs: int, yes: bool) -> None:
51
+ remote = [i for i in items if not i.spec.is_local]
52
+ remote_gen = generator is not None and not generator.is_local
53
+ typer.echo("Plan", err=True)
54
+ for item in items:
55
+ where = "local" if item.spec.is_local else (item.spec.url or "")
56
+ cost = money(item.est_cost) if item.est_cost is not None else "unknown price"
57
+ typer.echo(
58
+ f" {item.spec.name:<34} ~{item.est_tokens:,} tokens {cost:>10} {where}", err=True
59
+ )
60
+ if generator is not None:
61
+ typer.echo(f" {generator.name:<34} generates queries {generator.url}", err=True)
62
+ if not remote and not remote_gen:
63
+ return
64
+ typer.echo(
65
+ f"\nThis sends the text of {n_docs:,} sampled documents to the remote services above.",
66
+ err=True,
67
+ )
68
+ if yes:
69
+ return
70
+ if not sys.stdin.isatty():
71
+ raise _fail("Not sending data without confirmation.", "Pass --yes to proceed.")
72
+ if not typer.confirm("Continue?", err=True):
73
+ raise typer.Exit(1)
74
+
75
+
76
+ def _render(result: BenchResult) -> None:
77
+ from vecshift.bench.runner import ModelResult
78
+
79
+ typer.secho(
80
+ f"vecshift bench · {result.documents:,} documents, {result.queries:,} queries "
81
+ f"({result.query_source}) from {result.source}",
82
+ bold=True,
83
+ )
84
+ rows: list[tuple[str, ...]] = []
85
+ ranked = result.ranked()
86
+ for rank, m in enumerate(ranked, 1):
87
+ if m.scores is None:
88
+ continue
89
+ s = m.scores
90
+ speed = f"{m.docs_per_second:,.0f}" if m.docs_per_second else "cached"
91
+ cost = money(m.cost_per_million_docs)
92
+ if m.tokens_estimated and m.cost_per_million_docs:
93
+ cost = "~" + cost
94
+ rows.append(
95
+ (
96
+ str(rank),
97
+ m.name,
98
+ f"{s.recall_at_10:.3f}",
99
+ f"{s.recall_at_1:.3f}",
100
+ f"{s.mrr_at_10:.3f}",
101
+ _ms(m.query_ms_p50),
102
+ speed,
103
+ cost,
104
+ f"{m.gb_per_million_vectors:,.1f} GB" if m.gb_per_million_vectors else "—",
105
+ )
106
+ )
107
+ header = (
108
+ "#",
109
+ "Model",
110
+ "Recall@10",
111
+ "Recall@1",
112
+ "MRR@10",
113
+ "Query p50",
114
+ "Docs/s",
115
+ "Per 1M docs",
116
+ "Per 1M vecs",
117
+ )
118
+ if rows:
119
+ widths = [max(len(r[i]) for r in [header, *rows]) for i in range(len(header))]
120
+ right = {0, 2, 3, 4, 5, 6, 7, 8}
121
+
122
+ def line(cells: tuple[str, ...]) -> str:
123
+ return " ".join(
124
+ c.rjust(w) if i in right else c.ljust(w)
125
+ for i, (c, w) in enumerate(zip(cells, widths, strict=True))
126
+ )
127
+
128
+ typer.echo()
129
+ typer.secho(line(header), bold=True)
130
+ for r in rows:
131
+ typer.echo(line(r))
132
+
133
+ failed: list[ModelResult] = [m for m in ranked if m.error]
134
+ for m in failed:
135
+ typer.secho(f"\n✖ {m.name} failed: {m.error}", fg=typer.colors.RED)
136
+ for note in result.notes:
137
+ typer.secho(f"\n{note}", dim=True)
138
+
139
+
140
+ def bench(
141
+ ctx: typer.Context,
142
+ model: Annotated[
143
+ list[str] | None,
144
+ typer.Option(
145
+ "--model",
146
+ "-m",
147
+ help="Model to compare, as provider/model[,option=value]. Repeat for each model. "
148
+ "Defaults to two free hashing baselines.",
149
+ show_default=False,
150
+ ),
151
+ ] = None,
152
+ docs: Annotated[
153
+ Path | None,
154
+ typer.Option(help='JSONL file of documents, one {"id": ..., "text": ...} per line.'),
155
+ ] = None,
156
+ dsn: Annotated[
157
+ str | None,
158
+ typer.Option(
159
+ envvar=["VECSHIFT_DSN", "DATABASE_URL"],
160
+ help="PostgreSQL connection string, to sample documents from a pgvector table.",
161
+ show_default=False,
162
+ ),
163
+ ] = None,
164
+ table: Annotated[
165
+ str | None, typer.Option(help="Table to sample, as table or schema.table.")
166
+ ] = None,
167
+ column: Annotated[
168
+ str | None, typer.Option(help="Vector column, if the table has several.")
169
+ ] = None,
170
+ text_column: Annotated[str | None, typer.Option(help="Column holding the text.")] = None,
171
+ sample_size: Annotated[
172
+ int, typer.Option("--sample", min=10, max=100_000, help="Documents to sample.")
173
+ ] = 1000,
174
+ num_queries: Annotated[int, typer.Option(min=5, max=10_000, help="Queries to evaluate.")] = 100,
175
+ queries: Annotated[
176
+ Path | None,
177
+ typer.Option(help='JSONL of labeled queries: {"query": ..., "relevant": [ids]} per line.'),
178
+ ] = None,
179
+ generate_queries: Annotated[
180
+ str | None,
181
+ typer.Option(
182
+ help="Write realistic queries with this chat model, e.g. openai/gpt-4o-mini.",
183
+ show_default=False,
184
+ ),
185
+ ] = None,
186
+ save_queries: Annotated[
187
+ Path | None, typer.Option(help="Save generated queries here, to reuse with --queries.")
188
+ ] = None,
189
+ seed: Annotated[int, typer.Option(help="Random seed for sampling.")] = 7,
190
+ no_cache: Annotated[
191
+ bool, typer.Option("--no-cache", help="Don't read or write the embedding cache.")
192
+ ] = False,
193
+ yes: Annotated[
194
+ bool, typer.Option("--yes", "-y", help="Don't ask before sending data.")
195
+ ] = False,
196
+ output_json: Annotated[bool, typer.Option("--json", help="Print results as JSON.")] = False,
197
+ html: Annotated[
198
+ Path | None,
199
+ typer.Option("--html", dir_okay=False, help="Also write an HTML leaderboard to this file."),
200
+ ] = None,
201
+ ) -> None:
202
+ """Compare embedding models on a sample of your own data."""
203
+ warn_if_password_on_command_line(ctx, dsn)
204
+ try:
205
+ from vecshift.bench import Benchmark, CorpusError, Query, plan, run
206
+ from vecshift.bench import corpus as corpus_mod
207
+ from vecshift.embeddings import EmbeddingCache, SpecError, parse_spec
208
+ except ImportError as exc: # pragma: no cover - a broken install
209
+ raise _fail(
210
+ "A package vecshift needs is missing.",
211
+ "Reinstall: pip install --force-reinstall vecshift",
212
+ ) from exc
213
+
214
+ if queries and generate_queries:
215
+ raise _fail("Use either --queries or --generate-queries, not both.")
216
+ if save_queries and not generate_queries:
217
+ raise _fail(
218
+ "--save-queries only works with --generate-queries.",
219
+ "Proxy queries change the documents they come from, so they can't be reused.",
220
+ )
221
+ try:
222
+ specs = [parse_spec(m) for m in (model or DEFAULT_MODELS)]
223
+ generator_spec = parse_spec(generate_queries) if generate_queries else None
224
+ except SpecError as exc:
225
+ raise _fail(str(exc)) from exc
226
+ if not model:
227
+ _note(
228
+ "No --model given, so comparing two free hashing baselines. Add e.g. "
229
+ "-m openai/text-embedding-3-small -m ollama/nomic-embed-text"
230
+ )
231
+
232
+ try:
233
+ labeled = corpus_mod.load_queries(queries) if queries else None
234
+ wanted = {r for q in labeled for r in q.relevant} if labeled else set()
235
+ if docs is not None:
236
+ documents = corpus_mod.sample(
237
+ corpus_mod.load_documents(docs), sample_size, wanted, seed
238
+ )
239
+ source = docs.name
240
+ elif dsn:
241
+ documents, source = _from_database(dsn, table, column, text_column, sample_size, wanted)
242
+ else:
243
+ raise _fail(
244
+ "No documents to benchmark.",
245
+ "Pass --docs FILE, or a database with --dsn (or VECSHIFT_DSN) and --table.",
246
+ )
247
+ except CorpusError as exc:
248
+ raise _fail(str(exc)) from exc
249
+
250
+ notes: list[str] = []
251
+ if labeled is not None:
252
+ ids = {d.id for d in documents}
253
+ kept = [Query(q.text, q.relevant & ids) for q in labeled if q.relevant & ids]
254
+ if len(kept) < len(labeled):
255
+ notes.append(
256
+ f"{len(labeled) - len(kept)} labeled queries had no relevant document "
257
+ "in the corpus and were skipped."
258
+ )
259
+ if not kept:
260
+ raise _fail("None of the labeled queries' documents are in the corpus.")
261
+ benchmark = Benchmark(documents, kept, "labeled", notes)
262
+ elif generator_spec is not None:
263
+ benchmark = Benchmark(documents, [], "generated", notes)
264
+ else:
265
+ try:
266
+ edited, proxy, proxy_notes = corpus_mod.proxy_queries(documents, num_queries, seed)
267
+ except CorpusError as exc:
268
+ raise _fail(str(exc)) from exc
269
+ benchmark = Benchmark(edited, proxy, "proxy", proxy_notes)
270
+
271
+ if generator_spec is not None:
272
+ est = Benchmark(documents, [Query("x" * 60, frozenset())] * num_queries, "", [])
273
+ _confirm(plan(est, specs), generator_spec, len(documents), yes)
274
+ benchmark.queries = asyncio.run(_generate(generator_spec, documents, num_queries, seed))
275
+ if not benchmark.queries:
276
+ raise _fail("The chat model didn't return any queries.")
277
+ benchmark.notes.append(f"Queries were written by {generator_spec.name}.")
278
+ if save_queries:
279
+ corpus_mod.save_queries(save_queries, benchmark.queries)
280
+ _note(f"Saved {len(benchmark.queries)} queries to {save_queries}")
281
+ else:
282
+ _confirm(plan(benchmark, specs), None, len(benchmark.documents), yes)
283
+
284
+ cache = None if no_cache else EmbeddingCache()
285
+ result = asyncio.run(
286
+ run(benchmark, specs, cache, source, on_model=lambda s: _note(f"Embedding with {s.name}…"))
287
+ )
288
+ if cache:
289
+ cache.close()
290
+
291
+ if html is not None:
292
+ from vecshift.bench.html import render_html
293
+
294
+ try:
295
+ html.write_text(render_html(result, version=__version__), encoding="utf-8")
296
+ except OSError as exc:
297
+ raise _fail(f"Couldn't write {html}: {exc.strerror}") from exc
298
+ if output_json:
299
+ typer.echo(json.dumps(result.to_dict(), indent=2))
300
+ else:
301
+ _render(result)
302
+ if html is not None:
303
+ typer.echo(f"HTML leaderboard written to {html}", err=output_json)
304
+ if all(m.error for m in result.models):
305
+ raise typer.Exit(1)
306
+
307
+
308
+ async def _generate(spec: ModelSpec, documents: list, n: int, seed: int) -> list: # type: ignore[type-arg]
309
+ from vecshift.bench.generate import GenerationError, QueryGenerator
310
+
311
+ try:
312
+ generator = QueryGenerator(spec)
313
+ except GenerationError as exc:
314
+ raise _fail(str(exc)) from exc
315
+ try:
316
+ return await generator.generate(documents, n, seed)
317
+ except GenerationError as exc:
318
+ raise _fail(str(exc)) from exc
319
+ finally:
320
+ await generator.aclose()
321
+
322
+
323
+ def _from_database(
324
+ dsn: str,
325
+ table: str | None,
326
+ column: str | None,
327
+ text_column: str | None,
328
+ size: int,
329
+ wanted: set[str],
330
+ ) -> tuple[list, str]: # type: ignore[type-arg]
331
+ try:
332
+ from vecshift.connectors import pgvector
333
+ from vecshift.connectors.pgvector.documents import sample_documents
334
+ except ImportError as exc: # pragma: no cover
335
+ raise _fail(
336
+ "The PostgreSQL driver is missing.",
337
+ "Reinstall: pip install --force-reinstall vecshift",
338
+ ) from exc
339
+ from vecshift.bench import Document
340
+ from vecshift.bench.corpus import clip
341
+
342
+ try:
343
+ settings = pgvector.prepare(dsn)
344
+ conn = pgvector.connect(settings)
345
+ except pgvector.ConnectError as exc:
346
+ raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
347
+ try:
348
+ rows, name = sample_documents(conn, table, column, text_column, size, wanted)
349
+ except pgvector.TargetSelectionError as exc:
350
+ raise _fail(str(exc)) from exc
351
+ finally:
352
+ conn.rollback()
353
+ conn.close()
354
+ return [Document(i, clip(t)) for i, t in rows], name