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
|
@@ -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">–</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)
|
vecshift/bench/runner.py
ADDED
|
@@ -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
|