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,490 @@
|
|
|
1
|
+
"""The diagnostic checks. Each takes an :class:`IndexProfile` and returns findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import statistics
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from vecshift.doctor.findings import Finding, Report, Severity
|
|
10
|
+
from vecshift.doctor.profile import IndexProfile
|
|
11
|
+
|
|
12
|
+
NORM_TOLERANCE = 0.01
|
|
13
|
+
"""How far from 1.0 a vector's L2 norm may be and still count as normalized."""
|
|
14
|
+
|
|
15
|
+
ZERO_NORM = 1e-9
|
|
16
|
+
DUPLICATE_WARNING_RATIO = 0.01
|
|
17
|
+
UNINDEXED_ROWS_WARNING = 100_000
|
|
18
|
+
HISTOGRAM_BINS = 20
|
|
19
|
+
|
|
20
|
+
Check = Callable[[IndexProfile], list[Finding]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pct(part: int, whole: int) -> str:
|
|
24
|
+
if whole == 0:
|
|
25
|
+
return "0%"
|
|
26
|
+
value = 100 * part / whole
|
|
27
|
+
return f"{value:.0f}%" if value >= 1 or value == 0 else "<1%"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _verb(count: int, singular: str, plural: str) -> str:
|
|
31
|
+
return singular if count == 1 else plural
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _breakdown(counts: dict[int, int] | dict[str, int]) -> str:
|
|
35
|
+
items = sorted(counts.items(), key=lambda kv: -kv[1])
|
|
36
|
+
return ", ".join(f"{k} ({v:,})" for k, v in items)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_access(p: IndexProfile) -> list[Finding]:
|
|
40
|
+
if not p.rows_hidden_by_access_rules:
|
|
41
|
+
return []
|
|
42
|
+
return [
|
|
43
|
+
Finding(
|
|
44
|
+
"access.row_security",
|
|
45
|
+
Severity.WARNING,
|
|
46
|
+
"Row-level security may hide rows",
|
|
47
|
+
"Row-level security is on for this table and the connecting role doesn't bypass "
|
|
48
|
+
"it, so this report only covers the rows that role can see.",
|
|
49
|
+
hint="Connect as the table owner, or a role with BYPASSRLS (on Supabase, the "
|
|
50
|
+
"postgres user), for a complete picture.",
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_text(p: IndexProfile) -> list[Finding]:
|
|
56
|
+
if p.text_field is None:
|
|
57
|
+
return [
|
|
58
|
+
Finding(
|
|
59
|
+
"text.missing",
|
|
60
|
+
Severity.ERROR,
|
|
61
|
+
"No source text: this index can't be re-embedded from itself",
|
|
62
|
+
"No text column was found next to the vectors. Re-embedding with a new model "
|
|
63
|
+
"needs the original text.",
|
|
64
|
+
hint="If the text is in a column with an unusual name, pass --text-column. If "
|
|
65
|
+
"it lives elsewhere (another table, object storage), keep that source "
|
|
66
|
+
"available for the migration.",
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
rows = p.sample.rows
|
|
70
|
+
present = p.sample.texts_present or 0
|
|
71
|
+
if present < rows:
|
|
72
|
+
missing = rows - present
|
|
73
|
+
return [
|
|
74
|
+
Finding(
|
|
75
|
+
"text.partial",
|
|
76
|
+
Severity.WARNING,
|
|
77
|
+
"Some rows have no text",
|
|
78
|
+
f"{missing:,} of {rows:,} sampled rows ({_pct(missing, rows)}) "
|
|
79
|
+
f"{_verb(missing, 'has', 'have')} empty or missing `{p.text_field}`, so "
|
|
80
|
+
f"{_verb(missing, 'it', 'they')} can't be re-embedded.",
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
return [
|
|
84
|
+
Finding(
|
|
85
|
+
"text.ok",
|
|
86
|
+
Severity.OK,
|
|
87
|
+
"Source text available",
|
|
88
|
+
f"Every sampled row has text in `{p.text_field}`, so the index can be re-embedded.",
|
|
89
|
+
)
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_null_vectors(p: IndexProfile) -> list[Finding]:
|
|
94
|
+
s = p.sample
|
|
95
|
+
if s.null_vectors == 0:
|
|
96
|
+
return []
|
|
97
|
+
return [
|
|
98
|
+
Finding(
|
|
99
|
+
"vectors.null",
|
|
100
|
+
Severity.WARNING,
|
|
101
|
+
"Some rows have no vector",
|
|
102
|
+
f"{s.null_vectors:,} of {s.rows:,} sampled rows ({_pct(s.null_vectors, s.rows)}) "
|
|
103
|
+
f"{_verb(s.null_vectors, 'has', 'have')} no embedding, so search can't find "
|
|
104
|
+
f"{_verb(s.null_vectors, 'it', 'them')}.",
|
|
105
|
+
hint="These are often rows whose embedding failed or hasn't run yet.",
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_dimensions(p: IndexProfile) -> list[Finding]:
|
|
111
|
+
findings: list[Finding] = []
|
|
112
|
+
dims = p.sample.dimensions
|
|
113
|
+
if len(dims) > 1:
|
|
114
|
+
findings.append(
|
|
115
|
+
Finding(
|
|
116
|
+
"dims.mixed",
|
|
117
|
+
Severity.ERROR,
|
|
118
|
+
"Vectors of different sizes in one index",
|
|
119
|
+
f"Sampled vectors have {len(dims)} different sizes: {_breakdown(dict(dims))}. "
|
|
120
|
+
"They come from different models and can't be compared with each other.",
|
|
121
|
+
hint="Find which rows came from which model, and re-embed the minority "
|
|
122
|
+
"with the majority model.",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
if p.declared_dimensions is None:
|
|
126
|
+
findings.append(
|
|
127
|
+
Finding(
|
|
128
|
+
"dims.undeclared",
|
|
129
|
+
Severity.WARNING,
|
|
130
|
+
"Vector size isn't fixed by the schema",
|
|
131
|
+
f"The field is declared as `{p.vector_type}` without a size, so nothing stops "
|
|
132
|
+
"vectors of different sizes being written to it, and it can't be given an ANN "
|
|
133
|
+
"index for fast search.",
|
|
134
|
+
hint=f"Declare the size, for example `{p.vector_type}(1536)`, once every "
|
|
135
|
+
"vector has the same size.",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
elif len(dims) <= 1:
|
|
139
|
+
findings.append(
|
|
140
|
+
Finding(
|
|
141
|
+
"dims.ok",
|
|
142
|
+
Severity.OK,
|
|
143
|
+
"Consistent vector size",
|
|
144
|
+
f"The schema fixes vectors at {p.declared_dimensions:,} dimensions.",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
size = p.declared_dimensions or (max(dims) if dims else None)
|
|
149
|
+
limit = p.max_indexable_dimensions
|
|
150
|
+
if size and limit and size > limit and not p.ann_indexes:
|
|
151
|
+
findings.append(
|
|
152
|
+
Finding(
|
|
153
|
+
"dims.too_large_to_index",
|
|
154
|
+
Severity.WARNING,
|
|
155
|
+
"Too many dimensions for an ANN index",
|
|
156
|
+
f"{p.vector_type} vectors with {size:,} dimensions are over the "
|
|
157
|
+
f"{limit:,}-dimension limit for an index, so every search is an exact scan.",
|
|
158
|
+
hint="Index a halfvec cast instead (up to 4,000 dimensions), or truncate a "
|
|
159
|
+
"Matryoshka-trained model to fewer dimensions.",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return findings
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_norms(p: IndexProfile) -> list[Finding]:
|
|
166
|
+
norms = p.sample.norms
|
|
167
|
+
if not norms:
|
|
168
|
+
return []
|
|
169
|
+
findings: list[Finding] = []
|
|
170
|
+
zero = sum(1 for n in norms if n < ZERO_NORM)
|
|
171
|
+
if zero:
|
|
172
|
+
findings.append(
|
|
173
|
+
Finding(
|
|
174
|
+
"norms.zero",
|
|
175
|
+
Severity.ERROR,
|
|
176
|
+
"Zero vectors",
|
|
177
|
+
f"{zero:,} sampled {_verb(zero, 'vector is', 'vectors are')} all zeros. Cosine "
|
|
178
|
+
"distance is undefined for zero vectors, so they never rank sensibly.",
|
|
179
|
+
hint="Zero vectors usually mean a failed embedding call stored a placeholder. "
|
|
180
|
+
"Re-embed those rows.",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
nonzero = [n for n in norms if n >= ZERO_NORM]
|
|
185
|
+
if not nonzero:
|
|
186
|
+
return findings
|
|
187
|
+
unit = sum(1 for n in nonzero if abs(n - 1.0) <= NORM_TOLERANCE)
|
|
188
|
+
metrics = {i.metric for i in p.ann_indexes}
|
|
189
|
+
|
|
190
|
+
if 0 < unit < len(nonzero):
|
|
191
|
+
findings.append(
|
|
192
|
+
Finding(
|
|
193
|
+
"norms.mixed",
|
|
194
|
+
Severity.WARNING,
|
|
195
|
+
"Mix of normalized and unnormalized vectors",
|
|
196
|
+
f"{unit:,} of {len(nonzero):,} sampled vectors are unit length and the rest "
|
|
197
|
+
"aren't. That usually means two different pipelines or models wrote to "
|
|
198
|
+
"this index.",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
elif unit == len(nonzero):
|
|
202
|
+
findings.append(
|
|
203
|
+
Finding(
|
|
204
|
+
"norms.ok",
|
|
205
|
+
Severity.OK,
|
|
206
|
+
"Vectors are normalized",
|
|
207
|
+
"Every sampled vector has unit length, so cosine, inner product, and L2 "
|
|
208
|
+
"distance all rank results the same way.",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
lo, hi = min(nonzero), max(nonzero)
|
|
213
|
+
findings.append(
|
|
214
|
+
Finding(
|
|
215
|
+
"norms.unnormalized",
|
|
216
|
+
Severity.INFO,
|
|
217
|
+
"Vectors aren't normalized",
|
|
218
|
+
f"Sampled vector lengths range from {lo:.3g} to {hi:.3g}. That's fine with "
|
|
219
|
+
"cosine distance; inner product and L2 will favour longer vectors.",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if unit < len(nonzero) and "inner_product" in metrics:
|
|
224
|
+
findings.append(
|
|
225
|
+
Finding(
|
|
226
|
+
"norms.inner_product_unnormalized",
|
|
227
|
+
Severity.WARNING,
|
|
228
|
+
"Inner-product index over unnormalized vectors",
|
|
229
|
+
"An inner-product index ranks longer vectors higher regardless of meaning "
|
|
230
|
+
"when vectors aren't normalized.",
|
|
231
|
+
hint="Normalize vectors before writing them, or switch the index to cosine.",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return findings
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_duplicates(p: IndexProfile) -> list[Finding]:
|
|
238
|
+
s = p.sample
|
|
239
|
+
findings: list[Finding] = []
|
|
240
|
+
for kind, count, base, noun in (
|
|
241
|
+
("text", s.duplicate_texts, s.texts_present or 0, "texts"),
|
|
242
|
+
("vectors", s.duplicate_vectors, s.vectors, "vectors"),
|
|
243
|
+
):
|
|
244
|
+
if base and count / base > DUPLICATE_WARNING_RATIO:
|
|
245
|
+
findings.append(
|
|
246
|
+
Finding(
|
|
247
|
+
f"duplicates.{kind}",
|
|
248
|
+
Severity.WARNING,
|
|
249
|
+
f"Duplicate {noun}",
|
|
250
|
+
f"{count:,} of {base:,} sampled rows ({_pct(count, base)}) "
|
|
251
|
+
f"{_verb(count, 'repeats', 'repeat')} a "
|
|
252
|
+
f"{noun[:-1]} that's already in the sample. Duplicates crowd out other "
|
|
253
|
+
"results and cost money to re-embed.",
|
|
254
|
+
hint="Deduplicate before migrating: you only pay to embed each text once.",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return findings
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_models(p: IndexProfile) -> list[Finding]:
|
|
261
|
+
models = p.sample.models
|
|
262
|
+
if not models:
|
|
263
|
+
return [
|
|
264
|
+
Finding(
|
|
265
|
+
"models.untracked",
|
|
266
|
+
Severity.INFO,
|
|
267
|
+
"No record of which model made these vectors",
|
|
268
|
+
"Nothing next to the vectors says which embedding model produced them, so "
|
|
269
|
+
"a mixed or outdated index can't be detected directly.",
|
|
270
|
+
hint="Store a model tag with each row, for example the output of "
|
|
271
|
+
"`vecshift fingerprint`, in a column or in metadata under `model_tag`.",
|
|
272
|
+
)
|
|
273
|
+
]
|
|
274
|
+
findings: list[Finding] = []
|
|
275
|
+
if len(models) > 1:
|
|
276
|
+
findings.append(
|
|
277
|
+
Finding(
|
|
278
|
+
"models.mixed",
|
|
279
|
+
Severity.ERROR,
|
|
280
|
+
"Vectors from more than one model",
|
|
281
|
+
f"`{p.model_field}` records {len(models)} different models: "
|
|
282
|
+
f"{_breakdown(dict(models))}. Vectors from different models aren't "
|
|
283
|
+
"comparable, so searches across them return poor results.",
|
|
284
|
+
hint="Re-embed the rows from the older model(s) with the current one.",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
(model,) = models
|
|
289
|
+
findings.append(
|
|
290
|
+
Finding(
|
|
291
|
+
"models.ok",
|
|
292
|
+
Severity.OK,
|
|
293
|
+
"One embedding model",
|
|
294
|
+
f"Every sampled row that records a model in `{p.model_field}` says `{model}`.",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
recorded = sum(models.values())
|
|
298
|
+
if recorded < p.sample.vectors:
|
|
299
|
+
missing = p.sample.vectors - recorded
|
|
300
|
+
findings.append(
|
|
301
|
+
Finding(
|
|
302
|
+
"models.partial",
|
|
303
|
+
Severity.WARNING,
|
|
304
|
+
"Some rows don't record their model",
|
|
305
|
+
f"{missing:,} of {p.sample.vectors:,} sampled vectors "
|
|
306
|
+
f"{_verb(missing, 'has', 'have')} nothing in `{p.model_field}`, so "
|
|
307
|
+
f"{_verb(missing, 'its', 'their')} model is unknown.",
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
return findings
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_indexes(p: IndexProfile) -> list[Finding]:
|
|
314
|
+
if p.ann_indexes:
|
|
315
|
+
described = ", ".join(
|
|
316
|
+
f"{i.name} ({i.method}{', ' + i.metric if i.metric else ''})" for i in p.ann_indexes
|
|
317
|
+
)
|
|
318
|
+
return [Finding("index.ok", Severity.OK, "ANN index present", described)]
|
|
319
|
+
rows = p.estimated_rows or 0
|
|
320
|
+
large = rows > UNINDEXED_ROWS_WARNING
|
|
321
|
+
return [
|
|
322
|
+
Finding(
|
|
323
|
+
"index.missing",
|
|
324
|
+
Severity.WARNING if large else Severity.INFO,
|
|
325
|
+
"No ANN index",
|
|
326
|
+
f"Searches scan every row exactly{f' (about {rows:,} rows)' if rows else ''}. "
|
|
327
|
+
+ (
|
|
328
|
+
"That gets slow at this size."
|
|
329
|
+
if large
|
|
330
|
+
else "That's fine for small tables but slows down as the table grows."
|
|
331
|
+
),
|
|
332
|
+
hint="Create an HNSW index with the operator class for your distance, for "
|
|
333
|
+
"example `vector_cosine_ops`.",
|
|
334
|
+
)
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def check_sync(p: IndexProfile) -> list[Finding]:
|
|
339
|
+
findings: list[Finding] = []
|
|
340
|
+
if not p.has_primary_key:
|
|
341
|
+
findings.append(
|
|
342
|
+
Finding(
|
|
343
|
+
"sync.no_primary_key",
|
|
344
|
+
Severity.ERROR,
|
|
345
|
+
"No primary key",
|
|
346
|
+
"Without a stable ID per row, a migration can't resume safely, avoid "
|
|
347
|
+
"duplicates, or match updates and deletes to the right row.",
|
|
348
|
+
hint="Add a primary key before migrating.",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
if p.logical_replication and p.has_primary_key:
|
|
352
|
+
findings.append(
|
|
353
|
+
Finding(
|
|
354
|
+
"sync.logical_replication",
|
|
355
|
+
Severity.OK,
|
|
356
|
+
"Live writes can be streamed",
|
|
357
|
+
"Logical replication is enabled, so inserts, updates, and deletes made during "
|
|
358
|
+
"a migration can be streamed to the new index.",
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
elif p.updated_at_field:
|
|
362
|
+
findings.append(
|
|
363
|
+
Finding(
|
|
364
|
+
"sync.updated_at",
|
|
365
|
+
Severity.OK,
|
|
366
|
+
"Live writes can be tracked by timestamp",
|
|
367
|
+
f"`{p.updated_at_field}` lets a migration catch up on rows changed while it "
|
|
368
|
+
"ran. Deletes don't show up this way, so they need a separate reconciliation "
|
|
369
|
+
"pass by ID.",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
findings.append(
|
|
374
|
+
Finding(
|
|
375
|
+
"sync.none",
|
|
376
|
+
Severity.WARNING,
|
|
377
|
+
"No way to track writes during a migration",
|
|
378
|
+
"There's no logical replication and no updated-at timestamp column, so "
|
|
379
|
+
"changes made while a migration runs would be missed.",
|
|
380
|
+
hint="Add an `updated_at` column maintained by a trigger, or plan to pause "
|
|
381
|
+
"writes or dual-write during the migration.",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return findings
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
CHECKS: tuple[Check, ...] = (
|
|
388
|
+
check_access,
|
|
389
|
+
check_text,
|
|
390
|
+
check_null_vectors,
|
|
391
|
+
check_dimensions,
|
|
392
|
+
check_norms,
|
|
393
|
+
check_duplicates,
|
|
394
|
+
check_models,
|
|
395
|
+
check_indexes,
|
|
396
|
+
check_sync,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
SAMPLE_CHECKS = {
|
|
400
|
+
check_text,
|
|
401
|
+
check_null_vectors,
|
|
402
|
+
check_dimensions,
|
|
403
|
+
check_norms,
|
|
404
|
+
check_duplicates,
|
|
405
|
+
check_models,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _histogram(values: list[float], bins: int = HISTOGRAM_BINS) -> list[dict[str, float]]:
|
|
410
|
+
lo, hi = min(values), max(values)
|
|
411
|
+
if hi - lo < 1e-9:
|
|
412
|
+
return [{"start": lo, "end": hi, "count": len(values)}]
|
|
413
|
+
width = (hi - lo) / bins
|
|
414
|
+
counts = [0] * bins
|
|
415
|
+
for v in values:
|
|
416
|
+
counts[min(int((v - lo) / width), bins - 1)] += 1
|
|
417
|
+
return [
|
|
418
|
+
{"start": lo + i * width, "end": lo + (i + 1) * width, "count": c}
|
|
419
|
+
for i, c in enumerate(counts)
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def facts(profile: IndexProfile) -> dict[str, Any]:
|
|
424
|
+
"""The measurements behind the findings, in a JSON-friendly shape."""
|
|
425
|
+
s = profile.sample
|
|
426
|
+
nonzero = [n for n in s.norms if n >= ZERO_NORM]
|
|
427
|
+
norms: dict[str, Any] | None = None
|
|
428
|
+
if s.norms:
|
|
429
|
+
norms = {
|
|
430
|
+
"count": len(s.norms),
|
|
431
|
+
"zero": len(s.norms) - len(nonzero),
|
|
432
|
+
"unit": sum(1 for n in nonzero if abs(n - 1.0) <= NORM_TOLERANCE),
|
|
433
|
+
"min": min(s.norms),
|
|
434
|
+
"median": statistics.median(s.norms),
|
|
435
|
+
"max": max(s.norms),
|
|
436
|
+
"tolerance": NORM_TOLERANCE,
|
|
437
|
+
"histogram": _histogram(s.norms),
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
"text_field": profile.text_field,
|
|
441
|
+
"model_field": profile.model_field,
|
|
442
|
+
"updated_at_field": profile.updated_at_field,
|
|
443
|
+
"has_primary_key": profile.has_primary_key,
|
|
444
|
+
"logical_replication": profile.logical_replication,
|
|
445
|
+
"rows_hidden_by_access_rules": profile.rows_hidden_by_access_rules,
|
|
446
|
+
"max_indexable_dimensions": profile.max_indexable_dimensions,
|
|
447
|
+
"ann_indexes": [
|
|
448
|
+
{"name": i.name, "method": i.method, "metric": i.metric} for i in profile.ann_indexes
|
|
449
|
+
],
|
|
450
|
+
"sample": {
|
|
451
|
+
"rows": s.rows,
|
|
452
|
+
"vectors": s.vectors,
|
|
453
|
+
"null_vectors": s.null_vectors,
|
|
454
|
+
"texts_present": s.texts_present,
|
|
455
|
+
"duplicate_texts": s.duplicate_texts,
|
|
456
|
+
"duplicate_vectors": s.duplicate_vectors,
|
|
457
|
+
},
|
|
458
|
+
"dimensions": {str(d): n for d, n in s.dimensions.most_common()},
|
|
459
|
+
"models": dict(s.models.most_common()),
|
|
460
|
+
"norms": norms,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def run_checks(profile: IndexProfile) -> Report:
|
|
465
|
+
"""Run every check against ``profile`` and collect the findings."""
|
|
466
|
+
report = Report(
|
|
467
|
+
store=profile.store,
|
|
468
|
+
target=profile.target,
|
|
469
|
+
vector_type=profile.vector_type,
|
|
470
|
+
declared_dimensions=profile.declared_dimensions,
|
|
471
|
+
estimated_rows=profile.estimated_rows,
|
|
472
|
+
sample_rows=profile.sample.rows,
|
|
473
|
+
sample_method=profile.sample.method,
|
|
474
|
+
facts=facts(profile),
|
|
475
|
+
)
|
|
476
|
+
empty = profile.sample.rows == 0
|
|
477
|
+
if empty:
|
|
478
|
+
report.findings.append(
|
|
479
|
+
Finding(
|
|
480
|
+
"sample.empty",
|
|
481
|
+
Severity.INFO,
|
|
482
|
+
"No rows to inspect",
|
|
483
|
+
"The table is empty (or no rows are visible), so only schema checks ran.",
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
for check in CHECKS:
|
|
487
|
+
if empty and check in SAMPLE_CHECKS and check is not check_dimensions:
|
|
488
|
+
continue
|
|
489
|
+
report.findings.extend(check(profile))
|
|
490
|
+
return report
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Findings and the report that collects them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(StrEnum):
|
|
11
|
+
OK = "ok"
|
|
12
|
+
INFO = "info"
|
|
13
|
+
WARNING = "warning"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def rank(self) -> int:
|
|
18
|
+
return _RANK[self]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_RANK = {Severity.OK: 0, Severity.INFO: 1, Severity.WARNING: 2, Severity.ERROR: 3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Finding:
|
|
26
|
+
id: str
|
|
27
|
+
"""Stable identifier such as ``dims.mixed``, for scripts and CI."""
|
|
28
|
+
severity: Severity
|
|
29
|
+
title: str
|
|
30
|
+
detail: str
|
|
31
|
+
hint: str | None = None
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"id": self.id,
|
|
36
|
+
"severity": str(self.severity),
|
|
37
|
+
"title": self.title,
|
|
38
|
+
"detail": self.detail,
|
|
39
|
+
"hint": self.hint,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class Report:
|
|
45
|
+
store: str
|
|
46
|
+
target: str
|
|
47
|
+
vector_type: str
|
|
48
|
+
declared_dimensions: int | None
|
|
49
|
+
estimated_rows: int | None
|
|
50
|
+
sample_rows: int
|
|
51
|
+
sample_method: str
|
|
52
|
+
findings: list[Finding] = field(default_factory=list)
|
|
53
|
+
facts: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
"""What was measured, for reports and dashboards. See ``checks.facts``."""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def worst(self) -> Severity:
|
|
58
|
+
return max((f.severity for f in self.findings), key=lambda s: s.rank, default=Severity.OK)
|
|
59
|
+
|
|
60
|
+
def count(self, severity: Severity) -> int:
|
|
61
|
+
return sum(1 for f in self.findings if f.severity is severity)
|
|
62
|
+
|
|
63
|
+
def sorted_findings(self) -> list[Finding]:
|
|
64
|
+
"""Most severe first; ties keep check order."""
|
|
65
|
+
return sorted(self.findings, key=lambda f: -f.severity.rank)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"store": self.store,
|
|
70
|
+
"target": self.target,
|
|
71
|
+
"vector_type": self.vector_type,
|
|
72
|
+
"declared_dimensions": self.declared_dimensions,
|
|
73
|
+
"estimated_rows": self.estimated_rows,
|
|
74
|
+
"sample": {"rows": self.sample_rows, "method": self.sample_method},
|
|
75
|
+
"summary": {str(s): self.count(s) for s in Severity},
|
|
76
|
+
"findings": [f.to_dict() for f in self.sorted_findings()],
|
|
77
|
+
"facts": self.facts,
|
|
78
|
+
}
|