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/html.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""The ``vecshift eval --html`` report: one self-contained page, no network access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from vecshift.doctor.findings import Severity
|
|
9
|
+
from vecshift.doctor.html import _icon
|
|
10
|
+
from vecshift.eval.metrics import ALL
|
|
11
|
+
from vecshift.eval.runner import EvalReport, SideReport, SweepPoint
|
|
12
|
+
from vecshift.html_kit import asset, e, page, stamp
|
|
13
|
+
|
|
14
|
+
QUERY_SOURCES = {
|
|
15
|
+
"proxy": "sentences taken from the rows (no LLM)",
|
|
16
|
+
"labeled": "your labeled queries",
|
|
17
|
+
"generated": "written by an LLM from sampled rows",
|
|
18
|
+
"cross-language": "written by an LLM in the other language",
|
|
19
|
+
}
|
|
20
|
+
SETTING_NAMES = {"hnsw": "ef_search", "ivfflat": "probes"}
|
|
21
|
+
VERDICTS = {
|
|
22
|
+
"go": ("ok", Severity.OK, "GO: the new vectors are ready"),
|
|
23
|
+
"no_go": ("error", Severity.ERROR, "NO-GO: don't cut over yet"),
|
|
24
|
+
"inconclusive": ("warning", Severity.WARNING, "Inconclusive: not enough evidence"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _num(value: float | None) -> str:
|
|
29
|
+
return "—" if value is None else f"{value:.3f}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ms(value: float | None) -> str:
|
|
33
|
+
if value is None:
|
|
34
|
+
return "—"
|
|
35
|
+
return f"{value:,.0f} ms" if value >= 100 else f"{value:.1f} ms"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _slice_name(name: str) -> str:
|
|
39
|
+
return "All queries" if name == ALL else name.replace("→", " → ").capitalize()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _delta(
|
|
43
|
+
before: float | None, after: float | None, *, higher_is_better: bool, ms: bool = False
|
|
44
|
+
) -> str:
|
|
45
|
+
if before is None or after is None:
|
|
46
|
+
return ""
|
|
47
|
+
change = after - before
|
|
48
|
+
text = f"{change:+.1f} ms" if ms else f"{change:+.3f}"
|
|
49
|
+
if abs(change) < (0.05 if ms else 0.0005):
|
|
50
|
+
return (
|
|
51
|
+
f'<span class="delta">no change vs old {e(_ms(before) if ms else _num(before))}</span>'
|
|
52
|
+
)
|
|
53
|
+
better = (change > 0) == higher_is_better
|
|
54
|
+
arrow = "▲" if change > 0 else "▼"
|
|
55
|
+
tone = "good" if better else "bad"
|
|
56
|
+
was = _ms(before) if ms else _num(before)
|
|
57
|
+
return (
|
|
58
|
+
f'<span class="delta {tone}"><i aria-hidden="true">{arrow}</i>{e(text)} '
|
|
59
|
+
f"vs old {e(was)}</span>"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- Sections
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _verdict(report: EvalReport) -> str:
|
|
67
|
+
tone, severity, title = VERDICTS[report.verdict.status]
|
|
68
|
+
verdict = report.verdict
|
|
69
|
+
lead = verdict.reasons[0] if verdict.reasons else ""
|
|
70
|
+
rest = "".join(f"<li>{e(r)}</li>" for r in verdict.reasons[1:])
|
|
71
|
+
warnings = "".join(
|
|
72
|
+
f'<li class="warn">{_icon(Severity.WARNING)}<span>{e(w)}</span></li>'
|
|
73
|
+
for w in verdict.warnings
|
|
74
|
+
)
|
|
75
|
+
items = f'<ul class="reasons">{rest}{warnings}</ul>' if rest or warnings else ""
|
|
76
|
+
return (
|
|
77
|
+
f'<section class="card verdict" data-tone="{tone}" aria-labelledby="verdict-title">'
|
|
78
|
+
f"{_icon(severity, large=True)}"
|
|
79
|
+
f'<div><h2 id="verdict-title">{e(title)}</h2><p>{e(lead)}</p>{items}</div></section>'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _tile(label: str, value: str, caption: str) -> str:
|
|
84
|
+
return (
|
|
85
|
+
f'<div class="card tile"><div class="tile-label">{e(label)}</div>'
|
|
86
|
+
f'<div class="tile-value">{e(value)}</div><div class="tile-caption">{caption}</div></div>'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _tiles(report: EvalReport) -> str:
|
|
91
|
+
old, new = report.old, report.new
|
|
92
|
+
tiles = []
|
|
93
|
+
after = new.scores.get(ALL)
|
|
94
|
+
before = old.scores.get(ALL)
|
|
95
|
+
if after:
|
|
96
|
+
caption = (
|
|
97
|
+
_delta(before.recall_at_10, after.recall_at_10, higher_is_better=True)
|
|
98
|
+
if before
|
|
99
|
+
else "the old model wasn't available to compare"
|
|
100
|
+
)
|
|
101
|
+
tiles.append(_tile("Recall@10, new", _num(after.recall_at_10), caption))
|
|
102
|
+
if new.same_group_at_10 is not None:
|
|
103
|
+
tiles.append(
|
|
104
|
+
_tile(
|
|
105
|
+
"Same-document neighbours",
|
|
106
|
+
_num(new.same_group_at_10),
|
|
107
|
+
_delta(old.same_group_at_10, new.same_group_at_10, higher_is_better=True)
|
|
108
|
+
or "of each row's 10 nearest rows",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
if new.end_to_end_p95 is not None:
|
|
112
|
+
tiles.append(
|
|
113
|
+
_tile(
|
|
114
|
+
"End-to-end p95, new",
|
|
115
|
+
_ms(new.end_to_end_p95),
|
|
116
|
+
_delta(old.end_to_end_p95, new.end_to_end_p95, higher_is_better=False, ms=True)
|
|
117
|
+
or "query embedding plus search",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
point = new.current
|
|
121
|
+
if point and point.index_recall is not None:
|
|
122
|
+
setting = SETTING_NAMES.get(new.method or "", "")
|
|
123
|
+
where = f"at {setting}={point.setting}" if setting else "exact search"
|
|
124
|
+
tiles.append(
|
|
125
|
+
_tile(
|
|
126
|
+
"Index recall, new", f"{point.index_recall:.0%}", e(f"of the exact top 10, {where}")
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
tiles.append(
|
|
130
|
+
_tile(
|
|
131
|
+
"Queries",
|
|
132
|
+
f"{report.queries:,}",
|
|
133
|
+
e(QUERY_SOURCES.get(report.query_source, report.query_source)),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return (
|
|
137
|
+
'<section aria-label="Key numbers" style="margin-top:12px">'
|
|
138
|
+
f'<div class="tiles">{"".join(tiles)}</div></section>'
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _legend(*, ring: bool = False, line: bool = False) -> str:
|
|
143
|
+
shape = "key line" if line else "key"
|
|
144
|
+
items = [
|
|
145
|
+
f'<li><span class="{shape} old"></span>Old</li>',
|
|
146
|
+
f'<li><span class="{shape} new"></span>New</li>',
|
|
147
|
+
]
|
|
148
|
+
if ring:
|
|
149
|
+
items.append('<li><span class="key ring"></span>Current setting</li>')
|
|
150
|
+
return f'<ul class="legend">{"".join(items)}</ul>'
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _quality(report: EvalReport) -> str:
|
|
154
|
+
old, new = report.old, report.new
|
|
155
|
+
head = (
|
|
156
|
+
"<figcaption><b>Search quality by language</b><span>Recall@10: how often the right "
|
|
157
|
+
"row is in the top 10. Rows are query → document scripts.</span></figcaption>"
|
|
158
|
+
)
|
|
159
|
+
if not new.scores:
|
|
160
|
+
return (
|
|
161
|
+
f'<figure class="card wide">{head}<p class="statement">No queries were run</p></figure>'
|
|
162
|
+
)
|
|
163
|
+
ticks = (0.0, 0.25, 0.5, 0.75, 1.0)
|
|
164
|
+
grid = "".join(f'<div class="db-grid" style="left:{t * 100:.0f}%"></div>' for t in ticks[1:-1])
|
|
165
|
+
rows = []
|
|
166
|
+
for name, after in new.scores.items():
|
|
167
|
+
before = old.scores.get(name)
|
|
168
|
+
label = _slice_name(name)
|
|
169
|
+
dots = []
|
|
170
|
+
if before:
|
|
171
|
+
lo, hi = sorted((before.recall_at_10, after.recall_at_10))
|
|
172
|
+
dots.append(
|
|
173
|
+
f'<div class="db-line" style="left:{lo * 100:.2f}%;'
|
|
174
|
+
f'width:{(hi - lo) * 100:.2f}%"></div>'
|
|
175
|
+
)
|
|
176
|
+
dots.append(
|
|
177
|
+
f'<span class="db-dot old" tabindex="0" '
|
|
178
|
+
f'style="left:{before.recall_at_10 * 100:.2f}%" '
|
|
179
|
+
f'data-tip-value="{before.recall_at_10:.3f}" data-tip-label="{e(label)} · old" '
|
|
180
|
+
f'aria-label="{e(label)}, old: recall@10 {before.recall_at_10:.3f}"></span>'
|
|
181
|
+
)
|
|
182
|
+
dots.append(
|
|
183
|
+
f'<span class="db-dot new" tabindex="0" style="left:{after.recall_at_10 * 100:.2f}%" '
|
|
184
|
+
f'data-tip-value="{after.recall_at_10:.3f}" data-tip-label="{e(label)} · new" '
|
|
185
|
+
f'aria-label="{e(label)}, new: recall@10 {after.recall_at_10:.3f}"></span>'
|
|
186
|
+
)
|
|
187
|
+
value = (
|
|
188
|
+
f"{_num(before.recall_at_10)} → {_num(after.recall_at_10)}"
|
|
189
|
+
if before
|
|
190
|
+
else _num(after.recall_at_10)
|
|
191
|
+
)
|
|
192
|
+
rows.append(
|
|
193
|
+
f'<div class="db-row"><div class="db-label">{e(label)}<span>{after.queries:,} queries'
|
|
194
|
+
f'</span></div><div class="db-track">{grid}{"".join(dots)}</div>'
|
|
195
|
+
f'<div class="db-value">{e(value)}</div></div>'
|
|
196
|
+
)
|
|
197
|
+
axis = (
|
|
198
|
+
'<div class="db-axis" aria-hidden="true"><span></span><div class="ticks">'
|
|
199
|
+
+ "".join(f'<span style="left:{t * 100:.0f}%">{t:g}</span>' for t in ticks)
|
|
200
|
+
+ '</div><span class="spacer"></span></div>'
|
|
201
|
+
)
|
|
202
|
+
table_rows = "".join(
|
|
203
|
+
f'<tr><td>{e(_slice_name(name))}</td><td class="num">{after.queries:,}</td>'
|
|
204
|
+
+ "".join(
|
|
205
|
+
f'<td class="num">{_num(getattr(s, metric) if s else None)}</td>'
|
|
206
|
+
for s in (old.scores.get(name), after)
|
|
207
|
+
for metric in ("recall_at_1", "recall_at_10", "mrr_at_10")
|
|
208
|
+
)
|
|
209
|
+
+ "</tr>"
|
|
210
|
+
for name, after in new.scores.items()
|
|
211
|
+
)
|
|
212
|
+
table = (
|
|
213
|
+
'<details class="table-view"><summary>View as table</summary><div class="table-scroll">'
|
|
214
|
+
'<table><thead><tr><th>Queries</th><th class="num">Count</th>'
|
|
215
|
+
'<th class="num">Old R@1</th><th class="num">Old R@10</th><th class="num">Old MRR</th>'
|
|
216
|
+
'<th class="num">New R@1</th><th class="num">New R@10</th><th class="num">New MRR</th>'
|
|
217
|
+
f"</tr></thead><tbody>{table_rows}</tbody></table></div></details>"
|
|
218
|
+
)
|
|
219
|
+
overlap = (
|
|
220
|
+
f'<p class="chart-note">The two sides share {report.result_overlap:.0%} of their '
|
|
221
|
+
"top-10 results.</p>"
|
|
222
|
+
if report.result_overlap is not None
|
|
223
|
+
else ""
|
|
224
|
+
)
|
|
225
|
+
legend = _legend() if old.scores else ""
|
|
226
|
+
return (
|
|
227
|
+
f'<figure class="card wide">{head}{legend}<div class="db" role="img" '
|
|
228
|
+
f'aria-label="Recall@10 by language, old and new">{"".join(rows)}</div>'
|
|
229
|
+
f"{axis}{overlap}{table}</figure>"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _nice(value: float) -> float:
|
|
234
|
+
"""A round number at or above ``value``, for an axis maximum."""
|
|
235
|
+
if value <= 0:
|
|
236
|
+
return 1.0
|
|
237
|
+
magnitude = 10.0 ** math.floor(math.log10(value))
|
|
238
|
+
for step in (1, 2, 2.5, 5, 10):
|
|
239
|
+
if step * magnitude >= value:
|
|
240
|
+
return step * magnitude
|
|
241
|
+
return 10 * magnitude # pragma: no cover
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _latency_chart(report: EvalReport) -> str:
|
|
245
|
+
sides = [s for s in (report.old, report.new) if s.sweep]
|
|
246
|
+
head = (
|
|
247
|
+
"<figcaption><b>Latency vs accuracy</b><span>Each point is one index setting: search p95 "
|
|
248
|
+
"against the share of the exact top 10 the index finds. Up and left is better."
|
|
249
|
+
"</span></figcaption>"
|
|
250
|
+
)
|
|
251
|
+
if not sides:
|
|
252
|
+
why = (
|
|
253
|
+
"Measured once the migration is complete"
|
|
254
|
+
if report.partial
|
|
255
|
+
else "No latency was measured"
|
|
256
|
+
)
|
|
257
|
+
return f'<figure class="card wide">{head}<p class="statement">{e(why)}</p></figure>'
|
|
258
|
+
|
|
259
|
+
points = [(s, p) for s in sides for p in s.sweep if p.latency and p.index_recall is not None]
|
|
260
|
+
slowest = max(p.latency.p95_ms for _, p in points if p.latency)
|
|
261
|
+
x_step = _nice(slowest * 1.1 / 4)
|
|
262
|
+
x_max = x_step * math.ceil(slowest * 1.1 / x_step)
|
|
263
|
+
low = min(p.index_recall for _, p in points if p.index_recall is not None)
|
|
264
|
+
# Fit the data, but always show at least five points of recall.
|
|
265
|
+
y_min = max(0.0, min(0.95, math.floor(low * 20) / 20))
|
|
266
|
+
|
|
267
|
+
def x(v: float) -> float:
|
|
268
|
+
return float(100 * v / x_max)
|
|
269
|
+
|
|
270
|
+
def y(v: float) -> float:
|
|
271
|
+
return float(100 * (v - y_min) / (1 - y_min))
|
|
272
|
+
|
|
273
|
+
y_ticks = [y_min, (y_min + 1) / 2, 1.0]
|
|
274
|
+
x_ticks = [x_step * i for i in range(round(x_max / x_step) + 1)]
|
|
275
|
+
grid = "".join(f'<div class="gridline" style="bottom:{y(t):.2f}%"></div>' for t in y_ticks[1:])
|
|
276
|
+
vgrid = "".join(f'<div class="vline" style="left:{x(t):.2f}%"></div>' for t in x_ticks[1:])
|
|
277
|
+
lines, marks, labels = [], [], []
|
|
278
|
+
ends: list[tuple[float, float, str]] = []
|
|
279
|
+
for side in sides:
|
|
280
|
+
coords = [
|
|
281
|
+
(x(p.latency.p95_ms), y(p.index_recall), p)
|
|
282
|
+
for p in side.sweep
|
|
283
|
+
if p.latency and p.index_recall is not None
|
|
284
|
+
]
|
|
285
|
+
# In setting order, so the line shows what each step up buys and costs.
|
|
286
|
+
coords.sort(key=lambda c: c[2].setting or 0)
|
|
287
|
+
path = " ".join(f"{cx:.2f},{100 - cy:.2f}" for cx, cy, _ in coords)
|
|
288
|
+
lines.append(f'<polyline class="{side.side}" points="{path}"/>')
|
|
289
|
+
setting = SETTING_NAMES.get(side.method or "", "setting")
|
|
290
|
+
for cx, cy, point in coords:
|
|
291
|
+
name = f"{setting}={point.setting}" if point.setting is not None else "exact"
|
|
292
|
+
current = " current" if point.current else ""
|
|
293
|
+
lat = point.latency
|
|
294
|
+
p95 = _ms(lat.p95_ms if lat else None)
|
|
295
|
+
marks.append(
|
|
296
|
+
f'<span class="lp {side.side}{current}" tabindex="0" '
|
|
297
|
+
f'style="left:{cx:.2f}%;bottom:{cy:.2f}%" '
|
|
298
|
+
f'data-tip-value="{point.index_recall:.0%} · p95 {e(p95)}" '
|
|
299
|
+
f'data-tip-label="{side.side}, {e(name)}{" (current)" if point.current else ""}" '
|
|
300
|
+
f'aria-label="{side.side}, {e(name)}: index recall {point.index_recall:.0%}, '
|
|
301
|
+
f'search p95 {e(_ms(lat.p95_ms if lat else None))}"></span>'
|
|
302
|
+
)
|
|
303
|
+
if coords:
|
|
304
|
+
ends.append((coords[-1][0], coords[-1][1], side.side))
|
|
305
|
+
# End labels help only when they're apart; otherwise the legend and tooltips do the job.
|
|
306
|
+
crowded = len(ends) == 2 and (
|
|
307
|
+
abs(ends[0][0] - ends[1][0]) < 18 and abs(ends[0][1] - ends[1][1]) < 12
|
|
308
|
+
)
|
|
309
|
+
if not crowded:
|
|
310
|
+
for ex, ey, name in ends:
|
|
311
|
+
flip = " flip" if ex > 80 else ""
|
|
312
|
+
labels.append(
|
|
313
|
+
f'<span class="lc-label{flip}" style="left:{ex:.2f}%;bottom:{ey:.2f}%">'
|
|
314
|
+
f"{name}</span>"
|
|
315
|
+
)
|
|
316
|
+
yaxis = "".join(f'<span style="bottom:{y(t):.2f}%">{t * 100:g}%</span>' for t in y_ticks)
|
|
317
|
+
xaxis = "".join(f'<span style="left:{x(t):.2f}%">{round(t, 6):g} ms</span>' for t in x_ticks)
|
|
318
|
+
plot = (
|
|
319
|
+
f'<div class="lc" role="img" aria-label="Index recall against search p95, old and new">'
|
|
320
|
+
f'<div class="yaxis" aria-hidden="true" style="height:220px">{yaxis}</div>'
|
|
321
|
+
f'<div class="plot">{grid}{vgrid}<svg viewBox="0 0 100 100" preserveAspectRatio="none" '
|
|
322
|
+
f'aria-hidden="true">{"".join(lines)}</svg>{"".join(marks)}{"".join(labels)}</div>'
|
|
323
|
+
f'<div class="xaxis" aria-hidden="true">{xaxis}</div>'
|
|
324
|
+
'<div class="axis-title" aria-hidden="true">Search p95</div></div>'
|
|
325
|
+
)
|
|
326
|
+
rows = "".join(
|
|
327
|
+
f"<tr><td>{s.side}</td><td>{e(_setting(s, p))}</td>"
|
|
328
|
+
f'<td class="num">{_num(p.index_recall)}</td>'
|
|
329
|
+
+ "".join(
|
|
330
|
+
f'<td class="num">{e(_ms(getattr(p.latency, f) if p.latency else None))}</td>'
|
|
331
|
+
for f in ("p50_ms", "p95_ms", "p99_ms")
|
|
332
|
+
)
|
|
333
|
+
+ "</tr>"
|
|
334
|
+
for s in sides
|
|
335
|
+
for p in s.sweep
|
|
336
|
+
)
|
|
337
|
+
table = (
|
|
338
|
+
'<details class="table-view"><summary>View as table</summary><div class="table-scroll">'
|
|
339
|
+
'<table><thead><tr><th>Side</th><th>Setting</th><th class="num">Index recall</th>'
|
|
340
|
+
'<th class="num">p50</th><th class="num">p95</th><th class="num">p99</th></tr></thead>'
|
|
341
|
+
f"<tbody>{rows}</tbody></table></div></details>"
|
|
342
|
+
)
|
|
343
|
+
samples = next((p.latency.samples for _, p in points if p.latency), 0)
|
|
344
|
+
note = (
|
|
345
|
+
f'<p class="chart-note">{samples} queries per setting. Search times include the network '
|
|
346
|
+
"round trip from where vecshift ran.</p>"
|
|
347
|
+
)
|
|
348
|
+
legend = _legend(ring=True, line=True)
|
|
349
|
+
return f'<figure class="card wide">{head}{legend}{plot}{note}{table}</figure>'
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _setting(side: SideReport, point: SweepPoint) -> str:
|
|
353
|
+
if point.setting is None:
|
|
354
|
+
return "exact (no index)"
|
|
355
|
+
name = SETTING_NAMES.get(side.method or "", "setting")
|
|
356
|
+
return f"{name}={point.setting}" + (" (current)" if point.current else "")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _latency_table(report: EvalReport) -> str:
|
|
360
|
+
old, new = report.old, report.new
|
|
361
|
+
|
|
362
|
+
def search(side: SideReport, field: str) -> str:
|
|
363
|
+
point = side.current
|
|
364
|
+
return _ms(getattr(point.latency, field) if point and point.latency else None)
|
|
365
|
+
|
|
366
|
+
def embed(side: SideReport, field: str) -> str:
|
|
367
|
+
return _ms(getattr(side.embed_latency, field) if side.embed_latency else None)
|
|
368
|
+
|
|
369
|
+
rows = [
|
|
370
|
+
("Query embedding p50", embed(old, "p50_ms"), embed(new, "p50_ms"), ""),
|
|
371
|
+
("Query embedding p95", embed(old, "p95_ms"), embed(new, "p95_ms"), ""),
|
|
372
|
+
("Search p50 (current setting)", search(old, "p50_ms"), search(new, "p50_ms"), ""),
|
|
373
|
+
("Search p95 (current setting)", search(old, "p95_ms"), search(new, "p95_ms"), ""),
|
|
374
|
+
("Search p99 (current setting)", search(old, "p99_ms"), search(new, "p99_ms"), ""),
|
|
375
|
+
("End-to-end p95", _ms(old.end_to_end_p95), _ms(new.end_to_end_p95), "total"),
|
|
376
|
+
]
|
|
377
|
+
body = "".join(
|
|
378
|
+
f'<tr class="{cls}"><td>{e(label)}</td><td class="num">{e(a)}</td>'
|
|
379
|
+
f'<td class="num">{e(b)}</td></tr>'
|
|
380
|
+
for label, a, b, cls in rows
|
|
381
|
+
)
|
|
382
|
+
return (
|
|
383
|
+
'<section aria-labelledby="lat-title"><h2 id="lat-title">Latency</h2>'
|
|
384
|
+
'<div class="card lat"><div class="table-scroll"><table><thead><tr><th>Step</th>'
|
|
385
|
+
f'<th class="num">Old</th><th class="num">New</th></tr></thead><tbody>{body}</tbody>'
|
|
386
|
+
"</table></div></div></section>"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _method(report: EvalReport) -> str:
|
|
391
|
+
old, new = report.old, report.new
|
|
392
|
+
parts = [
|
|
393
|
+
f"<p>Old vectors: <code>{e(old.column)}</code>, {e(old.model or 'model unknown')}. "
|
|
394
|
+
f"New vectors: <code>{e(new.column)}</code>, {e(new.model or '')}.</p>",
|
|
395
|
+
"<p>Each query was embedded by each side's model and searched in that side's column, "
|
|
396
|
+
"through its index. Documents weren't re-embedded: the vectors already in the database "
|
|
397
|
+
"were searched. Languages are told apart by script (Arabic or Latin letters).</p>",
|
|
398
|
+
*(f"<p>{e(side.note)}</p>" for side in (old, new) if side.note),
|
|
399
|
+
*(f"<p>{e(note)}</p>" for note in report.notes),
|
|
400
|
+
]
|
|
401
|
+
if report.neighbour_overlap is not None:
|
|
402
|
+
parts.append(
|
|
403
|
+
f"<p>From stored vectors, each sampled row's 10 nearest rows overlap "
|
|
404
|
+
f"{report.neighbour_overlap:.0%} between the two sides.</p>"
|
|
405
|
+
)
|
|
406
|
+
return (
|
|
407
|
+
'<section aria-labelledby="method-title"><h2 id="method-title">How this was measured</h2>'
|
|
408
|
+
f'<div class="card method">{"".join(parts)}</div></section>'
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def render_html(
|
|
413
|
+
report: EvalReport,
|
|
414
|
+
*,
|
|
415
|
+
job: str,
|
|
416
|
+
version: str | None = None,
|
|
417
|
+
generated_at: datetime | None = None,
|
|
418
|
+
) -> str:
|
|
419
|
+
when = stamp(generated_at)
|
|
420
|
+
by = f"vecshift {version}" if version else "vecshift"
|
|
421
|
+
kind = "partial migration" if report.partial else "complete migration"
|
|
422
|
+
body = (
|
|
423
|
+
_verdict(report)
|
|
424
|
+
+ _tiles(report)
|
|
425
|
+
+ '<section aria-labelledby="charts-title"><h2 id="charts-title">Old vs new</h2>'
|
|
426
|
+
+ f'<div class="charts">{_quality(report)}{_latency_chart(report)}</div></section>'
|
|
427
|
+
+ _latency_table(report)
|
|
428
|
+
+ _method(report)
|
|
429
|
+
)
|
|
430
|
+
footer = (
|
|
431
|
+
f"<p>Generated by {e(by)} on {e(when)}.</p>"
|
|
432
|
+
"<p>This page holds scores, timings, and column and model names, no document or query "
|
|
433
|
+
"text. It loads nothing from the network.</p>"
|
|
434
|
+
)
|
|
435
|
+
return page(
|
|
436
|
+
title=f"Migration eval · {job}",
|
|
437
|
+
product="vecshift eval",
|
|
438
|
+
heading=f"Old vs new vectors · {job}",
|
|
439
|
+
meta=[f"{report.queries:,} queries", kind, when],
|
|
440
|
+
body=body,
|
|
441
|
+
footer=footer,
|
|
442
|
+
generator=by,
|
|
443
|
+
extra_css=asset("bench.css") + asset("eval.css"),
|
|
444
|
+
)
|
vecshift/eval/metrics.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Scores from ranked result lists, and latency percentiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from vecshift.eval.queries import EvalQuery
|
|
11
|
+
|
|
12
|
+
K = 10
|
|
13
|
+
ALL = "all"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Scores:
|
|
18
|
+
queries: int
|
|
19
|
+
recall_at_1: float
|
|
20
|
+
recall_at_10: float
|
|
21
|
+
mrr_at_10: float
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return asdict(self)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Latency:
|
|
29
|
+
samples: int
|
|
30
|
+
p50_ms: float
|
|
31
|
+
p95_ms: float
|
|
32
|
+
p99_ms: float
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def percentile(values: Sequence[float], pct: float) -> float:
|
|
39
|
+
"""Nearest-rank percentile, so small samples report a value that was measured."""
|
|
40
|
+
ordered = sorted(values)
|
|
41
|
+
rank = max(1, math.ceil(pct / 100 * len(ordered)))
|
|
42
|
+
return ordered[rank - 1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def latency(values: Sequence[float]) -> Latency | None:
|
|
46
|
+
if not values:
|
|
47
|
+
return None
|
|
48
|
+
return Latency(
|
|
49
|
+
len(values), percentile(values, 50), percentile(values, 95), percentile(values, 99)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def score(ranked: Sequence[Sequence[str]], relevant: Sequence[frozenset[str]]) -> Scores:
|
|
54
|
+
r1 = r10 = mrr = 0.0
|
|
55
|
+
for row, rel in zip(ranked, relevant, strict=True):
|
|
56
|
+
hits = [rank for rank, key in enumerate(row[:K], 1) if key in rel]
|
|
57
|
+
r1 += (1 if hits and hits[0] == 1 else 0) / len(rel)
|
|
58
|
+
r10 += len(hits) / len(rel)
|
|
59
|
+
mrr += 1 / hits[0] if hits else 0.0
|
|
60
|
+
n = len(relevant) or 1
|
|
61
|
+
return Scores(len(relevant), r1 / n, r10 / n, mrr / n)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def by_slice(ranked: Sequence[Sequence[str]], queries: Sequence[EvalQuery]) -> dict[str, Scores]:
|
|
65
|
+
"""Scores for all queries, then for each query→document script pair."""
|
|
66
|
+
result = {ALL: score(ranked, [q.relevant for q in queries])}
|
|
67
|
+
for name in sorted({q.slice for q in queries}):
|
|
68
|
+
picked = [i for i, q in enumerate(queries) if q.slice == name]
|
|
69
|
+
result[name] = score([ranked[i] for i in picked], [queries[i].relevant for i in picked])
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def overlap(a: Sequence[Sequence[str]], b: Sequence[Sequence[str]], k: int = K) -> float:
|
|
74
|
+
"""Average share of the top ``k`` results two searches have in common."""
|
|
75
|
+
if not a:
|
|
76
|
+
return 0.0
|
|
77
|
+
total = 0.0
|
|
78
|
+
for x, y in zip(a, b, strict=True):
|
|
79
|
+
size = min(k, max(len(x), len(y)))
|
|
80
|
+
total += len(set(x[:k]) & set(y[:k])) / size if size else 1.0
|
|
81
|
+
return total / len(a)
|
vecshift/eval/queries.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Test queries for comparing the old and new vectors, in any mix of Arabic and Latin text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
ARABIC = re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]")
|
|
12
|
+
LATIN = re.compile(r"[A-Za-z\u00C0-\u024F]")
|
|
13
|
+
# Sentence ends in Latin and Arabic text: . ! ? and the Arabic question mark and full stop.
|
|
14
|
+
_SENTENCE = re.compile(r"(?<=[.!?\u061F\u06D4])\s+|\n+")
|
|
15
|
+
_WORDS = re.compile(r"\S+")
|
|
16
|
+
|
|
17
|
+
SCRIPTS = ("arabic", "latin", "other")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def script(text: str) -> str:
|
|
21
|
+
"""``arabic``, ``latin``, or ``other``: the writing system most of the letters use.
|
|
22
|
+
|
|
23
|
+
It's a script, not a language: English and French are both ``latin``. That's enough to
|
|
24
|
+
show whether a model handles Arabic, without a language-detection model.
|
|
25
|
+
"""
|
|
26
|
+
arabic = len(ARABIC.findall(text))
|
|
27
|
+
latin = len(LATIN.findall(text))
|
|
28
|
+
if not arabic and not latin:
|
|
29
|
+
return "other"
|
|
30
|
+
return "arabic" if arabic >= latin else "latin"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class EvalQuery:
|
|
35
|
+
text: str
|
|
36
|
+
relevant: frozenset[str]
|
|
37
|
+
"""Keys of the rows that answer this query."""
|
|
38
|
+
language: str
|
|
39
|
+
"""Script of the query."""
|
|
40
|
+
doc_language: str
|
|
41
|
+
"""Script of the row that answers it."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def slice(self) -> str:
|
|
45
|
+
"""Results are grouped by query and document script, e.g. ``arabic→latin``."""
|
|
46
|
+
return f"{self.language}→{self.doc_language}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _norm(sentence: str) -> str:
|
|
50
|
+
return " ".join(sentence.lower().split())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def proxy_queries(
|
|
54
|
+
rows: Sequence[tuple[str, str]], n: int, seed: int
|
|
55
|
+
) -> tuple[list[EvalQuery], list[str]]:
|
|
56
|
+
"""Make queries from the rows themselves, with no LLM.
|
|
57
|
+
|
|
58
|
+
One sentence of a row becomes a query whose answer is that row. The sentence is still
|
|
59
|
+
part of the stored vector's text, so these are easy known-item searches: good for
|
|
60
|
+
comparing the old and new vectors, generous as absolute numbers. Returns the queries
|
|
61
|
+
and notes for the report.
|
|
62
|
+
"""
|
|
63
|
+
rng = random.Random(seed)
|
|
64
|
+
split = {key: [s.strip() for s in _SENTENCE.split(text) if s.strip()] for key, text in rows}
|
|
65
|
+
# Sentences repeated across rows (boilerplate) have more than one right answer.
|
|
66
|
+
counts = Counter(_norm(s) for sentences in split.values() for s in set(sentences))
|
|
67
|
+
candidates: dict[str, tuple[str, str]] = {}
|
|
68
|
+
for key, text in rows:
|
|
69
|
+
usable = [
|
|
70
|
+
s for s in split[key] if 5 <= len(_WORDS.findall(s)) <= 40 and counts[_norm(s)] == 1
|
|
71
|
+
]
|
|
72
|
+
if usable:
|
|
73
|
+
candidates[key] = (rng.choice(usable), text)
|
|
74
|
+
continue
|
|
75
|
+
words = _WORDS.findall(text)
|
|
76
|
+
if len(words) >= 12:
|
|
77
|
+
candidates[key] = (" ".join(words[: min(12, len(words) // 2)]), text)
|
|
78
|
+
chosen = sorted(candidates)
|
|
79
|
+
rng.shuffle(chosen)
|
|
80
|
+
chosen = chosen[:n]
|
|
81
|
+
queries = [
|
|
82
|
+
EvalQuery(
|
|
83
|
+
candidates[key][0],
|
|
84
|
+
frozenset({key}),
|
|
85
|
+
script(candidates[key][0]),
|
|
86
|
+
script(candidates[key][1]),
|
|
87
|
+
)
|
|
88
|
+
for key in chosen
|
|
89
|
+
]
|
|
90
|
+
notes = [
|
|
91
|
+
"Queries are sentences taken from the rows themselves (no LLM). The sentence is "
|
|
92
|
+
"still in the row's text, so absolute scores run high; the comparison between old "
|
|
93
|
+
"and new is what counts. Use --generate-queries or --queries for realistic numbers."
|
|
94
|
+
]
|
|
95
|
+
if len(queries) < n:
|
|
96
|
+
notes.append(f"Only {len(queries)} rows had text long enough to make queries from.")
|
|
97
|
+
return queries, notes
|