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/eval/runner.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Compare the old and new vectors: search quality per language, and latency against accuracy.
|
|
2
|
+
|
|
3
|
+
The runner talks to the store through a small searcher interface and to the models through
|
|
4
|
+
the embedding contract, so it can be tested with fakes and reused for other stores. Every
|
|
5
|
+
search it makes is a read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Literal, Protocol
|
|
14
|
+
|
|
15
|
+
from vecshift.eval.metrics import ALL, K, Latency, Scores, by_slice, latency, overlap
|
|
16
|
+
from vecshift.eval.queries import EvalQuery
|
|
17
|
+
|
|
18
|
+
Side = Literal["old", "new"]
|
|
19
|
+
Hit = tuple[str, float]
|
|
20
|
+
"""A row key and its distance from the query; smaller is closer."""
|
|
21
|
+
SIDES: tuple[Side, Side] = ("old", "new")
|
|
22
|
+
INDEX_RECALL_WARNING = 0.9
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Searcher(Protocol):
|
|
26
|
+
def search(
|
|
27
|
+
self, side: Side, vector: Sequence[float], k: int, setting: int | None = None
|
|
28
|
+
) -> list[Hit]:
|
|
29
|
+
"""Top ``k`` (key, distance) using the index, at a search setting (None: default)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def exact(self, side: Side, vector: Sequence[float], k: int) -> list[Hit]:
|
|
33
|
+
"""Top ``k`` (key, distance) by exact search, without the index."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def index(self, side: Side) -> tuple[str | None, int | None, list[int]]:
|
|
37
|
+
"""(index method, current search setting, settings to sweep)."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def neighbours(self, side: Side, key: str, k: int) -> tuple[str | None, list[tuple[str, str]]]:
|
|
41
|
+
"""A row's own group and its ``k`` nearest other rows with their groups."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def vectors(self, side: Side, keys: Sequence[str]) -> list[list[float]]:
|
|
45
|
+
"""Stored vectors of the given rows, to use as queries when no model can embed."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Embedder(Protocol):
|
|
50
|
+
async def embed(
|
|
51
|
+
self, texts: Sequence[str], mode: Literal["document", "query"] = ...
|
|
52
|
+
) -> list[list[float]]: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class SweepPoint:
|
|
57
|
+
setting: int | None
|
|
58
|
+
"""``hnsw.ef_search`` or ``ivfflat.probes``; None when there's no index."""
|
|
59
|
+
index_recall: float | None
|
|
60
|
+
"""Share of the exact top 10 that the index found."""
|
|
61
|
+
latency: Latency | None
|
|
62
|
+
current: bool = False
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"setting": self.setting,
|
|
67
|
+
"index_recall": self.index_recall,
|
|
68
|
+
"latency": self.latency.to_dict() if self.latency else None,
|
|
69
|
+
"current": self.current,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class SideReport:
|
|
75
|
+
side: Side
|
|
76
|
+
column: str
|
|
77
|
+
model: str | None
|
|
78
|
+
embedded: bool = False
|
|
79
|
+
"""Whether queries could be embedded with this side's model."""
|
|
80
|
+
note: str | None = None
|
|
81
|
+
scores: dict[str, Scores] = field(default_factory=dict)
|
|
82
|
+
embed_latency: Latency | None = None
|
|
83
|
+
method: str | None = None
|
|
84
|
+
sweep: list[SweepPoint] = field(default_factory=list)
|
|
85
|
+
same_group_at_10: float | None = None
|
|
86
|
+
"""Share of a row's nearest rows that come from the same document (or group)."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def current(self) -> SweepPoint | None:
|
|
90
|
+
return next((p for p in self.sweep if p.current), self.sweep[0] if self.sweep else None)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def end_to_end_p95(self) -> float | None:
|
|
94
|
+
point = self.current
|
|
95
|
+
if not point or not point.latency:
|
|
96
|
+
return None
|
|
97
|
+
embed = self.embed_latency.p95_ms if self.embed_latency else 0.0
|
|
98
|
+
return embed + point.latency.p95_ms
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"column": self.column,
|
|
103
|
+
"model": self.model,
|
|
104
|
+
"embedded": self.embedded,
|
|
105
|
+
"note": self.note,
|
|
106
|
+
"scores": {k: v.to_dict() for k, v in self.scores.items()},
|
|
107
|
+
"embed_latency": self.embed_latency.to_dict() if self.embed_latency else None,
|
|
108
|
+
"index_method": self.method,
|
|
109
|
+
"sweep": [p.to_dict() for p in self.sweep],
|
|
110
|
+
"same_group_at_10": self.same_group_at_10,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class Gates:
|
|
116
|
+
tolerance: float = 0.02
|
|
117
|
+
"""How far recall@10 may fall (absolute) before the verdict is no-go."""
|
|
118
|
+
min_slice: int = 30
|
|
119
|
+
"""Slices with fewer queries are reported but don't decide the verdict."""
|
|
120
|
+
max_p95_ms: float | None = None
|
|
121
|
+
max_slowdown_pct: float | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class Verdict:
|
|
126
|
+
status: Literal["go", "no_go", "inconclusive"]
|
|
127
|
+
reasons: list[str] = field(default_factory=list)
|
|
128
|
+
"""Why it isn't go (or, for go, what was checked)."""
|
|
129
|
+
warnings: list[str] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
return {"status": self.status, "reasons": self.reasons, "warnings": self.warnings}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class EvalReport:
|
|
137
|
+
mode: Literal["queries", "vectors"]
|
|
138
|
+
"""``queries``: both models embedded the queries. ``vectors``: the old model couldn't,
|
|
139
|
+
so the old side is judged from its stored vectors alone."""
|
|
140
|
+
partial: bool
|
|
141
|
+
query_source: str
|
|
142
|
+
queries: int
|
|
143
|
+
old: SideReport
|
|
144
|
+
new: SideReport
|
|
145
|
+
result_overlap: float | None = None
|
|
146
|
+
neighbour_overlap: float | None = None
|
|
147
|
+
notes: list[str] = field(default_factory=list)
|
|
148
|
+
verdict: Verdict = field(default_factory=lambda: Verdict("inconclusive"))
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
return {
|
|
152
|
+
"mode": self.mode,
|
|
153
|
+
"partial": self.partial,
|
|
154
|
+
"query_source": self.query_source,
|
|
155
|
+
"queries": self.queries,
|
|
156
|
+
"old": self.old.to_dict(),
|
|
157
|
+
"new": self.new.to_dict(),
|
|
158
|
+
"result_overlap_at_10": self.result_overlap,
|
|
159
|
+
"neighbour_overlap_at_10": self.neighbour_overlap,
|
|
160
|
+
"notes": self.notes,
|
|
161
|
+
"verdict": self.verdict.to_dict(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _embed(
|
|
166
|
+
embedder: Embedder, texts: list[str], samples: int
|
|
167
|
+
) -> tuple[list[list[float]], Latency | None]:
|
|
168
|
+
vectors = await embedder.embed(texts, "query")
|
|
169
|
+
timings = []
|
|
170
|
+
for text in texts[:samples]:
|
|
171
|
+
started = time.perf_counter()
|
|
172
|
+
await embedder.embed([text], "query")
|
|
173
|
+
timings.append((time.perf_counter() - started) * 1000)
|
|
174
|
+
return vectors, latency(timings)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _sweep(
|
|
178
|
+
searcher: Searcher, side: Side, vectors: list[list[float]], warmup: int = 5
|
|
179
|
+
) -> tuple[str | None, list[SweepPoint]]:
|
|
180
|
+
method, current, settings = searcher.index(side)
|
|
181
|
+
for vector in vectors[:warmup]:
|
|
182
|
+
searcher.search(side, vector, K)
|
|
183
|
+
exact = [searcher.exact(side, v, K) for v in vectors]
|
|
184
|
+
if method is None:
|
|
185
|
+
scans = []
|
|
186
|
+
for vector in vectors:
|
|
187
|
+
started = time.perf_counter()
|
|
188
|
+
searcher.exact(side, vector, K)
|
|
189
|
+
scans.append((time.perf_counter() - started) * 1000)
|
|
190
|
+
return None, [SweepPoint(None, 1.0, latency(scans), current=True)]
|
|
191
|
+
found: dict[int, list[list[Hit]]] = {s: [] for s in settings}
|
|
192
|
+
timings: dict[int, list[float]] = {s: [] for s in settings}
|
|
193
|
+
# Each query runs at every setting in turn, so caches warming up during the run
|
|
194
|
+
# don't flatter whichever setting happens to be measured last.
|
|
195
|
+
for i, vector in enumerate(vectors):
|
|
196
|
+
order = settings[i % len(settings) :] + settings[: i % len(settings)]
|
|
197
|
+
for setting in order:
|
|
198
|
+
started = time.perf_counter()
|
|
199
|
+
found[setting].append(searcher.search(side, vector, K, setting))
|
|
200
|
+
timings[setting].append((time.perf_counter() - started) * 1000)
|
|
201
|
+
points = [
|
|
202
|
+
SweepPoint(s, index_recall(found[s], exact), latency(timings[s]), s == current)
|
|
203
|
+
for s in settings
|
|
204
|
+
]
|
|
205
|
+
return method, points
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _keys(hits: Sequence[Hit]) -> list[str]:
|
|
209
|
+
return [key for key, _ in hits]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def index_recall(found: Sequence[Sequence[Hit]], exact: Sequence[Sequence[Hit]]) -> float:
|
|
213
|
+
"""Share of the exact top 10 the index found, counting ties as found.
|
|
214
|
+
|
|
215
|
+
When several rows are exactly as close as the 10th, exact search and the index may
|
|
216
|
+
pick different ones; any row at least as close as the exact 10th is a correct answer.
|
|
217
|
+
"""
|
|
218
|
+
if not exact:
|
|
219
|
+
return 1.0
|
|
220
|
+
total = 0.0
|
|
221
|
+
for got, truth in zip(found, exact, strict=True):
|
|
222
|
+
if not truth:
|
|
223
|
+
total += 1.0
|
|
224
|
+
continue
|
|
225
|
+
cutoff = truth[-1][1]
|
|
226
|
+
slack = 1e-6 * max(1.0, abs(cutoff))
|
|
227
|
+
hits = sum(1 for _, distance in got[:K] if distance <= cutoff + slack)
|
|
228
|
+
total += min(hits, len(truth)) / len(truth)
|
|
229
|
+
return total / len(exact)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _neighbours(
|
|
233
|
+
searcher: Searcher, side: Side, keys: Sequence[str]
|
|
234
|
+
) -> tuple[list[list[str]], float | None]:
|
|
235
|
+
lists, shares = [], []
|
|
236
|
+
for key in keys:
|
|
237
|
+
own, found = searcher.neighbours(side, key, K)
|
|
238
|
+
lists.append([k for k, _ in found])
|
|
239
|
+
if own is not None and found:
|
|
240
|
+
shares.append(sum(1 for _, group in found if group == own) / len(found))
|
|
241
|
+
return lists, (sum(shares) / len(shares) if shares else None)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def decide(report: EvalReport, gates: Gates) -> Verdict:
|
|
245
|
+
"""Go, no-go, or inconclusive, with the reasons."""
|
|
246
|
+
verdict = Verdict("go")
|
|
247
|
+
old, new = report.old, report.new
|
|
248
|
+
evidence = False
|
|
249
|
+
if report.mode == "queries":
|
|
250
|
+
for name, before in old.scores.items():
|
|
251
|
+
after = new.scores.get(name)
|
|
252
|
+
if after is None:
|
|
253
|
+
continue
|
|
254
|
+
label = "overall" if name == ALL else name
|
|
255
|
+
if before.queries < gates.min_slice:
|
|
256
|
+
verdict.warnings.append(
|
|
257
|
+
f"Only {before.queries} {label} queries: shown, but too few to decide on."
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
evidence = True
|
|
261
|
+
if after.recall_at_10 < before.recall_at_10 - gates.tolerance:
|
|
262
|
+
verdict.reasons.append(
|
|
263
|
+
f"Recall@10 {label} fell from {before.recall_at_10:.3f} to "
|
|
264
|
+
f"{after.recall_at_10:.3f}."
|
|
265
|
+
)
|
|
266
|
+
if old.same_group_at_10 is not None and new.same_group_at_10 is not None:
|
|
267
|
+
evidence = True
|
|
268
|
+
if new.same_group_at_10 < old.same_group_at_10 - gates.tolerance:
|
|
269
|
+
verdict.reasons.append(
|
|
270
|
+
"Fewer nearest rows come from the same document: "
|
|
271
|
+
f"{old.same_group_at_10:.3f} before, {new.same_group_at_10:.3f} after."
|
|
272
|
+
)
|
|
273
|
+
if report.mode == "vectors":
|
|
274
|
+
verdict.warnings.append(
|
|
275
|
+
"The old model wasn't available to embed queries, so the old side was judged from "
|
|
276
|
+
"its stored vectors only. That's a weaker check than comparing search results."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
old_p95, new_p95 = old.end_to_end_p95, new.end_to_end_p95
|
|
280
|
+
if gates.max_p95_ms is not None and new_p95 is not None and new_p95 > gates.max_p95_ms:
|
|
281
|
+
verdict.reasons.append(
|
|
282
|
+
f"New p95 latency is {new_p95:.1f} ms, over the {gates.max_p95_ms:g} ms limit."
|
|
283
|
+
)
|
|
284
|
+
if gates.max_slowdown_pct is not None and old_p95 and new_p95 is not None:
|
|
285
|
+
limit = old_p95 * (1 + gates.max_slowdown_pct / 100)
|
|
286
|
+
if new_p95 > limit:
|
|
287
|
+
verdict.reasons.append(
|
|
288
|
+
f"New p95 latency is {new_p95:.1f} ms against {old_p95:.1f} ms before, more "
|
|
289
|
+
f"than {gates.max_slowdown_pct:g}% slower."
|
|
290
|
+
)
|
|
291
|
+
for side in (old, new):
|
|
292
|
+
point = side.current
|
|
293
|
+
if point and point.index_recall is not None and point.index_recall < INDEX_RECALL_WARNING:
|
|
294
|
+
verdict.warnings.append(
|
|
295
|
+
f"The {side.side} index finds {point.index_recall:.0%} of the exact top 10 at "
|
|
296
|
+
"its current setting; a higher setting trades speed for accuracy."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if verdict.reasons:
|
|
300
|
+
verdict.status = "no_go"
|
|
301
|
+
elif evidence:
|
|
302
|
+
decided = [
|
|
303
|
+
"overall" if name == ALL else name
|
|
304
|
+
for name, scores in old.scores.items()
|
|
305
|
+
if scores.queries >= gates.min_slice
|
|
306
|
+
]
|
|
307
|
+
if report.mode == "queries" and decided:
|
|
308
|
+
verdict.reasons.append(
|
|
309
|
+
f"Recall@10 held within {gates.tolerance:g} or improved: {', '.join(decided)}."
|
|
310
|
+
)
|
|
311
|
+
if old.same_group_at_10 is not None and new.same_group_at_10 is not None:
|
|
312
|
+
verdict.reasons.append("Nearest rows from the same document held or improved.")
|
|
313
|
+
else:
|
|
314
|
+
verdict.status = "inconclusive"
|
|
315
|
+
verdict.reasons.append(
|
|
316
|
+
"Not enough evidence to decide: use more queries, give the old model "
|
|
317
|
+
"(source.model), or a way to group rows by document (--group-by)."
|
|
318
|
+
)
|
|
319
|
+
return verdict
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def run(
|
|
323
|
+
searcher: Searcher,
|
|
324
|
+
embedders: dict[Side, Embedder | None],
|
|
325
|
+
queries: Sequence[EvalQuery],
|
|
326
|
+
neighbour_keys: Sequence[str],
|
|
327
|
+
*,
|
|
328
|
+
old: SideReport,
|
|
329
|
+
new: SideReport,
|
|
330
|
+
query_source: str,
|
|
331
|
+
partial: bool,
|
|
332
|
+
gates: Gates | None = None,
|
|
333
|
+
sweep_queries: int = 30,
|
|
334
|
+
latency_samples: int = 20,
|
|
335
|
+
notes: Sequence[str] = (),
|
|
336
|
+
on_step: Callable[[str], None] = lambda s: None,
|
|
337
|
+
) -> EvalReport:
|
|
338
|
+
"""Evaluate both sides. ``embedders['old']`` is None when the old model is unavailable."""
|
|
339
|
+
sides = {"old": old, "new": new}
|
|
340
|
+
report = EvalReport(
|
|
341
|
+
mode="queries" if embedders.get("old") is not None else "vectors",
|
|
342
|
+
partial=partial,
|
|
343
|
+
query_source=query_source,
|
|
344
|
+
queries=len(queries),
|
|
345
|
+
old=old,
|
|
346
|
+
new=new,
|
|
347
|
+
notes=list(notes),
|
|
348
|
+
)
|
|
349
|
+
texts = [q.text for q in queries]
|
|
350
|
+
ranked: dict[Side, list[list[str]]] = {}
|
|
351
|
+
for name in SIDES:
|
|
352
|
+
embedder = embedders.get(name)
|
|
353
|
+
side = sides[name]
|
|
354
|
+
if embedder is None or not texts:
|
|
355
|
+
continue
|
|
356
|
+
on_step(f"Embedding {len(texts)} queries with the {name} model")
|
|
357
|
+
vectors, side.embed_latency = await _embed(embedder, texts, latency_samples)
|
|
358
|
+
side.embedded = True
|
|
359
|
+
on_step(f"Searching the {name} column")
|
|
360
|
+
if partial:
|
|
361
|
+
ranked[name] = [_keys(searcher.exact(name, v, K)) for v in vectors]
|
|
362
|
+
else:
|
|
363
|
+
ranked[name] = [_keys(searcher.search(name, v, K)) for v in vectors]
|
|
364
|
+
on_step(f"Measuring {name} search latency and index accuracy")
|
|
365
|
+
side.method, side.sweep = _sweep(searcher, name, vectors[:sweep_queries])
|
|
366
|
+
side.scores = by_slice(ranked[name], queries)
|
|
367
|
+
if len(ranked) == 2:
|
|
368
|
+
report.result_overlap = overlap(ranked["old"], ranked["new"])
|
|
369
|
+
for name in SIDES:
|
|
370
|
+
side = sides[name]
|
|
371
|
+
if side.sweep or partial or not neighbour_keys:
|
|
372
|
+
continue
|
|
373
|
+
# Search speed doesn't depend on the model, so stored vectors stand in for queries.
|
|
374
|
+
on_step(f"Measuring {name} search latency with stored vectors")
|
|
375
|
+
stored = searcher.vectors(name, neighbour_keys[:sweep_queries])
|
|
376
|
+
if stored:
|
|
377
|
+
side.method, side.sweep = _sweep(searcher, name, stored)
|
|
378
|
+
report.notes.append(
|
|
379
|
+
f"The {name} side's latency was measured with stored row vectors as queries, "
|
|
380
|
+
"since its model couldn't embed queries."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if neighbour_keys:
|
|
384
|
+
on_step("Comparing each sampled row's nearest rows")
|
|
385
|
+
old_lists, old.same_group_at_10 = _neighbours(searcher, "old", neighbour_keys)
|
|
386
|
+
new_lists, new.same_group_at_10 = _neighbours(searcher, "new", neighbour_keys)
|
|
387
|
+
report.neighbour_overlap = overlap(old_lists, new_lists)
|
|
388
|
+
if partial:
|
|
389
|
+
report.notes.append(
|
|
390
|
+
"The migration isn't finished, so both sides were searched exactly, over the rows "
|
|
391
|
+
"that have both vectors. Latency is measured once it's complete."
|
|
392
|
+
)
|
|
393
|
+
report.verdict = decide(report, gates or Gates())
|
|
394
|
+
return report
|
vecshift/html_kit.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared pieces for vecshift's self-contained HTML pages: the page shell, logo,
|
|
2
|
+
favicon, styles, script, escaping, and number formatting."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from html import escape
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
# The vecshift mark: points moving from the old embedding space into the new one.
|
|
16
|
+
# Generated by scripts/make_logo.py; colours come from the --mark-* tokens in report.css.
|
|
17
|
+
DOTS = ((6.0, 25.0, 2.6), (11.2, 20.0, 3.2), (17.6, 13.8, 4.0), (25.0, 7.0, 5.0))
|
|
18
|
+
LOGO = (
|
|
19
|
+
'<svg width="20" height="20" viewBox="0 0 32 32" aria-hidden="true">'
|
|
20
|
+
+ "".join(
|
|
21
|
+
f'<circle cx="{x}" cy="{y}" r="{r}" fill="var(--mark-{i})"/>'
|
|
22
|
+
for i, (x, y, r) in enumerate(DOTS)
|
|
23
|
+
)
|
|
24
|
+
+ "</svg>"
|
|
25
|
+
)
|
|
26
|
+
FAVICON = "data:image/svg+xml," + quote(
|
|
27
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>'
|
|
28
|
+
".a{fill:#a9a8a1}.b{fill:#839ab5}.c{fill:#5b8bc7}.d{fill:#2a78d6}"
|
|
29
|
+
"@media (prefers-color-scheme:dark){.a{fill:#8a8983}.b{fill:#738ba5}"
|
|
30
|
+
".c{fill:#5a8ac5}.d{fill:#3987e5}}</style>"
|
|
31
|
+
+ "".join(
|
|
32
|
+
f'<circle class="{c}" cx="{x}" cy="{y}" r="{r}"/>'
|
|
33
|
+
for c, (x, y, r) in zip("abcd", DOTS, strict=True)
|
|
34
|
+
)
|
|
35
|
+
+ "</svg>"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def e(value: object) -> str:
|
|
40
|
+
"""Escape for HTML text and attribute values."""
|
|
41
|
+
return escape(str(value), quote=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rich(text: str) -> str:
|
|
45
|
+
"""Escape ``text`` and show its `backticked` names as code."""
|
|
46
|
+
return re.sub(r"`([^`]+)`", r"<code>\1</code>", e(text))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compact(n: int) -> str:
|
|
50
|
+
"""1,284 · 12.9K · 4.2M"""
|
|
51
|
+
if abs(n) < 10_000:
|
|
52
|
+
return f"{n:,}"
|
|
53
|
+
for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
|
|
54
|
+
if abs(n) >= size:
|
|
55
|
+
value = n / size
|
|
56
|
+
text = f"{value:.1f}".rstrip("0").rstrip(".") if value < 100 else f"{value:.0f}"
|
|
57
|
+
return f"{text}{suffix}"
|
|
58
|
+
return f"{n:,}" # pragma: no cover
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def percent(part: int, whole: int) -> str:
|
|
62
|
+
if whole <= 0:
|
|
63
|
+
return "—"
|
|
64
|
+
value = 100 * part / whole
|
|
65
|
+
if 0 < value < 1:
|
|
66
|
+
return "<1%"
|
|
67
|
+
if 99 < value < 100:
|
|
68
|
+
return ">99%"
|
|
69
|
+
return f"{value:.0f}%"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def nice_ceiling(n: int) -> int:
|
|
73
|
+
if n <= 1:
|
|
74
|
+
return 1
|
|
75
|
+
magnitude = 10 ** math.floor(math.log10(n))
|
|
76
|
+
for step in (1, 2, 2.5, 5, 10):
|
|
77
|
+
if n <= step * magnitude:
|
|
78
|
+
return int(step * magnitude) if step * magnitude >= 1 else 1
|
|
79
|
+
return n # pragma: no cover
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def asset(name: str) -> str:
|
|
83
|
+
return files("vecshift").joinpath("assets", name).read_text(encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stamp(when: datetime | None = None) -> str:
|
|
87
|
+
moment = (when or datetime.now(UTC)).astimezone(UTC)
|
|
88
|
+
return f"{moment.day} {moment:%b %Y, %H:%M} UTC"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def content_security_policy(script: str) -> str:
|
|
92
|
+
"""Allow only this page's own script (by hash) and inline styles, and no network at all.
|
|
93
|
+
|
|
94
|
+
Even if an escaping bug ever let data inject markup, the browser would refuse to run
|
|
95
|
+
any other script or send anything anywhere.
|
|
96
|
+
"""
|
|
97
|
+
digest = base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()
|
|
98
|
+
return (
|
|
99
|
+
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; "
|
|
100
|
+
f"script-src 'sha256-{digest}'; base-uri 'none'; form-action 'none'"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def page(
|
|
105
|
+
*,
|
|
106
|
+
title: str,
|
|
107
|
+
product: str,
|
|
108
|
+
heading: str,
|
|
109
|
+
meta: list[str],
|
|
110
|
+
body: str,
|
|
111
|
+
footer: str,
|
|
112
|
+
generator: str,
|
|
113
|
+
extra_css: str = "",
|
|
114
|
+
) -> str:
|
|
115
|
+
"""A complete, self-contained HTML document in vecshift's report style."""
|
|
116
|
+
meta_html = "".join(f"<span>{e(m)}</span>" for m in meta)
|
|
117
|
+
script = asset("report.js")
|
|
118
|
+
return f"""<!doctype html>
|
|
119
|
+
<html lang="en">
|
|
120
|
+
<head>
|
|
121
|
+
<meta charset="utf-8">
|
|
122
|
+
<meta http-equiv="Content-Security-Policy" content="{content_security_policy(script)}">
|
|
123
|
+
<meta name="referrer" content="no-referrer">
|
|
124
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
125
|
+
<meta name="generator" content="{e(generator)}">
|
|
126
|
+
<link rel="icon" href="{FAVICON}">
|
|
127
|
+
<title>{e(title)}</title>
|
|
128
|
+
<style>{asset("report.css")}{extra_css}</style>
|
|
129
|
+
</head>
|
|
130
|
+
<body>
|
|
131
|
+
<main class="wrap">
|
|
132
|
+
<header>
|
|
133
|
+
<div class="brand">{LOGO}{e(product)}</div>
|
|
134
|
+
<h1>{e(heading)}</h1>
|
|
135
|
+
<p class="meta">{meta_html}</p>
|
|
136
|
+
</header>
|
|
137
|
+
{body}
|
|
138
|
+
<footer>{footer}</footer>
|
|
139
|
+
</main>
|
|
140
|
+
<script>{script}</script>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
|
143
|
+
"""
|