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/doctor/html.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Render a doctor :class:`Report` as one self-contained HTML file.
|
|
2
|
+
|
|
3
|
+
The page inlines its own styles and script and fetches nothing, so it can be opened
|
|
4
|
+
offline, attached to a ticket, or shared without sending data anywhere. It holds
|
|
5
|
+
statistics only: no vectors and no row text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from vecshift.doctor.findings import Finding, Report, Severity
|
|
15
|
+
from vecshift.html_kit import compact, page, percent, stamp
|
|
16
|
+
from vecshift.html_kit import e as _e
|
|
17
|
+
from vecshift.html_kit import nice_ceiling as _nice_ceiling
|
|
18
|
+
from vecshift.html_kit import rich as _rich
|
|
19
|
+
|
|
20
|
+
MAX_BARS = 7
|
|
21
|
+
DASH = "\u2013" # en dash, for ranges
|
|
22
|
+
|
|
23
|
+
_ICONS = {
|
|
24
|
+
Severity.ERROR: (
|
|
25
|
+
'<circle cx="12" cy="12" r="11" fill="var(--critical)"/>'
|
|
26
|
+
'<path d="M8 8l8 8M16 8l-8 8" stroke="var(--on-status)" stroke-width="2.2" '
|
|
27
|
+
'stroke-linecap="round"/>'
|
|
28
|
+
),
|
|
29
|
+
Severity.WARNING: (
|
|
30
|
+
'<path d="M12 2.5 23 21.5H1z" fill="var(--warning)" stroke="var(--warning)" '
|
|
31
|
+
'stroke-width="1.5" stroke-linejoin="round"/>'
|
|
32
|
+
'<path d="M12 9v5.5" stroke="var(--on-warning)" stroke-width="2.2" '
|
|
33
|
+
'stroke-linecap="round"/><circle cx="12" cy="18" r="1.3" fill="var(--on-warning)"/>'
|
|
34
|
+
),
|
|
35
|
+
Severity.INFO: (
|
|
36
|
+
'<circle cx="12" cy="12" r="11" fill="var(--info)"/>'
|
|
37
|
+
'<path d="M12 11v6" stroke="var(--surface)" stroke-width="2.2" stroke-linecap="round"/>'
|
|
38
|
+
'<circle cx="12" cy="7.5" r="1.4" fill="var(--surface)"/>'
|
|
39
|
+
),
|
|
40
|
+
Severity.OK: (
|
|
41
|
+
'<circle cx="12" cy="12" r="11" fill="var(--good)"/>'
|
|
42
|
+
'<path d="m7.5 12.5 3 3 6-6.5" fill="none" stroke="var(--on-status)" '
|
|
43
|
+
'stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_LABELS = {
|
|
48
|
+
Severity.ERROR: ("Error", "Errors"),
|
|
49
|
+
Severity.WARNING: ("Warning", "Warnings"),
|
|
50
|
+
Severity.INFO: ("Info", "Info"),
|
|
51
|
+
Severity.OK: ("OK", "OK"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _icon(severity: Severity, large: bool = False) -> str:
|
|
56
|
+
cls = "sev sev-lg" if large else "sev"
|
|
57
|
+
return f'<svg class="{cls}" viewBox="0 0 24 24" aria-hidden="true">{_ICONS[severity]}</svg>'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fmt_norm(x: float) -> str:
|
|
61
|
+
if x == 0:
|
|
62
|
+
return "0"
|
|
63
|
+
if abs(x) >= 100:
|
|
64
|
+
return f"{x:,.0f}"
|
|
65
|
+
if abs(x) >= 10:
|
|
66
|
+
return f"{x:.1f}"
|
|
67
|
+
return f"{x:.2f}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class _Tile:
|
|
72
|
+
label: str
|
|
73
|
+
value: str
|
|
74
|
+
caption: str
|
|
75
|
+
state: Severity | None = None
|
|
76
|
+
meter: float | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _verdict(report: Report) -> str:
|
|
80
|
+
errors = report.count(Severity.ERROR)
|
|
81
|
+
warnings = report.count(Severity.WARNING)
|
|
82
|
+
|
|
83
|
+
def n(count: int, word: str) -> str:
|
|
84
|
+
return f"{count} {word}{'' if count == 1 else 's'}"
|
|
85
|
+
|
|
86
|
+
if report.sample_rows == 0 and not errors and not warnings:
|
|
87
|
+
tone, severity = "info", Severity.INFO
|
|
88
|
+
title, text = "Nothing to inspect", "No rows were visible, so only the schema was checked."
|
|
89
|
+
elif errors:
|
|
90
|
+
tone, severity = "error", Severity.ERROR
|
|
91
|
+
title = "Not ready to migrate"
|
|
92
|
+
text = f"Fix {n(errors, 'error')} before migrating"
|
|
93
|
+
text += f", and review {n(warnings, 'warning')}." if warnings else "."
|
|
94
|
+
elif warnings:
|
|
95
|
+
tone, severity = "warning", Severity.WARNING
|
|
96
|
+
title = "Ready to migrate, with caveats"
|
|
97
|
+
text = f"Review {n(warnings, 'warning')} before migrating."
|
|
98
|
+
else:
|
|
99
|
+
tone, severity = "ok", Severity.OK
|
|
100
|
+
title = "Ready to migrate"
|
|
101
|
+
text = "No problems found in the inspected rows."
|
|
102
|
+
|
|
103
|
+
counts = "".join(
|
|
104
|
+
f"<li>{_icon(s)}<span><b>{report.count(s)}</b> "
|
|
105
|
+
f"{_LABELS[s][0 if report.count(s) == 1 else 1].lower()}</span></li>"
|
|
106
|
+
for s in (Severity.ERROR, Severity.WARNING, Severity.INFO, Severity.OK)
|
|
107
|
+
)
|
|
108
|
+
return (
|
|
109
|
+
f'<section class="card verdict" data-tone="{tone}" aria-labelledby="verdict-title">'
|
|
110
|
+
f"{_icon(severity, large=True)}"
|
|
111
|
+
f'<div><h2 id="verdict-title">{_e(title)}</h2><p>{_e(text)}</p>'
|
|
112
|
+
f'<ul class="counts" aria-label="Findings by severity">{counts}</ul></div>'
|
|
113
|
+
"</section>"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _tiles(report: Report) -> list[_Tile]:
|
|
118
|
+
f = report.facts
|
|
119
|
+
sample = f.get("sample", {})
|
|
120
|
+
rows = report.sample_rows
|
|
121
|
+
tiles: list[_Tile] = []
|
|
122
|
+
|
|
123
|
+
if report.estimated_rows is None:
|
|
124
|
+
tiles.append(_Tile("Rows", "Unknown", "table not analyzed yet"))
|
|
125
|
+
else:
|
|
126
|
+
exact = report.sample_method == "full table"
|
|
127
|
+
tiles.append(
|
|
128
|
+
_Tile(
|
|
129
|
+
"Rows",
|
|
130
|
+
compact(report.estimated_rows),
|
|
131
|
+
"exact count" if exact else "estimated from statistics",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
tiles.append(_Tile("Inspected", compact(rows), report.sample_method))
|
|
135
|
+
|
|
136
|
+
dims: dict[str, int] = f.get("dimensions", {})
|
|
137
|
+
if len(dims) > 1:
|
|
138
|
+
tiles.append(
|
|
139
|
+
_Tile("Vector size", "Mixed", f"{len(dims)} sizes in the sample", Severity.ERROR)
|
|
140
|
+
)
|
|
141
|
+
elif report.declared_dimensions:
|
|
142
|
+
tiles.append(
|
|
143
|
+
_Tile(
|
|
144
|
+
"Vector size",
|
|
145
|
+
f"{report.declared_dimensions:,}",
|
|
146
|
+
f"{report.vector_type}, fixed by the schema",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
elif dims:
|
|
150
|
+
(size,) = dims
|
|
151
|
+
tiles.append(
|
|
152
|
+
_Tile(
|
|
153
|
+
"Vector size",
|
|
154
|
+
f"{int(size):,}",
|
|
155
|
+
f"{report.vector_type}, not fixed by the schema",
|
|
156
|
+
Severity.WARNING,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
tiles.append(_Tile("Vector size", "—", f"{report.vector_type}, no vectors sampled"))
|
|
161
|
+
|
|
162
|
+
text_field = f.get("text_field")
|
|
163
|
+
present = sample.get("texts_present") or 0
|
|
164
|
+
if text_field is None:
|
|
165
|
+
tiles.append(_Tile("Re-embeddable", "None", "no source text column", Severity.ERROR, 0.0))
|
|
166
|
+
elif rows:
|
|
167
|
+
state = Severity.WARNING if present < rows else None
|
|
168
|
+
tiles.append(
|
|
169
|
+
_Tile(
|
|
170
|
+
"Re-embeddable",
|
|
171
|
+
percent(present, rows),
|
|
172
|
+
f"rows with text in {text_field}",
|
|
173
|
+
state,
|
|
174
|
+
present / rows,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
tiles.append(_Tile("Re-embeddable", "—", f"text in {text_field}"))
|
|
179
|
+
|
|
180
|
+
norms = f.get("norms")
|
|
181
|
+
if norms:
|
|
182
|
+
nonzero = norms["count"] - norms["zero"]
|
|
183
|
+
unit = norms["unit"]
|
|
184
|
+
# Zero vectors have their own finding; this tile is only about normalization.
|
|
185
|
+
norm_state = Severity.WARNING if 0 < unit < nonzero else None
|
|
186
|
+
caption = "unit-length vectors" if unit else "not normalized; fine with cosine"
|
|
187
|
+
tiles.append(
|
|
188
|
+
_Tile(
|
|
189
|
+
"Normalized",
|
|
190
|
+
percent(unit, norms["count"]),
|
|
191
|
+
caption,
|
|
192
|
+
norm_state,
|
|
193
|
+
unit / norms["count"],
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
tiles.append(_Tile("Normalized", "—", "no vectors sampled"))
|
|
198
|
+
|
|
199
|
+
if text_field is not None and present:
|
|
200
|
+
dupes, base, what = sample.get("duplicate_texts", 0), present, "repeated texts"
|
|
201
|
+
else:
|
|
202
|
+
dupes, base, what = (
|
|
203
|
+
sample.get("duplicate_vectors", 0),
|
|
204
|
+
sample.get("vectors", 0),
|
|
205
|
+
("repeated vectors"),
|
|
206
|
+
)
|
|
207
|
+
if base:
|
|
208
|
+
warn = any(x.id.startswith("duplicates.") for x in report.findings)
|
|
209
|
+
tiles.append(
|
|
210
|
+
_Tile(
|
|
211
|
+
"Duplicates",
|
|
212
|
+
percent(dupes, base),
|
|
213
|
+
f"{what} in the sample",
|
|
214
|
+
Severity.WARNING if warn else None,
|
|
215
|
+
dupes / base,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
tiles.append(_Tile("Duplicates", "—", "nothing sampled"))
|
|
220
|
+
return tiles
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _render_tile(t: _Tile) -> str:
|
|
224
|
+
flag = ""
|
|
225
|
+
if t.state is not None:
|
|
226
|
+
flag = f'{_icon(t.state)}<span class="sr-only">{_LABELS[t.state][0]}:</span>'
|
|
227
|
+
meter = ""
|
|
228
|
+
if t.meter is not None:
|
|
229
|
+
state = {Severity.ERROR: "error", Severity.WARNING: "warning"}.get(
|
|
230
|
+
t.state or Severity.OK, "series"
|
|
231
|
+
)
|
|
232
|
+
width = max(0.0, min(1.0, t.meter)) * 100
|
|
233
|
+
meter = (
|
|
234
|
+
f'<div class="meter" data-state="{state}" aria-hidden="true">'
|
|
235
|
+
f'<span style="width:{width:.1f}%"></span></div>'
|
|
236
|
+
)
|
|
237
|
+
return (
|
|
238
|
+
f'<div class="card tile"><div class="tile-label">{flag}{_e(t.label)}</div>'
|
|
239
|
+
f'<div class="tile-value">{_e(t.value)}</div>'
|
|
240
|
+
f'<div class="tile-caption">{_e(t.caption)}</div>{meter}</div>'
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_finding(finding: Finding) -> str:
|
|
245
|
+
hint = f'<p class="hint">{_rich(finding.hint)}</p>' if finding.hint else ""
|
|
246
|
+
label = _LABELS[finding.severity][0]
|
|
247
|
+
return (
|
|
248
|
+
f'<article class="card finding" data-severity="{finding.severity}">'
|
|
249
|
+
f"{_icon(finding.severity)}"
|
|
250
|
+
f'<div class="finding-head"><span class="label">{label}</span>'
|
|
251
|
+
f"<h3>{_rich(finding.title)}</h3>"
|
|
252
|
+
f'<span class="fid">{_e(finding.id)}</span></div>'
|
|
253
|
+
f"<p>{_rich(finding.detail)}</p>{hint}</article>"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _findings(report: Report) -> str:
|
|
258
|
+
ordered = report.sorted_findings()
|
|
259
|
+
buttons = [
|
|
260
|
+
f'<button type="button" data-filter="all" aria-pressed="true">All {len(ordered)}</button>'
|
|
261
|
+
]
|
|
262
|
+
for s in (Severity.ERROR, Severity.WARNING, Severity.INFO, Severity.OK):
|
|
263
|
+
n = report.count(s)
|
|
264
|
+
disabled = " disabled" if n == 0 else ""
|
|
265
|
+
buttons.append(
|
|
266
|
+
f'<button type="button" data-filter="{s}" aria-pressed="false"{disabled}>'
|
|
267
|
+
f"{_LABELS[s][1]} {n}</button>"
|
|
268
|
+
)
|
|
269
|
+
return (
|
|
270
|
+
'<section aria-labelledby="findings-title"><h2 id="findings-title">Findings</h2>'
|
|
271
|
+
f'<div class="filters" role="toolbar" aria-label="Filter findings">{"".join(buttons)}</div>'
|
|
272
|
+
f'<div class="findings">{"".join(_render_finding(x) for x in ordered)}</div></section>'
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _table(headers: tuple[str, str], rows: list[tuple[str, str]]) -> str:
|
|
277
|
+
body = "".join(f'<tr><td>{_e(a)}</td><td class="num">{_e(b)}</td></tr>' for a, b in rows)
|
|
278
|
+
return (
|
|
279
|
+
'<details class="table-view"><summary>View as table</summary>'
|
|
280
|
+
f'<div class="table-scroll"><table><thead><tr><th>{_e(headers[0])}</th>'
|
|
281
|
+
f'<th class="num">{_e(headers[1])}</th></tr></thead><tbody>{body}</tbody></table></div>'
|
|
282
|
+
"</details>"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _histogram(norms: dict[str, Any] | None) -> str:
|
|
287
|
+
head = (
|
|
288
|
+
"<figcaption><b>Vector lengths</b><span>L2 norm of each sampled vector. "
|
|
289
|
+
"Normalized vectors sit at 1.0.</span></figcaption>"
|
|
290
|
+
)
|
|
291
|
+
if not norms:
|
|
292
|
+
return (
|
|
293
|
+
f'<figure class="card wide">{head}<p class="statement">No vectors sampled</p></figure>'
|
|
294
|
+
)
|
|
295
|
+
lo, hi = norms["min"], norms["max"]
|
|
296
|
+
tol = norms["tolerance"]
|
|
297
|
+
if hi - lo <= 2 * tol:
|
|
298
|
+
return (
|
|
299
|
+
f'<figure class="card wide">{head}'
|
|
300
|
+
f'<p class="statement">All {norms["count"]:,} vectors have length '
|
|
301
|
+
f"{_fmt_norm(norms['median'])}</p>"
|
|
302
|
+
f'<p class="note">The spread is under ±{tol}, so there is nothing to chart.</p>'
|
|
303
|
+
"</figure>"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
bins: list[dict[str, float]] = norms["histogram"]
|
|
307
|
+
peak = max(int(b["count"]) for b in bins)
|
|
308
|
+
top = _nice_ceiling(peak)
|
|
309
|
+
grid = "".join(
|
|
310
|
+
f'<div class="gridline" style="bottom:{frac * 100:.0f}%"></div>' for frac in (0.5, 1.0)
|
|
311
|
+
)
|
|
312
|
+
yaxis = "".join(
|
|
313
|
+
f'<span style="bottom:{frac * 100:.0f}%">{compact(round(top * frac))}</span>'
|
|
314
|
+
for frac in (0.0, 0.5, 1.0)
|
|
315
|
+
)
|
|
316
|
+
cells = []
|
|
317
|
+
for b in bins:
|
|
318
|
+
count = int(b["count"])
|
|
319
|
+
height = 0.0 if count == 0 else max(1.5, 100 * count / top)
|
|
320
|
+
label = f"length {_fmt_norm(b['start'])}{DASH}{_fmt_norm(b['end'])}"
|
|
321
|
+
value = f"{count:,} vector{'' if count == 1 else 's'}"
|
|
322
|
+
cells.append(
|
|
323
|
+
f'<div class="bin" tabindex="0" data-tip-value="{_e(value)}" '
|
|
324
|
+
f'data-tip-label="{_e(label)}" aria-label="{_e(value)}, {_e(label)}">'
|
|
325
|
+
f'<i style="height:{height:.2f}%"></i></div>'
|
|
326
|
+
)
|
|
327
|
+
ref = ""
|
|
328
|
+
if lo <= 1.0 <= hi:
|
|
329
|
+
x = 100 * (1.0 - lo) / (hi - lo)
|
|
330
|
+
flip = " flip" if x > 80 else ""
|
|
331
|
+
ref = (
|
|
332
|
+
f'<div class="refline{flip}" style="left:{x:.2f}%" aria-hidden="true">'
|
|
333
|
+
"<span>1.0 = unit length</span></div>"
|
|
334
|
+
)
|
|
335
|
+
plot = (
|
|
336
|
+
'<div class="hist" role="img" aria-label="Histogram of vector lengths">'
|
|
337
|
+
f'<div class="yaxis" aria-hidden="true">{yaxis}</div>'
|
|
338
|
+
f'<div class="plot">{grid}<div class="bins">{"".join(cells)}</div>{ref}</div>'
|
|
339
|
+
f'<div class="xaxis" aria-hidden="true"><span>{_fmt_norm(lo)}</span>'
|
|
340
|
+
f"<span>{_fmt_norm((lo + hi) / 2)}</span><span>{_fmt_norm(hi)}</span></div>"
|
|
341
|
+
'<div class="axis-title" aria-hidden="true">vector length</div></div>'
|
|
342
|
+
)
|
|
343
|
+
table = _table(
|
|
344
|
+
("Length", "Vectors"),
|
|
345
|
+
[
|
|
346
|
+
(f"{_fmt_norm(b['start'])}{DASH}{_fmt_norm(b['end'])}", f"{int(b['count']):,}")
|
|
347
|
+
for b in bins
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
return f'<figure class="card wide">{head}{plot}{table}</figure>'
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _breakdown(
|
|
354
|
+
title: str, subtitle: str, counts: dict[str, int], noun: str, empty: str, single: str
|
|
355
|
+
) -> str:
|
|
356
|
+
head = f"<figcaption><b>{_e(title)}</b><span>{_rich(subtitle)}</span></figcaption>"
|
|
357
|
+
if not counts:
|
|
358
|
+
return f'<figure class="card">{head}<p class="statement">{_e(empty)}</p></figure>'
|
|
359
|
+
total = sum(counts.values())
|
|
360
|
+
items = sorted(counts.items(), key=lambda kv: -kv[1])
|
|
361
|
+
if len(items) == 1:
|
|
362
|
+
(name, n) = items[0]
|
|
363
|
+
return (
|
|
364
|
+
f'<figure class="card">{head}<p class="statement">{_e(name)}</p>'
|
|
365
|
+
f'<p class="note">{_e(single.format(n=n))}</p></figure>'
|
|
366
|
+
)
|
|
367
|
+
if len(items) > MAX_BARS:
|
|
368
|
+
rest = sum(n for _, n in items[MAX_BARS - 1 :])
|
|
369
|
+
items = [*items[: MAX_BARS - 1], (f"Other ({len(items) - MAX_BARS + 1})", rest)]
|
|
370
|
+
peak = max(n for _, n in items)
|
|
371
|
+
rows = []
|
|
372
|
+
for name, n in items:
|
|
373
|
+
share = percent(n, total)
|
|
374
|
+
rows.append(
|
|
375
|
+
f'<div class="bar-row" tabindex="0" data-tip-value="{n:,} {noun} · {share}" '
|
|
376
|
+
f'data-tip-label="{_e(name)}" aria-label="{_e(name)}: {n:,} {noun}, {share}">'
|
|
377
|
+
f'<div class="bar-label">{_e(name)}</div><div class="bar-cell">'
|
|
378
|
+
f'<div class="bar" style="width:calc({100 * n / peak:.2f}% - 72px)"></div>'
|
|
379
|
+
f'<span class="bar-value">{n:,} · {share}</span></div></div>'
|
|
380
|
+
)
|
|
381
|
+
table = _table((title.split()[0], noun.capitalize()), [(k, f"{v:,}") for k, v in items])
|
|
382
|
+
return f'<figure class="card">{head}<div class="bars">{"".join(rows)}</div>{table}</figure>'
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _charts(report: Report) -> str:
|
|
386
|
+
f = report.facts
|
|
387
|
+
models = f.get("models", {})
|
|
388
|
+
model_field = f.get("model_field")
|
|
389
|
+
model_sub = f"Recorded in `{model_field}`" if model_field else "Where rows record their model"
|
|
390
|
+
dims = {f"{int(k):,} dimensions": v for k, v in f.get("dimensions", {}).items()}
|
|
391
|
+
models_card = _breakdown(
|
|
392
|
+
"Models",
|
|
393
|
+
model_sub,
|
|
394
|
+
models,
|
|
395
|
+
"vectors",
|
|
396
|
+
empty="No model recorded",
|
|
397
|
+
single="The only model named by the {n:,} sampled vectors that record one.",
|
|
398
|
+
)
|
|
399
|
+
sizes_card = _breakdown(
|
|
400
|
+
"Vector sizes",
|
|
401
|
+
"Dimensions per sampled vector",
|
|
402
|
+
dims,
|
|
403
|
+
"vectors",
|
|
404
|
+
empty="No vectors sampled",
|
|
405
|
+
single="All {n:,} sampled vectors have this size.",
|
|
406
|
+
)
|
|
407
|
+
return (
|
|
408
|
+
'<section aria-labelledby="sample-title"><h2 id="sample-title">Inside the sample</h2>'
|
|
409
|
+
f'<div class="charts">{_histogram(f.get("norms"))}{models_card}{sizes_card}</div>'
|
|
410
|
+
"</section>"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _facts(report: Report, connection: str | None) -> str:
|
|
415
|
+
f = report.facts
|
|
416
|
+
yes_no = {True: "Yes", False: "No"}
|
|
417
|
+
if f.get("logical_replication") and f.get("has_primary_key"):
|
|
418
|
+
tracking = "Logical replication"
|
|
419
|
+
elif f.get("updated_at_field"):
|
|
420
|
+
tracking = f"Polling `{f['updated_at_field']}`"
|
|
421
|
+
else:
|
|
422
|
+
tracking = "None"
|
|
423
|
+
indexes = ", ".join(
|
|
424
|
+
f"{i['name']} ({i['method']}{', ' + i['metric'] if i.get('metric') else ''})"
|
|
425
|
+
for i in f.get("ann_indexes", [])
|
|
426
|
+
)
|
|
427
|
+
dims = (
|
|
428
|
+
f"`{report.vector_type}({report.declared_dimensions})`"
|
|
429
|
+
if report.declared_dimensions
|
|
430
|
+
else f"`{report.vector_type}` (size not fixed)"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def code(name: str | None, missing: str) -> str:
|
|
434
|
+
return f"`{name}`" if name else missing
|
|
435
|
+
|
|
436
|
+
rows: list[tuple[str, str]] = [
|
|
437
|
+
("Column", code(report.target, "")),
|
|
438
|
+
("Type", dims),
|
|
439
|
+
("Source text", code(f.get("text_field"), "Not found")),
|
|
440
|
+
("Model recorded in", code(f.get("model_field"), "Not recorded")),
|
|
441
|
+
("Primary key", yes_no[bool(f.get("has_primary_key"))]),
|
|
442
|
+
("Change tracking", tracking),
|
|
443
|
+
("ANN indexes", indexes or "None"),
|
|
444
|
+
(
|
|
445
|
+
"Row-level security",
|
|
446
|
+
"Hides rows from this role" if f.get("rows_hidden_by_access_rules") else "Not limiting",
|
|
447
|
+
),
|
|
448
|
+
("Sampling", f"{report.sample_rows:,} rows, {report.sample_method}"),
|
|
449
|
+
]
|
|
450
|
+
if connection:
|
|
451
|
+
rows.append(("Connection", f"`{connection}`"))
|
|
452
|
+
items = "".join(f'<div class="fact"><dt>{_e(k)}</dt><dd>{_rich(v)}</dd></div>' for k, v in rows)
|
|
453
|
+
return (
|
|
454
|
+
'<section aria-labelledby="setup-title"><h2 id="setup-title">Setup</h2>'
|
|
455
|
+
f'<dl class="card facts">{items}</dl></section>'
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def render_html(
|
|
460
|
+
report: Report,
|
|
461
|
+
*,
|
|
462
|
+
connection: str | None = None,
|
|
463
|
+
connection_kind: str | None = None,
|
|
464
|
+
version: str | None = None,
|
|
465
|
+
generated_at: datetime | None = None,
|
|
466
|
+
) -> str:
|
|
467
|
+
"""The report as a complete HTML document.
|
|
468
|
+
|
|
469
|
+
``connection`` must already have its password removed.
|
|
470
|
+
"""
|
|
471
|
+
when = stamp(generated_at)
|
|
472
|
+
meta = [report.store, *([connection_kind] if connection_kind else []), when]
|
|
473
|
+
tiles = "".join(_render_tile(t) for t in _tiles(report))
|
|
474
|
+
by = f"vecshift {version}" if version else "vecshift"
|
|
475
|
+
body = (
|
|
476
|
+
f"{_verdict(report)}"
|
|
477
|
+
f'<section aria-label="Key numbers"><div class="tiles">{tiles}</div></section>'
|
|
478
|
+
f"{_findings(report)}{_charts(report)}{_facts(report, connection)}"
|
|
479
|
+
)
|
|
480
|
+
footer = (
|
|
481
|
+
f"<p>Generated by {_e(by)} on {_e(when)}.</p>"
|
|
482
|
+
"<p>This report holds statistics only: no vectors and no row text. It loads nothing "
|
|
483
|
+
"from the network.</p>"
|
|
484
|
+
)
|
|
485
|
+
return page(
|
|
486
|
+
title=f"Doctor report · {report.target}",
|
|
487
|
+
product="vecshift doctor",
|
|
488
|
+
heading=report.target,
|
|
489
|
+
meta=meta,
|
|
490
|
+
body=body,
|
|
491
|
+
footer=footer,
|
|
492
|
+
generator=by,
|
|
493
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""What a connector learns about an index, in a store-independent shape.
|
|
2
|
+
|
|
3
|
+
Connectors fill in an :class:`IndexProfile`; the checks in :mod:`vecshift.doctor.checks`
|
|
4
|
+
only ever look at the profile, never at the store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class AnnIndex:
|
|
15
|
+
"""An approximate-nearest-neighbour index over the vector field."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
method: str
|
|
19
|
+
"""For example ``hnsw`` or ``ivfflat``."""
|
|
20
|
+
metric: str | None
|
|
21
|
+
"""``cosine``, ``inner_product``, ``l2``, ``l1``, or ``None`` if unknown."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Sample:
|
|
26
|
+
"""Statistics over a sample of rows. Vectors themselves are never fetched."""
|
|
27
|
+
|
|
28
|
+
rows: int
|
|
29
|
+
method: str
|
|
30
|
+
"""How rows were chosen, for example ``"full table"`` or ``"table sample"``."""
|
|
31
|
+
null_vectors: int = 0
|
|
32
|
+
dimensions: Counter[int] = field(default_factory=Counter)
|
|
33
|
+
norms: list[float] = field(default_factory=list)
|
|
34
|
+
texts_present: int | None = None
|
|
35
|
+
"""Rows with non-empty text, or ``None`` when there's no text field."""
|
|
36
|
+
duplicate_texts: int = 0
|
|
37
|
+
duplicate_vectors: int = 0
|
|
38
|
+
models: Counter[str] = field(default_factory=Counter)
|
|
39
|
+
"""Recorded model per row, for rows that record one."""
|
|
40
|
+
model_source: str | None = None
|
|
41
|
+
"""Where most rows record their model, for example ``metadata->>'model'``."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def vectors(self) -> int:
|
|
45
|
+
"""Rows that have a vector."""
|
|
46
|
+
return self.rows - self.null_vectors
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class IndexProfile:
|
|
51
|
+
"""Everything the checks need to know about one vector field."""
|
|
52
|
+
|
|
53
|
+
store: str
|
|
54
|
+
target: str
|
|
55
|
+
"""Human-readable location, for example ``public.documents.embedding``."""
|
|
56
|
+
vector_type: str
|
|
57
|
+
declared_dimensions: int | None
|
|
58
|
+
estimated_rows: int | None
|
|
59
|
+
sample: Sample
|
|
60
|
+
text_field: str | None = None
|
|
61
|
+
model_field: str | None = None
|
|
62
|
+
updated_at_field: str | None = None
|
|
63
|
+
has_primary_key: bool = True
|
|
64
|
+
logical_replication: bool = False
|
|
65
|
+
ann_indexes: list[AnnIndex] = field(default_factory=list)
|
|
66
|
+
max_indexable_dimensions: int | None = None
|
|
67
|
+
"""The most dimensions the store can put in an ANN index, if it has a limit."""
|
|
68
|
+
rows_hidden_by_access_rules: bool = False
|
|
69
|
+
"""Row-level security (or similar) may hide rows from the connecting role."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Embedding providers: turn text into vectors."""
|
|
2
|
+
|
|
3
|
+
from vecshift.embeddings.cache import CachedEmbedder, EmbeddingCache
|
|
4
|
+
from vecshift.embeddings.providers import (
|
|
5
|
+
EmbeddingError,
|
|
6
|
+
HashingEmbedder,
|
|
7
|
+
OpenAICompatEmbedder,
|
|
8
|
+
create_embedder,
|
|
9
|
+
)
|
|
10
|
+
from vecshift.embeddings.spec import ModelSpec, SpecError, parse_spec
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CachedEmbedder",
|
|
14
|
+
"EmbeddingCache",
|
|
15
|
+
"EmbeddingError",
|
|
16
|
+
"HashingEmbedder",
|
|
17
|
+
"ModelSpec",
|
|
18
|
+
"OpenAICompatEmbedder",
|
|
19
|
+
"SpecError",
|
|
20
|
+
"create_embedder",
|
|
21
|
+
"parse_spec",
|
|
22
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""An on-disk embedding cache, so re-running a benchmark doesn't pay twice.
|
|
2
|
+
|
|
3
|
+
Keys are hashes of the model configuration, the mode, and the text; the text itself is
|
|
4
|
+
never stored. The cache lives in the user's cache directory, not the project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
from array import array
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from vecshift.embeddings.providers import Embedder, EmbedMode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_cache_path() -> Path:
|
|
20
|
+
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
21
|
+
return Path(base) / "vecshift" / "embeddings.sqlite"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmbeddingCache:
|
|
25
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
26
|
+
self.path = path or default_cache_path()
|
|
27
|
+
# Embeddings can be partly inverted back into text, so the cache is readable by
|
|
28
|
+
# its owner only, like ~/.ssh.
|
|
29
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
30
|
+
os.chmod(self.path.parent, 0o700)
|
|
31
|
+
self._db = sqlite3.connect(self.path)
|
|
32
|
+
os.chmod(self.path, 0o600)
|
|
33
|
+
self._db.execute(
|
|
34
|
+
"CREATE TABLE IF NOT EXISTS embeddings (key BLOB PRIMARY KEY, vector BLOB NOT NULL)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def key(identity: str, mode: str, text: str) -> bytes:
|
|
39
|
+
return hashlib.sha256(f"{identity}\0{mode}\0{text}".encode()).digest()
|
|
40
|
+
|
|
41
|
+
def get_many(self, keys: Sequence[bytes]) -> dict[bytes, list[float]]:
|
|
42
|
+
found: dict[bytes, list[float]] = {}
|
|
43
|
+
for i in range(0, len(keys), 500):
|
|
44
|
+
chunk = list(keys[i : i + 500])
|
|
45
|
+
placeholders = ",".join("?" * len(chunk)) # only "?" marks; values are bound
|
|
46
|
+
rows = self._db.execute(
|
|
47
|
+
f"SELECT key, vector FROM embeddings WHERE key IN ({placeholders})", # noqa: S608
|
|
48
|
+
chunk,
|
|
49
|
+
).fetchall()
|
|
50
|
+
for key, blob in rows:
|
|
51
|
+
found[key] = array("f", blob).tolist()
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
def put_many(self, items: Sequence[tuple[bytes, Sequence[float]]]) -> None:
|
|
55
|
+
self._db.executemany(
|
|
56
|
+
"INSERT OR REPLACE INTO embeddings (key, vector) VALUES (?, ?)",
|
|
57
|
+
[(k, array("f", v).tobytes()) for k, v in items],
|
|
58
|
+
)
|
|
59
|
+
self._db.commit()
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
self._db.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CachedEmbedder:
|
|
66
|
+
"""Wraps an embedder; only texts missing from the cache reach the provider."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, inner: Embedder, cache: EmbeddingCache) -> None:
|
|
69
|
+
self.inner = inner
|
|
70
|
+
self.cache = cache
|
|
71
|
+
self.hits = 0
|
|
72
|
+
self.misses = 0
|
|
73
|
+
|
|
74
|
+
async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
|
|
75
|
+
identity = self.inner.spec.identity
|
|
76
|
+
keys = [EmbeddingCache.key(identity, mode, t) for t in texts]
|
|
77
|
+
found = self.cache.get_many(keys)
|
|
78
|
+
missing = [i for i, k in enumerate(keys) if k not in found]
|
|
79
|
+
self.hits += len(texts) - len(missing)
|
|
80
|
+
self.misses += len(missing)
|
|
81
|
+
if missing:
|
|
82
|
+
vectors = await self.inner.embed([texts[i] for i in missing], mode)
|
|
83
|
+
self.cache.put_many([(keys[i], v) for i, v in zip(missing, vectors, strict=True)])
|
|
84
|
+
for i, v in zip(missing, vectors, strict=True):
|
|
85
|
+
found[keys[i]] = v
|
|
86
|
+
return [found[k] for k in keys]
|