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_eval.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""The ``vecshift eval`` command: is the new model better on your data, and how fast is it?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from vecshift.cli_plan import DEFAULT_JOB, _fail, load
|
|
15
|
+
from vecshift.migrate.engine import SETTLED_ROWS, SETTLED_SHARE
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import psycopg
|
|
19
|
+
from psycopg import sql
|
|
20
|
+
|
|
21
|
+
from vecshift.connectors.pgvector.search import PgSearcher
|
|
22
|
+
from vecshift.embeddings import ModelSpec
|
|
23
|
+
from vecshift.eval import EvalQuery, EvalReport, SideReport
|
|
24
|
+
|
|
25
|
+
EXIT_INCONCLUSIVE = 3
|
|
26
|
+
SETUP_TIMEOUT_MS = 10 * 60_000
|
|
27
|
+
GROUP_COLUMNS = ("document_id", "doc_id", "parent_id", "source_id", "file_id", "source", "url")
|
|
28
|
+
GROUP_KEYS = (*GROUP_COLUMNS, "file_name", "filename", "path", "title")
|
|
29
|
+
OTHER_LANGUAGE = {"arabic": "English", "latin": "Arabic"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class _Columns:
|
|
34
|
+
old: str
|
|
35
|
+
new: str
|
|
36
|
+
stage: str
|
|
37
|
+
"""``applied`` (before cutover) or ``cut_over``."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _columns(conn: psycopg.Connection, relid: int, live: str, target: str) -> _Columns:
|
|
41
|
+
from vecshift.connectors.pgvector.inspect import _columns as table_columns
|
|
42
|
+
|
|
43
|
+
names = table_columns(conn, relid)
|
|
44
|
+
previous = f"{live}_old"[:63]
|
|
45
|
+
if target in names:
|
|
46
|
+
return _Columns(live, target, "applied")
|
|
47
|
+
if previous in names:
|
|
48
|
+
return _Columns(previous, live, "cut_over")
|
|
49
|
+
raise _fail(f"There are no new vectors to evaluate yet: {target} doesn't exist.", "Run apply.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _metric(conn: psycopg.Connection, relid: int, column: str, configured: str | None) -> str:
|
|
53
|
+
if configured:
|
|
54
|
+
return configured
|
|
55
|
+
rows = conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
SELECT opc.opcname FROM pg_index i
|
|
58
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
59
|
+
JOIN pg_opclass opc ON opc.oid = i.indclass[0]
|
|
60
|
+
WHERE i.indrelid = %s AND a.attname = %s
|
|
61
|
+
""",
|
|
62
|
+
(relid, column),
|
|
63
|
+
).fetchall()
|
|
64
|
+
for (name,) in rows:
|
|
65
|
+
for metric, suffix in (("cosine", "cosine_ops"), ("inner_product", "ip_ops")):
|
|
66
|
+
if str(name).endswith(suffix):
|
|
67
|
+
return metric
|
|
68
|
+
if str(name).endswith("l2_ops"):
|
|
69
|
+
return "l2"
|
|
70
|
+
return "cosine"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _group(
|
|
74
|
+
conn: psycopg.Connection, relid: int, table: sql.Composable, option: str | None
|
|
75
|
+
) -> tuple[sql.Composable | None, str | None]:
|
|
76
|
+
"""How to tell which rows belong to the same document, and its name for the report."""
|
|
77
|
+
from psycopg import sql
|
|
78
|
+
|
|
79
|
+
from vecshift.connectors.pgvector.inspect import _columns as table_columns
|
|
80
|
+
|
|
81
|
+
names = table_columns(conn, relid)
|
|
82
|
+
if option:
|
|
83
|
+
column, _, key = option.partition(".")
|
|
84
|
+
if column not in names:
|
|
85
|
+
raise _fail(f"--group-by: there's no column {column!r}.")
|
|
86
|
+
if key:
|
|
87
|
+
if names[column] not in {"jsonb", "json"}:
|
|
88
|
+
raise _fail(f"--group-by: {column} isn't a JSON column.")
|
|
89
|
+
return sql.SQL("{}->>{}").format(sql.Identifier(column), sql.Literal(key)), option
|
|
90
|
+
return sql.Identifier(column), option
|
|
91
|
+
for column in GROUP_COLUMNS:
|
|
92
|
+
if column in names:
|
|
93
|
+
return sql.Identifier(column), column
|
|
94
|
+
for column, kind in names.items():
|
|
95
|
+
if kind != "jsonb":
|
|
96
|
+
continue
|
|
97
|
+
rows = conn.execute(
|
|
98
|
+
sql.SQL(
|
|
99
|
+
"SELECT DISTINCT jsonb_object_keys({c}) FROM (SELECT {c} FROM {t} "
|
|
100
|
+
"WHERE jsonb_typeof({c}) = 'object' LIMIT 200) s"
|
|
101
|
+
).format(c=sql.Identifier(column), t=table)
|
|
102
|
+
).fetchall()
|
|
103
|
+
keys = {str(r[0]) for r in rows}
|
|
104
|
+
for key in GROUP_KEYS:
|
|
105
|
+
if key in keys:
|
|
106
|
+
return (
|
|
107
|
+
sql.SQL("{}->>{}").format(sql.Identifier(column), sql.Literal(key)),
|
|
108
|
+
f"{column}.{key}",
|
|
109
|
+
)
|
|
110
|
+
return None, None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _working(spec: ModelSpec, dims: int | None) -> tuple[Any | None, str | None]:
|
|
114
|
+
"""An embedder for ``spec`` if it answers and matches the column, else why not."""
|
|
115
|
+
from vecshift.embeddings import EmbeddingError, create_embedder
|
|
116
|
+
|
|
117
|
+
embedder = create_embedder(spec)
|
|
118
|
+
try:
|
|
119
|
+
vector = (await embedder.embed(["a short test query"], "query"))[0]
|
|
120
|
+
except EmbeddingError as exc:
|
|
121
|
+
await embedder.aclose()
|
|
122
|
+
return None, f"{spec.name} couldn't embed a test query: {exc}"
|
|
123
|
+
if dims and len(vector) != dims:
|
|
124
|
+
await embedder.aclose()
|
|
125
|
+
return None, (
|
|
126
|
+
f"{spec.name} returns {len(vector)} dimensions, but the column holds {dims}: "
|
|
127
|
+
"is that the model that made these vectors?"
|
|
128
|
+
)
|
|
129
|
+
return embedder, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _queries(
|
|
133
|
+
searcher: PgSearcher,
|
|
134
|
+
text: str,
|
|
135
|
+
rows: list[tuple[str, str]],
|
|
136
|
+
*,
|
|
137
|
+
count: int,
|
|
138
|
+
seed: int,
|
|
139
|
+
file: Path | None,
|
|
140
|
+
generator: ModelSpec | None,
|
|
141
|
+
cross_language: bool,
|
|
142
|
+
) -> tuple[list[EvalQuery], str, list[str]]:
|
|
143
|
+
from vecshift.bench.corpus import CorpusError, Document, load_queries
|
|
144
|
+
from vecshift.bench.generate import GenerationError, QueryGenerator
|
|
145
|
+
from vecshift.eval import EvalQuery, proxy_queries, script
|
|
146
|
+
|
|
147
|
+
if file is not None:
|
|
148
|
+
try:
|
|
149
|
+
labeled = load_queries(file)[:count]
|
|
150
|
+
except CorpusError as exc:
|
|
151
|
+
raise _fail(str(exc)) from exc
|
|
152
|
+
keys = sorted({k for q in labeled for k in q.relevant})
|
|
153
|
+
texts = searcher.texts(text, keys)
|
|
154
|
+
queries = [
|
|
155
|
+
EvalQuery(
|
|
156
|
+
q.text,
|
|
157
|
+
q.relevant,
|
|
158
|
+
script(q.text),
|
|
159
|
+
script(" ".join(texts.get(k, "") for k in sorted(q.relevant))),
|
|
160
|
+
)
|
|
161
|
+
for q in labeled
|
|
162
|
+
]
|
|
163
|
+
missing = [k for k in keys if k not in texts]
|
|
164
|
+
notes = [f"{len(missing)} labeled answers aren't in the table."] if missing else []
|
|
165
|
+
return queries, "labeled", notes
|
|
166
|
+
if generator is not None:
|
|
167
|
+
docs = [Document(key, body) for key, body in rows]
|
|
168
|
+
scripts = {key: script(body) for key, body in rows}
|
|
169
|
+
|
|
170
|
+
def language(doc: Document) -> str | None:
|
|
171
|
+
return OTHER_LANGUAGE.get(scripts[doc.id]) if cross_language else None
|
|
172
|
+
|
|
173
|
+
async def make() -> list[Any]:
|
|
174
|
+
gen = QueryGenerator(generator)
|
|
175
|
+
try:
|
|
176
|
+
return await gen.generate(docs, count, seed, language)
|
|
177
|
+
finally:
|
|
178
|
+
await gen.aclose()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
made = asyncio.run(make())
|
|
182
|
+
except GenerationError as exc:
|
|
183
|
+
raise _fail(str(exc)) from exc
|
|
184
|
+
queries = [
|
|
185
|
+
EvalQuery(q.text, q.relevant, script(q.text), scripts[next(iter(q.relevant))])
|
|
186
|
+
for q in made
|
|
187
|
+
]
|
|
188
|
+
return queries, "cross-language" if cross_language else "generated", []
|
|
189
|
+
queries, notes = proxy_queries(rows, count, seed)
|
|
190
|
+
return queries, "proxy", notes
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _confirm(sends: list[tuple[str, str]], count: int, yes: bool) -> None:
|
|
194
|
+
remote = [(name, url) for name, url in sends if url]
|
|
195
|
+
if not remote:
|
|
196
|
+
return
|
|
197
|
+
out = sys.stderr
|
|
198
|
+
print("eval sends text to:", file=out)
|
|
199
|
+
for name, url in remote:
|
|
200
|
+
print(f" {name:<40} {url}", file=out)
|
|
201
|
+
print(
|
|
202
|
+
f"About {count:,} short queries (sentences from your rows, or generated from them).",
|
|
203
|
+
file=out,
|
|
204
|
+
)
|
|
205
|
+
if yes:
|
|
206
|
+
return
|
|
207
|
+
if not sys.stdin.isatty():
|
|
208
|
+
raise _fail("Not sending data without confirmation.", "Pass --yes to proceed.")
|
|
209
|
+
if not typer.confirm("Continue?", err=True):
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def eval_(
|
|
214
|
+
job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
|
|
215
|
+
num_queries: Annotated[int, typer.Option(min=5, max=10_000, help="Queries to evaluate.")] = 200,
|
|
216
|
+
queries: Annotated[
|
|
217
|
+
Path | None,
|
|
218
|
+
typer.Option(help='JSONL of labeled queries: {"query": ..., "relevant": [ids]} per line.'),
|
|
219
|
+
] = None,
|
|
220
|
+
generate_queries: Annotated[
|
|
221
|
+
str | None,
|
|
222
|
+
typer.Option(
|
|
223
|
+
metavar="CHAT_MODEL",
|
|
224
|
+
help="Write realistic queries with this chat model, e.g. openai/gpt-4o-mini.",
|
|
225
|
+
),
|
|
226
|
+
] = None,
|
|
227
|
+
cross_language: Annotated[
|
|
228
|
+
bool,
|
|
229
|
+
typer.Option(
|
|
230
|
+
help="With --generate-queries: ask in the other language (Arabic for Latin-script "
|
|
231
|
+
"rows, English for Arabic ones)."
|
|
232
|
+
),
|
|
233
|
+
] = False,
|
|
234
|
+
old_model: Annotated[
|
|
235
|
+
str | None,
|
|
236
|
+
typer.Option(help="The model that made the old vectors (default: source.model)."),
|
|
237
|
+
] = None,
|
|
238
|
+
group_by: Annotated[
|
|
239
|
+
str | None,
|
|
240
|
+
typer.Option(
|
|
241
|
+
metavar="COLUMN[.KEY]",
|
|
242
|
+
help="Which rows belong to one document, e.g. document_id or metadata.source.",
|
|
243
|
+
),
|
|
244
|
+
] = None,
|
|
245
|
+
neighbours: Annotated[
|
|
246
|
+
int, typer.Option(min=0, max=5_000, help="Rows whose nearest rows are compared.")
|
|
247
|
+
] = 100,
|
|
248
|
+
tolerance: Annotated[
|
|
249
|
+
float, typer.Option(min=0, max=1, help="How far recall@10 may fall and still pass.")
|
|
250
|
+
] = 0.02,
|
|
251
|
+
min_slice: Annotated[
|
|
252
|
+
int, typer.Option(min=1, help="Fewest queries for a language pair to decide the verdict.")
|
|
253
|
+
] = 30,
|
|
254
|
+
max_p95_ms: Annotated[
|
|
255
|
+
float | None, typer.Option(help="Fail if the new p95 (embed + search) is above this.")
|
|
256
|
+
] = None,
|
|
257
|
+
max_slowdown: Annotated[
|
|
258
|
+
float | None,
|
|
259
|
+
typer.Option(metavar="PERCENT", help="Fail if the new p95 is this much slower than old."),
|
|
260
|
+
] = None,
|
|
261
|
+
dsn_env: Annotated[
|
|
262
|
+
str | None,
|
|
263
|
+
typer.Option(help="Read the connection string from this variable, e.g. a replica's."),
|
|
264
|
+
] = None,
|
|
265
|
+
seed: Annotated[int, typer.Option(help="Seed for sampling.")] = 7,
|
|
266
|
+
yes: Annotated[
|
|
267
|
+
bool, typer.Option("--yes", "-y", help="Don't ask before sending data.")
|
|
268
|
+
] = False,
|
|
269
|
+
output_json: Annotated[bool, typer.Option("--json", help="Print results as JSON.")] = False,
|
|
270
|
+
html: Annotated[
|
|
271
|
+
Path | None,
|
|
272
|
+
typer.Option("--html", dir_okay=False, help="Also write an HTML report to this file."),
|
|
273
|
+
] = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Compare the old and new vectors on your data: search quality and latency. Read-only."""
|
|
276
|
+
from vecshift.connectors import pgvector
|
|
277
|
+
from vecshift.connectors.pgvector.inspect import TEXT_COLUMNS, TEXT_TYPES, _pick
|
|
278
|
+
from vecshift.connectors.pgvector.inspect import _columns as table_columns
|
|
279
|
+
from vecshift.connectors.pgvector.search import PgSearcher
|
|
280
|
+
from vecshift.connectors.pgvector.target import inspect_target, resolve_source_column
|
|
281
|
+
from vecshift.embeddings import parse_spec
|
|
282
|
+
from vecshift.eval import Gates, SideReport
|
|
283
|
+
|
|
284
|
+
job, settings, spec = load(job_file, dsn_env)
|
|
285
|
+
old_raw = old_model or job.source.model
|
|
286
|
+
old_spec = parse_spec(old_raw) if old_raw else None
|
|
287
|
+
generator = parse_spec(generate_queries) if generate_queries else None
|
|
288
|
+
if cross_language and generator is None:
|
|
289
|
+
raise _fail("--cross-language needs --generate-queries.")
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
conn = pgvector.connect(settings)
|
|
293
|
+
except pgvector.ConnectError as exc:
|
|
294
|
+
raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
|
|
295
|
+
conn.rollback()
|
|
296
|
+
try:
|
|
297
|
+
# One read-only transaction for the setup queries, under a generous time limit.
|
|
298
|
+
conn.execute("SELECT set_config('statement_timeout', %s, true)", (str(SETUP_TIMEOUT_MS),))
|
|
299
|
+
live = resolve_source_column(
|
|
300
|
+
conn, job.source.table, job.source.vector_column, job.target.column
|
|
301
|
+
)
|
|
302
|
+
target = inspect_target(conn, job.source.table, live, job.target.column)
|
|
303
|
+
relid = target.source.relid
|
|
304
|
+
names = table_columns(conn, relid)
|
|
305
|
+
text = job.source.text_column or _pick(names, TEXT_COLUMNS, TEXT_TYPES)
|
|
306
|
+
if len(target.primary_key) != 1 or text is None:
|
|
307
|
+
raise _fail("eval needs a single-column primary key and a text column.")
|
|
308
|
+
cols = _columns(conn, relid, live, job.target.column)
|
|
309
|
+
metric = _metric(
|
|
310
|
+
conn, relid, cols.old, job.target.metric.value if job.target.metric else None
|
|
311
|
+
)
|
|
312
|
+
from psycopg import sql
|
|
313
|
+
|
|
314
|
+
table = sql.Identifier(target.source.schema, target.source.table)
|
|
315
|
+
group, group_name = _group(conn, relid, table, group_by)
|
|
316
|
+
dims = {
|
|
317
|
+
side: conn.execute(
|
|
318
|
+
"SELECT atttypmod FROM pg_attribute WHERE attrelid = %s AND attname = %s",
|
|
319
|
+
(relid, name),
|
|
320
|
+
).fetchone()
|
|
321
|
+
for side, name in (("old", cols.old), ("new", cols.new))
|
|
322
|
+
}
|
|
323
|
+
missing = conn.execute(
|
|
324
|
+
sql.SQL(
|
|
325
|
+
"SELECT count(*) FILTER (WHERE {new} IS NULL), count(*) FILTER "
|
|
326
|
+
"(WHERE {new} IS NOT NULL) FROM {t} WHERE {txt} IS NOT NULL "
|
|
327
|
+
"AND btrim({txt}::text) <> ''"
|
|
328
|
+
).format(new=sql.Identifier(cols.new), t=table, txt=sql.Identifier(text))
|
|
329
|
+
).fetchone()
|
|
330
|
+
conn.rollback()
|
|
331
|
+
except pgvector.TargetSelectionError as exc:
|
|
332
|
+
conn.close()
|
|
333
|
+
raise _fail(str(exc)) from exc
|
|
334
|
+
except typer.Exit:
|
|
335
|
+
conn.close()
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
pending, done = (int(missing[0]), int(missing[1])) if missing else (0, 0)
|
|
339
|
+
# A busy table always has a few rows waiting for a vector; that's not a partial migration.
|
|
340
|
+
partial = pending > max(SETTLED_ROWS, SETTLED_SHARE * done)
|
|
341
|
+
if not done:
|
|
342
|
+
conn.close()
|
|
343
|
+
raise _fail("The new column has no vectors yet.", "Run apply (or apply --until 10).")
|
|
344
|
+
searcher = PgSearcher(
|
|
345
|
+
conn,
|
|
346
|
+
schema=target.source.schema,
|
|
347
|
+
table=target.source.table,
|
|
348
|
+
pk=target.primary_key[0],
|
|
349
|
+
columns={"old": cols.old, "new": cols.new},
|
|
350
|
+
types={"old": names[cols.old], "new": names[cols.new]},
|
|
351
|
+
extension_schema=target.extension_schema,
|
|
352
|
+
metric=metric,
|
|
353
|
+
partial=partial,
|
|
354
|
+
group=group,
|
|
355
|
+
)
|
|
356
|
+
old = SideReport("old", cols.old, old_spec.name if old_spec else None)
|
|
357
|
+
new = SideReport("new", cols.new, spec.name)
|
|
358
|
+
try:
|
|
359
|
+
rows = searcher.sample(text, max(num_queries * 3, neighbours))
|
|
360
|
+
if not rows:
|
|
361
|
+
raise _fail("No rows have both an old and a new vector to compare.")
|
|
362
|
+
sends = [(spec.name, "" if spec.is_local else spec.url or "")]
|
|
363
|
+
if old_spec:
|
|
364
|
+
sends.append(
|
|
365
|
+
(f"{old_spec.name} (old)", "" if old_spec.is_local else old_spec.url or "")
|
|
366
|
+
)
|
|
367
|
+
if generator:
|
|
368
|
+
sends.append(
|
|
369
|
+
(
|
|
370
|
+
f"{generator.name} (writes queries)",
|
|
371
|
+
"" if generator.is_local else generator.url or "",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
_confirm(sends, num_queries, yes)
|
|
375
|
+
built, source, notes = _queries(
|
|
376
|
+
searcher,
|
|
377
|
+
text,
|
|
378
|
+
rows,
|
|
379
|
+
count=num_queries,
|
|
380
|
+
seed=seed,
|
|
381
|
+
file=queries,
|
|
382
|
+
generator=generator,
|
|
383
|
+
cross_language=cross_language,
|
|
384
|
+
)
|
|
385
|
+
report = asyncio.run(
|
|
386
|
+
_evaluate(
|
|
387
|
+
searcher,
|
|
388
|
+
spec,
|
|
389
|
+
old_spec,
|
|
390
|
+
built,
|
|
391
|
+
[k for k, _ in rows[:neighbours]],
|
|
392
|
+
old=old,
|
|
393
|
+
new=new,
|
|
394
|
+
dims={k: (int(v[0]) if v and v[0] > 0 else None) for k, v in dims.items()},
|
|
395
|
+
source=source,
|
|
396
|
+
partial=partial,
|
|
397
|
+
gates=Gates(tolerance, min_slice, max_p95_ms, max_slowdown),
|
|
398
|
+
notes=notes,
|
|
399
|
+
on_step=(lambda s: None) if output_json else _step,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
finally:
|
|
403
|
+
conn.close()
|
|
404
|
+
|
|
405
|
+
if group_name:
|
|
406
|
+
report.notes.append(f"Rows of the same document are matched by {group_name}.")
|
|
407
|
+
if pending:
|
|
408
|
+
report.notes.append(f"{pending:,} rows don't have a new vector yet.")
|
|
409
|
+
if output_json:
|
|
410
|
+
typer.echo(json.dumps(report.to_dict(), indent=2))
|
|
411
|
+
else:
|
|
412
|
+
_render(report, job.name)
|
|
413
|
+
if html is not None:
|
|
414
|
+
from vecshift import __version__
|
|
415
|
+
from vecshift.eval.html import render_html
|
|
416
|
+
|
|
417
|
+
html.write_text(render_html(report, job=job.name, version=__version__), encoding="utf-8")
|
|
418
|
+
if not output_json:
|
|
419
|
+
typer.echo(f"\nWrote {html}")
|
|
420
|
+
if report.verdict.status == "no_go":
|
|
421
|
+
raise typer.Exit(1)
|
|
422
|
+
if report.verdict.status == "inconclusive":
|
|
423
|
+
raise typer.Exit(EXIT_INCONCLUSIVE)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _step(message: str) -> None:
|
|
427
|
+
typer.secho(f" {message}…", dim=True, err=True)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _evaluate(
|
|
431
|
+
searcher: PgSearcher,
|
|
432
|
+
spec: ModelSpec,
|
|
433
|
+
old_spec: ModelSpec | None,
|
|
434
|
+
queries: list[EvalQuery],
|
|
435
|
+
neighbour_keys: list[str],
|
|
436
|
+
*,
|
|
437
|
+
old: SideReport,
|
|
438
|
+
new: SideReport,
|
|
439
|
+
dims: dict[str, int | None],
|
|
440
|
+
source: str,
|
|
441
|
+
partial: bool,
|
|
442
|
+
gates: Any,
|
|
443
|
+
notes: list[str],
|
|
444
|
+
on_step: Any,
|
|
445
|
+
) -> EvalReport:
|
|
446
|
+
from vecshift.eval import run
|
|
447
|
+
|
|
448
|
+
new_embedder, problem = await _working(spec, dims["new"])
|
|
449
|
+
if new_embedder is None:
|
|
450
|
+
raise _fail(problem or "The new model isn't working.")
|
|
451
|
+
old_embedder = None
|
|
452
|
+
if old_spec is None:
|
|
453
|
+
old.note = (
|
|
454
|
+
"No old model given (source.model or --old-model), so the old side is judged "
|
|
455
|
+
"from its stored vectors only."
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
old_embedder, old.note = await _working(old_spec, dims["old"])
|
|
459
|
+
try:
|
|
460
|
+
return await run(
|
|
461
|
+
searcher,
|
|
462
|
+
{"old": old_embedder, "new": new_embedder},
|
|
463
|
+
queries,
|
|
464
|
+
neighbour_keys,
|
|
465
|
+
old=old,
|
|
466
|
+
new=new,
|
|
467
|
+
query_source=source,
|
|
468
|
+
partial=partial,
|
|
469
|
+
gates=gates,
|
|
470
|
+
notes=notes,
|
|
471
|
+
on_step=on_step,
|
|
472
|
+
)
|
|
473
|
+
finally:
|
|
474
|
+
await new_embedder.aclose()
|
|
475
|
+
if old_embedder is not None:
|
|
476
|
+
await old_embedder.aclose()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# --- Rendering
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _ms(value: float | None) -> str:
|
|
483
|
+
if value is None:
|
|
484
|
+
return "—"
|
|
485
|
+
return f"{value:,.0f} ms" if value >= 100 else f"{value:.1f} ms"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _num(value: float | None) -> str:
|
|
489
|
+
return "—" if value is None else f"{value:.3f}"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _change(before: float | None, after: float | None) -> str:
|
|
493
|
+
if before is None or after is None:
|
|
494
|
+
return ""
|
|
495
|
+
delta = after - before
|
|
496
|
+
color = typer.colors.GREEN if delta > 0.005 else typer.colors.RED if delta < -0.005 else None
|
|
497
|
+
return typer.style(f"{delta:+.3f}", fg=color)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _render(report: EvalReport, name: str) -> None:
|
|
501
|
+
old, new = report.old, report.new
|
|
502
|
+
typer.secho(f"vecshift eval · {name}", bold=True)
|
|
503
|
+
typer.echo(f" Old {old.column} · {old.model or 'model unknown'}")
|
|
504
|
+
typer.echo(f" New {new.column} · {new.model}")
|
|
505
|
+
kind = "partial migration" if report.partial else "complete migration"
|
|
506
|
+
typer.echo(f" {report.queries:,} {report.query_source} queries · {kind}")
|
|
507
|
+
for side in (old, new):
|
|
508
|
+
if side.note:
|
|
509
|
+
typer.secho(f" ⚠ {side.note}", fg=typer.colors.YELLOW)
|
|
510
|
+
|
|
511
|
+
if new.scores:
|
|
512
|
+
typer.secho("\nSearch quality", bold=True)
|
|
513
|
+
typer.echo(f" {'':<30}{'old':>8}{'new':>8} change")
|
|
514
|
+
for key, after in new.scores.items():
|
|
515
|
+
before = old.scores.get(key)
|
|
516
|
+
label = f"{'all queries' if key == 'all' else key} ({after.queries})"
|
|
517
|
+
typer.echo(
|
|
518
|
+
f" Recall@10 {label:<20}{_num(before.recall_at_10 if before else None):>8}"
|
|
519
|
+
f"{_num(after.recall_at_10):>8} "
|
|
520
|
+
f"{_change(before.recall_at_10 if before else None, after.recall_at_10)}"
|
|
521
|
+
)
|
|
522
|
+
a_old, a_new = old.scores.get("all"), new.scores.get("all")
|
|
523
|
+
typer.echo(
|
|
524
|
+
f" {'Recall@1 (all)':<30}{_num(a_old.recall_at_1 if a_old else None):>8}"
|
|
525
|
+
f"{_num(a_new.recall_at_1 if a_new else None):>8}"
|
|
526
|
+
)
|
|
527
|
+
typer.echo(
|
|
528
|
+
f" {'MRR@10 (all)':<30}{_num(a_old.mrr_at_10 if a_old else None):>8}"
|
|
529
|
+
f"{_num(a_new.mrr_at_10 if a_new else None):>8}"
|
|
530
|
+
)
|
|
531
|
+
if report.result_overlap is not None:
|
|
532
|
+
typer.echo(f" Top-10 results in common: {report.result_overlap:.0%}")
|
|
533
|
+
|
|
534
|
+
if old.same_group_at_10 is not None or report.neighbour_overlap is not None:
|
|
535
|
+
typer.secho("\nNearest rows (from stored vectors)", bold=True)
|
|
536
|
+
if old.same_group_at_10 is not None or new.same_group_at_10 is not None:
|
|
537
|
+
typer.echo(
|
|
538
|
+
f" {'Same document in top 10':<30}{_num(old.same_group_at_10):>8}"
|
|
539
|
+
f"{_num(new.same_group_at_10):>8} "
|
|
540
|
+
f"{_change(old.same_group_at_10, new.same_group_at_10)}"
|
|
541
|
+
)
|
|
542
|
+
if report.neighbour_overlap is not None:
|
|
543
|
+
typer.echo(f" Nearest rows in common: {report.neighbour_overlap:.0%}")
|
|
544
|
+
|
|
545
|
+
if old.sweep or new.sweep or old.embed_latency or new.embed_latency:
|
|
546
|
+
typer.secho("\nLatency vs accuracy", bold=True)
|
|
547
|
+
typer.echo(f" {'Query embedding p50 / p95':<26}{_lat(old)} {_lat(new)}")
|
|
548
|
+
for side in (old, new):
|
|
549
|
+
if not side.sweep:
|
|
550
|
+
continue
|
|
551
|
+
setting = {"hnsw": "ef_search", "ivfflat": "probes"}.get(side.method or "", "exact")
|
|
552
|
+
typer.echo(f" {side.side} search ({side.method or 'no index'}):")
|
|
553
|
+
for point in side.sweep:
|
|
554
|
+
mark = " *" if point.current else ""
|
|
555
|
+
lat = point.latency
|
|
556
|
+
p50, p95, p99 = (lat.p50_ms, lat.p95_ms, lat.p99_ms) if lat else (None, None, None)
|
|
557
|
+
value = point.setting if point.setting is not None else "—"
|
|
558
|
+
typer.echo(
|
|
559
|
+
f" {setting}={value}{mark:<3} index recall {_num(point.index_recall)}"
|
|
560
|
+
f" p50 {_ms(p50)} p95 {_ms(p95)} p99 {_ms(p99)}"
|
|
561
|
+
)
|
|
562
|
+
samples = next((p.latency.samples for p in new.sweep if p.latency), 0)
|
|
563
|
+
typer.secho(
|
|
564
|
+
f" * current setting. {samples} queries per setting; search times include the "
|
|
565
|
+
"network round trip.",
|
|
566
|
+
dim=True,
|
|
567
|
+
)
|
|
568
|
+
typer.echo(
|
|
569
|
+
f" End-to-end p95 (embed + search): old {_ms(old.end_to_end_p95)} · "
|
|
570
|
+
f"new {_ms(new.end_to_end_p95)}"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
for note in report.notes:
|
|
574
|
+
typer.secho(f"\n {note}", dim=True)
|
|
575
|
+
verdict = report.verdict
|
|
576
|
+
label, color = {
|
|
577
|
+
"go": ("GO", typer.colors.GREEN),
|
|
578
|
+
"no_go": ("NO-GO", typer.colors.RED),
|
|
579
|
+
"inconclusive": ("INCONCLUSIVE", typer.colors.YELLOW),
|
|
580
|
+
}[verdict.status]
|
|
581
|
+
typer.secho(f"\nVerdict: {label}", fg=color, bold=True)
|
|
582
|
+
for reason in verdict.reasons:
|
|
583
|
+
typer.echo(f" ✖ {reason}" if verdict.status != "go" else f" {reason}")
|
|
584
|
+
for warning in verdict.warnings:
|
|
585
|
+
typer.secho(f" ⚠ {warning}", fg=typer.colors.YELLOW)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _lat(side: SideReport) -> str:
|
|
589
|
+
lat = side.embed_latency
|
|
590
|
+
text = f"{_ms(lat.p50_ms)} / {_ms(lat.p95_ms)}" if lat else "—"
|
|
591
|
+
return f"{side.side} {text}"
|