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
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
|