vecshift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,106 @@
1
+ """Generate realistic search queries for documents with an LLM.
2
+
3
+ Any OpenAI-compatible chat endpoint works. The generated queries can be saved and reused,
4
+ so a benchmark only pays for them once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import random
11
+ from collections.abc import Callable, Sequence
12
+
13
+ import httpx
14
+
15
+ from vecshift.bench.corpus import Document, Query
16
+ from vecshift.embeddings.providers import CONCURRENCY, MAX_ATTEMPTS, RETRY_STATUSES, api_key
17
+ from vecshift.embeddings.spec import ModelSpec
18
+
19
+ PROMPT = """Write one search query that a person might type to find the passage below.
20
+ The passage must answer the query. Don't copy long phrases from it: use the words a
21
+ searcher would use. Reply with the query only, on one line.
22
+
23
+ Passage:
24
+ {text}"""
25
+
26
+
27
+ class GenerationError(Exception):
28
+ """Query generation failed. The message never contains credentials."""
29
+
30
+
31
+ class QueryGenerator:
32
+ def __init__(self, spec: ModelSpec, client: httpx.AsyncClient | None = None) -> None:
33
+ if spec.provider == "hash" or not spec.url:
34
+ raise GenerationError("Query generation needs a chat model, such as openai/gpt-4o-mini")
35
+ key = api_key(spec, GenerationError)
36
+ self.spec = spec
37
+ self.tokens = 0
38
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
39
+ self._client = client or httpx.AsyncClient(
40
+ timeout=120, headers=headers, verify=True, follow_redirects=False
41
+ )
42
+ if client is not None and key:
43
+ self._client.headers.update(headers)
44
+ self._owns_client = client is None
45
+ self._limit = asyncio.Semaphore(CONCURRENCY)
46
+
47
+ async def aclose(self) -> None:
48
+ if self._owns_client:
49
+ await self._client.aclose()
50
+
51
+ async def _one(self, doc: Document, language: str | None = None) -> Query | None:
52
+ prompt = PROMPT.format(text=doc.text[:4000])
53
+ if language:
54
+ prompt = prompt.replace(
55
+ "Reply with the query only", f"Write the query in {language}. Reply with it only"
56
+ )
57
+ payload = {
58
+ "model": self.spec.model,
59
+ "temperature": 0,
60
+ "max_tokens": 64,
61
+ "messages": [{"role": "user", "content": prompt}],
62
+ }
63
+ delay = 1.0
64
+ async with self._limit:
65
+ for attempt in range(1, MAX_ATTEMPTS + 1):
66
+ try:
67
+ r = await self._client.post(f"{self.spec.url}/chat/completions", json=payload)
68
+ except httpx.TransportError as exc:
69
+ if attempt == MAX_ATTEMPTS:
70
+ raise GenerationError(
71
+ f"{self.spec.name}: couldn't reach {self.spec.url}"
72
+ ) from exc
73
+ await asyncio.sleep(delay)
74
+ delay *= 2
75
+ continue
76
+ if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS:
77
+ await asyncio.sleep(delay + random.uniform(0, 0.5))
78
+ delay *= 2
79
+ continue
80
+ if r.status_code >= 400:
81
+ raise GenerationError(f"{self.spec.name}: HTTP {r.status_code}")
82
+ body = r.json()
83
+ self.tokens += int((body.get("usage") or {}).get("total_tokens") or 0)
84
+ text = str(body["choices"][0]["message"]["content"] or "")
85
+ line = text.strip().splitlines()[0] if text.strip() else ""
86
+ line = line.strip().strip('"“”').strip()
87
+ return Query(line, frozenset({doc.id})) if line else None
88
+ return None # pragma: no cover
89
+
90
+ async def generate(
91
+ self,
92
+ docs: Sequence[Document],
93
+ n: int,
94
+ seed: int,
95
+ language: Callable[[Document], str | None] | None = None,
96
+ ) -> list[Query]:
97
+ """One query for each of ``n`` sampled documents.
98
+
99
+ ``language`` picks the language to write each query in, for cross-language tests
100
+ such as an Arabic question about an English passage.
101
+ """
102
+ chosen = random.Random(seed).sample(list(docs), min(n, len(docs)))
103
+ results = await asyncio.gather(
104
+ *(self._one(d, language(d) if language else None) for d in chosen)
105
+ )
106
+ return [q for q in results if q is not None]
vecshift/bench/html.py ADDED
@@ -0,0 +1,325 @@
1
+ """Render a benchmark as a self-contained HTML leaderboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+
9
+ from vecshift.bench.runner import BenchResult, ModelResult
10
+ from vecshift.html_kit import asset, compact, e, page, stamp
11
+
12
+ VALUE_TOLERANCE = 0.02
13
+ """A model within this much recall@10 of the best counts as "as good" when picking value."""
14
+
15
+ QUERY_SOURCES = {
16
+ "proxy": "proxy queries taken from the documents",
17
+ "generated": "written by an LLM",
18
+ "labeled": "your labeled queries",
19
+ }
20
+
21
+
22
+ def _money(value: float | None) -> str:
23
+ if value is None:
24
+ return "—"
25
+ if value == 0:
26
+ return "free"
27
+ if value < 0.01:
28
+ return "<$0.01"
29
+ return f"${value:,.2f}" if value < 1000 else f"${value:,.0f}"
30
+
31
+
32
+ def _ms(value: float | None) -> str:
33
+ if value is None:
34
+ return "—"
35
+ return f"{value:.2f} ms" if value < 1 else f"{value:,.1f} ms"
36
+
37
+
38
+ def _gb(value: float | None) -> str:
39
+ return "—" if value is None else f"{value:,.1f} GB" if value >= 1 else f"{value * 1000:,.0f} MB"
40
+
41
+
42
+ def _value_pick(models: list[ModelResult]) -> ModelResult | None:
43
+ best = max(m.scores.recall_at_10 for m in models if m.scores)
44
+ close = [m for m in models if m.scores and m.scores.recall_at_10 >= best - VALUE_TOLERANCE]
45
+ priced = [m for m in close if m.cost_per_million_docs is not None]
46
+ pool = priced or close
47
+ return min(
48
+ pool,
49
+ key=lambda m: (
50
+ m.cost_per_million_docs if priced else 0,
51
+ m.gb_per_million_vectors or math.inf,
52
+ ),
53
+ )
54
+
55
+
56
+ def _picks(models: list[ModelResult]) -> str:
57
+ scored = [m for m in models if m.scores]
58
+ if not scored:
59
+ return ""
60
+ best = max(scored, key=lambda m: m.scores.recall_at_10 if m.scores else 0)
61
+ value = _value_pick(scored)
62
+ timed = [m for m in scored if m.query_ms_p50 is not None]
63
+ fastest = min(timed, key=lambda m: m.query_ms_p50 or 0) if timed else None
64
+
65
+ def card(label: str, m: ModelResult | None, why: str) -> str:
66
+ if m is None:
67
+ return ""
68
+ return (
69
+ f'<div class="card pick"><div class="tile-label">{e(label)}</div>'
70
+ f'<div class="who">{e(m.name)}</div><div class="why">{e(why)}</div></div>'
71
+ )
72
+
73
+ if best.scores is None: # pragma: no cover - scored models always have scores
74
+ return ""
75
+ value_why = ""
76
+ if value:
77
+ cost = value.cost_per_million_docs
78
+ price = "free to embed" if cost == 0 else f"{_money(cost)} per 1M documents"
79
+ value_why = f"{price}, {_gb(value.gb_per_million_vectors)} per 1M vectors"
80
+ return (
81
+ '<section aria-label="Picks"><div class="picks">'
82
+ + card("Best retrieval", best, f"recall@10 {best.scores.recall_at_10:.3f}")
83
+ + card("Best value", value, value_why)
84
+ + card(
85
+ "Fastest queries",
86
+ fastest,
87
+ f"{_ms(fastest.query_ms_p50)} median per query" if fastest else "",
88
+ )
89
+ + "</div></section>"
90
+ )
91
+
92
+
93
+ def _tiles(result: BenchResult) -> str:
94
+ tiles = [
95
+ ("Documents", compact(result.documents), f"sampled from {result.source}"),
96
+ ("Queries", compact(result.queries), QUERY_SOURCES.get(result.query_source, "")),
97
+ ("Models", str(len(result.models)), "compared on the same data"),
98
+ ]
99
+ return (
100
+ '<section aria-label="Key numbers"><div class="tiles">'
101
+ + "".join(
102
+ f'<div class="card tile"><div class="tile-label">{e(label)}</div>'
103
+ f'<div class="tile-value">{e(value)}</div><div class="tile-caption">{e(caption)}</div>'
104
+ "</div>"
105
+ for label, value, caption in tiles
106
+ )
107
+ + "</div></section>"
108
+ )
109
+
110
+
111
+ def _leaderboard(ranked: list[ModelResult]) -> str:
112
+ scored = [m for m in ranked if m.scores]
113
+
114
+ def best(get: Callable[[ModelResult], float | None], low: bool = False) -> float | None:
115
+ values = [v for m in scored if (v := get(m)) is not None]
116
+ if len(set(values)) < 2: # nothing stands out when every model ties
117
+ return None
118
+ return min(values) if low else max(values)
119
+
120
+ def cell(value: float | None, target: float | None, text: str) -> str:
121
+ cls = "num best" if value is not None and value == target and len(scored) > 1 else "num"
122
+ return f'<td class="{cls}">{e(text)}</td>'
123
+
124
+ tops = {
125
+ "r10": best(lambda m: m.scores.recall_at_10 if m.scores else None),
126
+ "r1": best(lambda m: m.scores.recall_at_1 if m.scores else None),
127
+ "mrr": best(lambda m: m.scores.mrr_at_10 if m.scores else None),
128
+ "lat": best(lambda m: m.query_ms_p50, low=True),
129
+ "dps": best(lambda m: m.docs_per_second),
130
+ "cost": best(lambda m: m.cost_per_million_docs, low=True),
131
+ "gb": best(lambda m: m.gb_per_million_vectors, low=True),
132
+ }
133
+ rows = []
134
+ for rank, m in enumerate(ranked, 1):
135
+ name = f'<td class="model"><b>{e(m.name)}</b><code>{e(m.model_tag or m.spec)}</code></td>'
136
+ if not m.scores:
137
+ rows.append(
138
+ f'<tr class="failed"><td class="rank">&ndash;</td>{name}'
139
+ f'<td colspan="7">Failed: {e(m.error or "unknown error")}</td></tr>'
140
+ )
141
+ continue
142
+ s = m.scores
143
+ r10 = s.recall_at_10
144
+ r10_cls = "best" if r10 == tops["r10"] and len(scored) > 1 else ""
145
+ cost = _money(m.cost_per_million_docs)
146
+ if m.tokens_estimated and m.cost_per_million_docs:
147
+ cost = "~" + cost
148
+ rows.append(
149
+ f'<tr><td class="rank">{rank}</td>{name}'
150
+ f'<td class="num"><div class="recall"><span class="{r10_cls}">{r10:.3f}</span>'
151
+ f'<span class="track" aria-hidden="true"><span style="width:{r10 * 100:.1f}%">'
152
+ "</span></span></div></td>"
153
+ + cell(s.recall_at_1, tops["r1"], f"{s.recall_at_1:.3f}")
154
+ + cell(s.mrr_at_10, tops["mrr"], f"{s.mrr_at_10:.3f}")
155
+ + cell(m.query_ms_p50, tops["lat"], _ms(m.query_ms_p50))
156
+ + cell(
157
+ m.docs_per_second,
158
+ tops["dps"],
159
+ f"{m.docs_per_second:,.0f}" if m.docs_per_second else "cached",
160
+ )
161
+ + cell(m.cost_per_million_docs, tops["cost"], cost)
162
+ + cell(m.gb_per_million_vectors, tops["gb"], _gb(m.gb_per_million_vectors))
163
+ + "</tr>"
164
+ )
165
+ head = (
166
+ '<tr><th class="rank">#</th><th>Model</th><th class="num">Recall@10</th>'
167
+ '<th class="num">Recall@1</th><th class="num">MRR@10</th>'
168
+ '<th class="num">Query p50</th><th class="num">Docs/s</th>'
169
+ '<th class="num">Per 1M docs</th><th class="num">Per 1M vectors</th></tr>'
170
+ )
171
+ return (
172
+ '<section aria-labelledby="lb-title"><h2 id="lb-title">Leaderboard</h2>'
173
+ '<div class="card lb"><div class="table-scroll"><table>'
174
+ f"<thead>{head}</thead><tbody>{''.join(rows)}</tbody></table></div></div></section>"
175
+ )
176
+
177
+
178
+ def _log_ticks(lo: float, hi: float) -> list[float]:
179
+ ticks = []
180
+ for exp in range(math.floor(math.log10(lo)) - 1, math.ceil(math.log10(hi)) + 1):
181
+ for step in (1, 2, 5):
182
+ t = step * 10.0**exp
183
+ if lo <= t <= hi:
184
+ ticks.append(t)
185
+ if len(ticks) > 6:
186
+ ticks = [t for t in ticks if str(t).lstrip("0.").startswith("1")] or ticks[::2]
187
+ return ticks
188
+
189
+
190
+ def _scatter(
191
+ ranked: list[ModelResult],
192
+ title: str,
193
+ subtitle: str,
194
+ get_x: Callable[[ModelResult], float | None],
195
+ fmt_x: Callable[[float], str],
196
+ x_name: str,
197
+ ) -> str:
198
+ points = [
199
+ (rank, m, x, m.scores.recall_at_10)
200
+ for rank, m in enumerate(ranked, 1)
201
+ if m.scores and (x := get_x(m)) is not None and x > 0
202
+ ]
203
+ head = f"<figcaption><b>{e(title)}</b><span>{e(subtitle)}</span></figcaption>"
204
+ if len(points) < 2:
205
+ return (
206
+ f'<figure class="card">{head}<p class="statement">Needs two or more models</p></figure>'
207
+ )
208
+
209
+ xs = [p[2] for p in points]
210
+ lo, hi = min(xs) / 1.6, max(xs) * 1.6
211
+ span = math.log10(hi) - math.log10(lo)
212
+
213
+ def xpos(v: float) -> float:
214
+ return 100 * (math.log10(v) - math.log10(lo)) / span
215
+
216
+ top = min(1.0, math.ceil(max(p[3] for p in points) * 10 + 0.5) / 10)
217
+ ygrid = "".join(
218
+ f'<div class="gridline" style="bottom:{f * 100:.0f}%"></div>' for f in (0.5, 1.0)
219
+ )
220
+ yaxis = "".join(
221
+ f'<span style="bottom:{f * 100:.0f}%">{top * f:.2f}</span>' for f in (0.0, 0.5, 1.0)
222
+ )
223
+ ticks = _log_ticks(lo, hi)
224
+ vgrid = "".join(f'<div class="vline" style="left:{xpos(t):.2f}%"></div>' for t in ticks)
225
+ xlabels = "".join(f'<span style="left:{xpos(t):.2f}%">{e(fmt_x(t))}</span>' for t in ticks)
226
+ dots = "".join(
227
+ f'<div class="pt" tabindex="0" style="left:{xpos(x):.2f}%;bottom:{100 * y / top:.2f}%" '
228
+ f'data-tip-value="{y:.3f} recall@10" data-tip-label="{e(m.name)} · {e(fmt_x(x))}" '
229
+ f'aria-label="{rank}. {e(m.name)}: recall@10 {y:.3f}, {e(fmt_x(x))}">{rank}</div>'
230
+ for rank, m, x, y in points
231
+ )
232
+ rows = "".join(
233
+ f'<tr><td>{rank}. {e(m.name)}</td><td class="num">{e(fmt_x(x))}</td>'
234
+ f'<td class="num">{y:.3f}</td></tr>'
235
+ for rank, m, x, y in points
236
+ )
237
+ table = (
238
+ '<details class="table-view"><summary>View as table</summary><div class="table-scroll">'
239
+ f'<table><thead><tr><th>Model</th><th class="num">{e(x_name)}</th>'
240
+ f'<th class="num">Recall@10</th></tr></thead><tbody>{rows}</tbody></table></div></details>'
241
+ )
242
+ plot = (
243
+ f'<div class="scatter" role="img" aria-label="{e(title)}">'
244
+ f'<div class="yaxis" aria-hidden="true" style="height:220px">{yaxis}</div>'
245
+ f'<div class="plot">{ygrid}{vgrid}{dots}</div>'
246
+ f'<div class="xaxis log" aria-hidden="true">{xlabels}</div>'
247
+ f'<div class="axis-title" aria-hidden="true">{e(x_name)}, log scale</div></div>'
248
+ )
249
+ return f'<figure class="card">{head}{plot}{table}</figure>'
250
+
251
+
252
+ def _charts(ranked: list[ModelResult]) -> str:
253
+ storage = _scatter(
254
+ ranked,
255
+ "Quality vs storage",
256
+ "Recall@10 against float32 storage for a million vectors. Up and left is better.",
257
+ lambda m: m.gb_per_million_vectors,
258
+ _gb,
259
+ "Storage per 1M vectors",
260
+ )
261
+ latency = _scatter(
262
+ ranked,
263
+ "Quality vs query speed",
264
+ "Recall@10 against median time to embed one query. Up and left is better.",
265
+ lambda m: m.query_ms_p50,
266
+ _ms,
267
+ "Query latency",
268
+ )
269
+ return (
270
+ '<section aria-labelledby="charts-title"><h2 id="charts-title">Trade-offs</h2>'
271
+ f'<div class="charts">{storage}{latency}</div>'
272
+ '<p class="chart-note">Points are numbered by leaderboard rank.</p></section>'
273
+ )
274
+
275
+
276
+ def _method(result: BenchResult) -> str:
277
+ parts = [
278
+ f"<p>Each model embedded the same {result.documents:,} documents and "
279
+ f"{result.queries:,} queries ({e(QUERY_SOURCES.get(result.query_source, ''))}). "
280
+ "Search is exact cosine similarity over all documents, so the numbers measure the "
281
+ "models, not an index. Recall@10 is the share of relevant documents found in the top "
282
+ "10 results.</p>",
283
+ *(f"<p>{e(n)}</p>" for n in result.notes),
284
+ *(
285
+ f'<p class="err">✖ {e(m.name)} failed: {e(m.error)}</p>'
286
+ for m in result.models
287
+ if m.error
288
+ ),
289
+ "<p>Costs use the provider's reported token counts and the price per million tokens "
290
+ "in each model spec; a ~ marks an estimate.</p>",
291
+ ]
292
+ return (
293
+ '<section aria-labelledby="method-title"><h2 id="method-title">How this was measured</h2>'
294
+ f'<div class="card method">{"".join(parts)}</div></section>'
295
+ )
296
+
297
+
298
+ def render_html(
299
+ result: BenchResult, *, version: str | None = None, generated_at: datetime | None = None
300
+ ) -> str:
301
+ when = stamp(generated_at)
302
+ ranked = result.ranked()
303
+ by = f"vecshift {version}" if version else "vecshift"
304
+ body = (
305
+ _tiles(result)
306
+ + _picks(result.models)
307
+ + _leaderboard(ranked)
308
+ + _charts(ranked)
309
+ + _method(result)
310
+ )
311
+ footer = (
312
+ f"<p>Generated by {e(by)} on {e(when)}.</p>"
313
+ "<p>This page holds scores and model names only, no document text. It loads nothing "
314
+ "from the network.</p>"
315
+ )
316
+ return page(
317
+ title=f"Embedding benchmark · {result.source}",
318
+ product="vecshift bench",
319
+ heading=f"Embedding models on {result.source}",
320
+ meta=[f"{len(result.models)} models", when],
321
+ body=body,
322
+ footer=footer,
323
+ generator=by,
324
+ extra_css=asset("bench.css"),
325
+ )
@@ -0,0 +1,50 @@
1
+ """Retrieval metrics computed with exact (brute-force) cosine search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+
12
+ K = 10
13
+
14
+ Matrix = npt.NDArray[np.float32]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Scores:
19
+ recall_at_1: float
20
+ recall_at_10: float
21
+ mrr_at_10: float
22
+ ndcg_at_10: float
23
+
24
+
25
+ def normalize(vectors: Sequence[Sequence[float]]) -> Matrix:
26
+ m = np.asarray(vectors, dtype=np.float32)
27
+ norms = np.linalg.norm(m, axis=1, keepdims=True)
28
+ norms[norms == 0] = 1.0
29
+ return m / norms
30
+
31
+
32
+ def evaluate(docs: Matrix, queries: Matrix, relevant: Sequence[set[int]]) -> Scores:
33
+ """Score each query's top 10 documents against its relevant document indexes."""
34
+ k = min(K, docs.shape[0])
35
+ sims = queries @ docs.T
36
+ top = np.argpartition(-sims, k - 1, axis=1)[:, :k]
37
+ order = np.take_along_axis(sims, top, axis=1).argsort(axis=1)[:, ::-1]
38
+ ranked = np.take_along_axis(top, order, axis=1)
39
+
40
+ r1 = r10 = mrr = ndcg = 0.0
41
+ for row, rel in zip(ranked.tolist(), relevant, strict=True):
42
+ hits = [rank for rank, doc in enumerate(row, 1) if doc in rel]
43
+ r1 += (1 if hits and hits[0] == 1 else 0) / len(rel)
44
+ r10 += len(hits) / len(rel)
45
+ mrr += 1 / hits[0] if hits else 0.0
46
+ dcg = sum(1 / math.log2(rank + 1) for rank in hits)
47
+ ideal = sum(1 / math.log2(i + 1) for i in range(1, min(len(rel), k) + 1))
48
+ ndcg += dcg / ideal
49
+ n = len(relevant)
50
+ return Scores(r1 / n, r10 / n, mrr / n, ndcg / n)
@@ -0,0 +1,183 @@
1
+ """Run a benchmark: embed, search, score, and measure each model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ import time
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import asdict, dataclass, field
9
+ from typing import Any
10
+
11
+ from vecshift.bench.corpus import Benchmark
12
+ from vecshift.bench.metrics import Scores, evaluate, normalize
13
+ from vecshift.embeddings import (
14
+ CachedEmbedder,
15
+ EmbeddingCache,
16
+ EmbeddingError,
17
+ ModelSpec,
18
+ create_embedder,
19
+ )
20
+
21
+ LATENCY_SAMPLES = 20
22
+ CHARS_PER_TOKEN = 4
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class ModelResult:
27
+ name: str
28
+ spec: str
29
+ dimensions: int | None = None
30
+ model_tag: str | None = None
31
+ scores: Scores | None = None
32
+ error: str | None = None
33
+ docs_per_second: float | None = None
34
+ """Documents embedded per second, or ``None`` when every document was cached."""
35
+ query_ms_p50: float | None = None
36
+ query_ms_p95: float | None = None
37
+ tokens_per_doc: float | None = None
38
+ tokens_estimated: bool = False
39
+ price_per_million_tokens: float | None = None
40
+ cached_docs: int = 0
41
+ local: bool = False
42
+
43
+ @property
44
+ def cost_per_million_docs(self) -> float | None:
45
+ if self.price_per_million_tokens is None or self.tokens_per_doc is None:
46
+ return None
47
+ return self.tokens_per_doc * self.price_per_million_tokens
48
+
49
+ @property
50
+ def gb_per_million_vectors(self) -> float | None:
51
+ """float32 storage for a million vectors, in GB."""
52
+ return self.dimensions * 4 / 1000 if self.dimensions else None
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ data = {k: v for k, v in asdict(self).items() if k != "scores"}
56
+ data["scores"] = asdict(self.scores) if self.scores else None
57
+ data["cost_per_million_docs"] = self.cost_per_million_docs
58
+ data["gb_per_million_vectors"] = self.gb_per_million_vectors
59
+ return data
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class BenchResult:
64
+ source: str
65
+ documents: int
66
+ queries: int
67
+ query_source: str
68
+ notes: list[str] = field(default_factory=list)
69
+ models: list[ModelResult] = field(default_factory=list)
70
+
71
+ def ranked(self) -> list[ModelResult]:
72
+ """Best recall@10 first; failed models last."""
73
+ return sorted(
74
+ self.models,
75
+ key=lambda m: (m.scores is None, -(m.scores.recall_at_10 if m.scores else 0)),
76
+ )
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "source": self.source,
81
+ "documents": self.documents,
82
+ "queries": self.queries,
83
+ "query_source": self.query_source,
84
+ "notes": self.notes,
85
+ "models": [m.to_dict() for m in self.ranked()],
86
+ }
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class PlanItem:
91
+ spec: ModelSpec
92
+ est_tokens: int
93
+ est_cost: float | None
94
+
95
+
96
+ def plan(bench: Benchmark, specs: Sequence[ModelSpec]) -> list[PlanItem]:
97
+ """Estimated tokens and cost per model, before anything is sent."""
98
+ chars = sum(len(d.text) for d in bench.documents) + sum(len(q.text) for q in bench.queries)
99
+ chars += sum(len(q.text) for q in bench.queries[:LATENCY_SAMPLES])
100
+ tokens = chars // CHARS_PER_TOKEN
101
+ return [
102
+ PlanItem(s, tokens, None if s.price is None else tokens * s.price / 1_000_000)
103
+ for s in specs
104
+ ]
105
+
106
+
107
+ def _percentile(values: Sequence[float], pct: float) -> float:
108
+ ordered = sorted(values)
109
+ index = min(len(ordered) - 1, max(0, round(pct / 100 * (len(ordered) - 1))))
110
+ return ordered[index]
111
+
112
+
113
+ async def _run_one(bench: Benchmark, spec: ModelSpec, cache: EmbeddingCache | None) -> ModelResult:
114
+ result = ModelResult(
115
+ name=spec.name,
116
+ spec=spec.safe_raw,
117
+ price_per_million_tokens=spec.price,
118
+ local=spec.is_local,
119
+ )
120
+ embedder = create_embedder(spec)
121
+ use_cache = cache is not None and spec.provider != "hash"
122
+ wrapped = CachedEmbedder(embedder, cache) if use_cache and cache else None
123
+ try:
124
+ doc_texts = [d.text for d in bench.documents]
125
+ start = time.perf_counter()
126
+ doc_vecs = await (wrapped or embedder).embed(doc_texts, "document")
127
+ elapsed = time.perf_counter() - start
128
+ fresh = wrapped.misses if wrapped else len(doc_texts)
129
+ result.cached_docs = len(doc_texts) - fresh
130
+ if fresh:
131
+ result.docs_per_second = fresh / elapsed if elapsed > 0 else None
132
+ result.tokens_per_doc = embedder.tokens / fresh
133
+ result.tokens_estimated = embedder.tokens_estimated
134
+ else:
135
+ result.tokens_per_doc = (
136
+ sum(len(t) for t in doc_texts) / len(doc_texts) / CHARS_PER_TOKEN
137
+ )
138
+ result.tokens_estimated = True
139
+
140
+ query_texts = [q.text for q in bench.queries]
141
+ query_vecs = await (wrapped or embedder).embed(query_texts, "query")
142
+
143
+ timings = []
144
+ for text in query_texts[:LATENCY_SAMPLES]:
145
+ t0 = time.perf_counter()
146
+ await embedder.embed([text], "query")
147
+ timings.append((time.perf_counter() - t0) * 1000)
148
+ if timings:
149
+ result.query_ms_p50 = statistics.median(timings)
150
+ result.query_ms_p95 = _percentile(timings, 95)
151
+
152
+ index = {d.id: i for i, d in enumerate(bench.documents)}
153
+ relevant = [{index[r] for r in q.relevant if r in index} for q in bench.queries]
154
+ result.scores = evaluate(normalize(doc_vecs), normalize(query_vecs), relevant)
155
+ result.dimensions = len(doc_vecs[0])
156
+ result.model_tag = embedder.fingerprint.model_tag
157
+ except EmbeddingError as exc:
158
+ result.error = str(exc)
159
+ finally:
160
+ await embedder.aclose()
161
+ return result
162
+
163
+
164
+ async def run(
165
+ bench: Benchmark,
166
+ specs: Sequence[ModelSpec],
167
+ cache: EmbeddingCache | None,
168
+ source: str,
169
+ on_model: Callable[[ModelSpec], None] | None = None,
170
+ ) -> BenchResult:
171
+ """Benchmark each model in turn, so latency measurements don't compete."""
172
+ result = BenchResult(
173
+ source=source,
174
+ documents=len(bench.documents),
175
+ queries=len(bench.queries),
176
+ query_source=bench.query_source,
177
+ notes=list(bench.notes),
178
+ )
179
+ for spec in specs:
180
+ if on_model:
181
+ on_model(spec)
182
+ result.models.append(await _run_one(bench, spec, cache))
183
+ return result