vecscope 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.
- vecscope/__init__.py +6 -0
- vecscope/_knn.py +46 -0
- vecscope/cli.py +87 -0
- vecscope/diagnostics/__init__.py +20 -0
- vecscope/diagnostics/anisotropy.py +48 -0
- vecscope/diagnostics/ann_recall.py +25 -0
- vecscope/diagnostics/clusters.py +64 -0
- vecscope/diagnostics/collapse.py +32 -0
- vecscope/diagnostics/geometry.py +120 -0
- vecscope/diagnostics/hubness.py +33 -0
- vecscope/diagnostics/queries.py +65 -0
- vecscope/evidence.py +337 -0
- vecscope/io.py +58 -0
- vecscope/mcp_server.py +64 -0
- vecscope/sweep.py +207 -0
- vecscope-0.1.0.dist-info/METADATA +392 -0
- vecscope-0.1.0.dist-info/RECORD +20 -0
- vecscope-0.1.0.dist-info/WHEEL +4 -0
- vecscope-0.1.0.dist-info/entry_points.txt +3 -0
- vecscope-0.1.0.dist-info/licenses/LICENSE +21 -0
vecscope/evidence.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Evidence model: deterministic diagnostics with verdicts an agent can act on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from vecscope._knn import exact_knn
|
|
11
|
+
from vecscope.diagnostics import (
|
|
12
|
+
anisotropy,
|
|
13
|
+
ann_recall,
|
|
14
|
+
cluster_report,
|
|
15
|
+
collapse,
|
|
16
|
+
connectivity,
|
|
17
|
+
distance_concentration,
|
|
18
|
+
hubness,
|
|
19
|
+
metric_mismatch,
|
|
20
|
+
norms,
|
|
21
|
+
outliers,
|
|
22
|
+
query_audit,
|
|
23
|
+
rogue_dimensions,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Verdict thresholds. Conservative, documented, overridable.
|
|
27
|
+
THRESHOLDS = {
|
|
28
|
+
"hubness.skewness": (2.0, 4.0), # real 1k-1.5k-dim text models sit at 1.1-1.6
|
|
29
|
+
"hubness.antihub_ratio": (0.05, 0.20),
|
|
30
|
+
"anisotropy.mean_random_cosine": (0.3, 0.6), # ada-002 ~0.69, 3-large ~0.09
|
|
31
|
+
"anisotropy.effective_rank_ratio": (0.10, 0.05), # lower is worse; real models 0.09-0.18
|
|
32
|
+
"collapse.near_duplicate_ratio": (0.02, 0.10),
|
|
33
|
+
"ann_recall.recall_at_k": (0.95, 0.85), # lower is worse
|
|
34
|
+
"rogue_dimensions.top1_dim_variance_share": (0.05, 0.20),
|
|
35
|
+
"norms.norm_cv": (0.10, 0.30),
|
|
36
|
+
"metric_mismatch.topk_overlap": (0.90, 0.70), # lower is worse
|
|
37
|
+
"distance_concentration.relative_contrast": (1.2, 1.05), # lower is worse; real 1.9-3.0, 256-d noise 1.28
|
|
38
|
+
"outliers.outlier_ratio": (0.01, 0.05),
|
|
39
|
+
"connectivity.island_ratio": (0.01, 0.10),
|
|
40
|
+
"queries.ood_query_ratio": (0.05, 0.20),
|
|
41
|
+
"queries.query_hubness_skewness": (2.0, 4.0),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Finding:
|
|
47
|
+
name: str
|
|
48
|
+
metric: str
|
|
49
|
+
value: float
|
|
50
|
+
verdict: str # ok | warn | fail
|
|
51
|
+
explanation: str
|
|
52
|
+
details: dict = field(default_factory=dict)
|
|
53
|
+
warn_at: float | None = None
|
|
54
|
+
fail_at: float | None = None
|
|
55
|
+
lower_is_worse: bool = False
|
|
56
|
+
healthy_range: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Evidence:
|
|
61
|
+
tool: str
|
|
62
|
+
version: str
|
|
63
|
+
n: int
|
|
64
|
+
dim: int
|
|
65
|
+
k: int
|
|
66
|
+
findings: list[Finding]
|
|
67
|
+
n_total: int = 0
|
|
68
|
+
sampled: bool = False
|
|
69
|
+
n_queries: int = 0
|
|
70
|
+
clusters: list[dict] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def verdict(self) -> str:
|
|
74
|
+
order = {"ok": 0, "warn": 1, "fail": 2}
|
|
75
|
+
return max((f.verdict for f in self.findings), key=order.get, default="ok")
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
d = asdict(self)
|
|
79
|
+
d["verdict"] = self.verdict
|
|
80
|
+
return d
|
|
81
|
+
|
|
82
|
+
def to_json(self, indent: int | None = 2) -> str:
|
|
83
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _verdict(key: str, value: float) -> str:
|
|
87
|
+
warn, fail = THRESHOLDS[key]
|
|
88
|
+
if warn > fail: # lower is worse
|
|
89
|
+
return "fail" if value < fail else "warn" if value < warn else "ok"
|
|
90
|
+
return "fail" if value >= fail else "warn" if value >= warn else "ok"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_FINDING_KEYS = {
|
|
94
|
+
"hubness": "hubness.skewness",
|
|
95
|
+
"antihubs": "hubness.antihub_ratio",
|
|
96
|
+
"anisotropy": "anisotropy.mean_random_cosine",
|
|
97
|
+
"effective_rank": "anisotropy.effective_rank_ratio",
|
|
98
|
+
"collapse": "collapse.near_duplicate_ratio",
|
|
99
|
+
"ann_recall": "ann_recall.recall_at_k",
|
|
100
|
+
"rogue_dimensions": "rogue_dimensions.top1_dim_variance_share",
|
|
101
|
+
"norms": "norms.norm_cv",
|
|
102
|
+
"metric_mismatch": "metric_mismatch.topk_overlap",
|
|
103
|
+
"distance_concentration": "distance_concentration.relative_contrast",
|
|
104
|
+
"outliers": "outliers.outlier_ratio",
|
|
105
|
+
"connectivity": "connectivity.island_ratio",
|
|
106
|
+
"ood_queries": "queries.ood_query_ratio",
|
|
107
|
+
"query_hubness": "queries.query_hubness_skewness",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _attach_thresholds(findings: list[Finding]) -> list[Finding]:
|
|
112
|
+
for f in findings:
|
|
113
|
+
warn, fail = THRESHOLDS[_FINDING_KEYS[f.name]]
|
|
114
|
+
f.warn_at, f.fail_at, f.lower_is_worse = warn, fail, warn > fail
|
|
115
|
+
if f.lower_is_worse:
|
|
116
|
+
f.healthy_range = f"ok >= {warn:g}, warn < {warn:g}, fail < {fail:g}"
|
|
117
|
+
else:
|
|
118
|
+
f.healthy_range = f"ok < {warn:g}, warn >= {warn:g}, fail >= {fail:g}"
|
|
119
|
+
return findings
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def audit(
|
|
123
|
+
x: np.ndarray,
|
|
124
|
+
k: int = 10,
|
|
125
|
+
ann_neighbors: np.ndarray | None = None,
|
|
126
|
+
near_dup_cos: float = 0.99,
|
|
127
|
+
sample: int | None = 100_000,
|
|
128
|
+
seed: int = 0,
|
|
129
|
+
queries: np.ndarray | None = None,
|
|
130
|
+
clusters: int = 0,
|
|
131
|
+
) -> Evidence:
|
|
132
|
+
"""Audit an embedding matrix and return deterministic Evidence.
|
|
133
|
+
|
|
134
|
+
Exact k-NN is O(n^2); when ``sample`` is set and ``n > sample``, a seeded random
|
|
135
|
+
subset of rows is audited instead (``ann_neighbors`` is then ignored because its
|
|
136
|
+
row ids no longer line up). Pass ``sample=None`` to audit every row.
|
|
137
|
+
|
|
138
|
+
``queries`` adds query-side findings (out-of-distribution queries, query hubness).
|
|
139
|
+
``clusters`` > 0 attaches a per-cluster report with sample ids (no verdicts).
|
|
140
|
+
"""
|
|
141
|
+
from vecscope import __version__
|
|
142
|
+
|
|
143
|
+
x = np.asarray(x, dtype=np.float32)
|
|
144
|
+
if x.ndim != 2 or x.shape[0] < 3:
|
|
145
|
+
raise ValueError("expected a 2-D array with at least 3 rows")
|
|
146
|
+
if not np.isfinite(x).all():
|
|
147
|
+
raise ValueError("embeddings contain NaN or Inf")
|
|
148
|
+
n_total = int(x.shape[0])
|
|
149
|
+
sampled = sample is not None and n_total > sample
|
|
150
|
+
if sampled:
|
|
151
|
+
rows = np.sort(np.random.default_rng(seed).choice(n_total, sample, replace=False))
|
|
152
|
+
x = x[rows]
|
|
153
|
+
ann_neighbors = None
|
|
154
|
+
if k >= x.shape[0]:
|
|
155
|
+
raise ValueError(f"k={k} must be smaller than the number of rows ({x.shape[0]})")
|
|
156
|
+
knn_idx, knn_dist = exact_knn(x, k)
|
|
157
|
+
findings: list[Finding] = []
|
|
158
|
+
|
|
159
|
+
h = hubness(x, k, knn=knn_idx)
|
|
160
|
+
findings.append(
|
|
161
|
+
Finding(
|
|
162
|
+
"hubness", "skewness", h["skewness"], _verdict("hubness.skewness", h["skewness"]),
|
|
163
|
+
f"k-occurrence skewness {h['skewness']:.2f}; top 1% of items take "
|
|
164
|
+
f"{h['hub_share_top1pct']:.1%} of all neighbor slots (max N_k={h['max_k_occurrence']}, "
|
|
165
|
+
f"expected {k}).",
|
|
166
|
+
h,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
findings.append(
|
|
170
|
+
Finding(
|
|
171
|
+
"antihubs", "antihub_ratio", h["antihub_ratio"],
|
|
172
|
+
_verdict("hubness.antihub_ratio", h["antihub_ratio"]),
|
|
173
|
+
f"{h['antihub_ratio']:.1%} of items are never anyone's neighbor; they are "
|
|
174
|
+
"unreachable through k-NN retrieval.",
|
|
175
|
+
{},
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
a = anisotropy(x)
|
|
180
|
+
findings.append(
|
|
181
|
+
Finding(
|
|
182
|
+
"anisotropy", "mean_random_cosine", a["mean_random_cosine"],
|
|
183
|
+
_verdict("anisotropy.mean_random_cosine", a["mean_random_cosine"]),
|
|
184
|
+
f"random pairs have mean cosine {a['mean_random_cosine']:.3f} (isotropic ~0); "
|
|
185
|
+
f"mean unit vector has norm {a['mean_vector_norm']:.2f}; centering would bring the "
|
|
186
|
+
f"cosine to {a['mean_random_cosine_after_centering']:.3f}.",
|
|
187
|
+
a,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
findings.append(
|
|
191
|
+
Finding(
|
|
192
|
+
"effective_rank", "effective_rank_ratio", a["effective_rank_ratio"],
|
|
193
|
+
_verdict("anisotropy.effective_rank_ratio", a["effective_rank_ratio"]),
|
|
194
|
+
f"effective rank {a['effective_rank']:.1f} of {a['dim']} dims "
|
|
195
|
+
f"({a['effective_rank_ratio']:.1%}); low values mean most dimensions carry nothing.",
|
|
196
|
+
{},
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
c = collapse(x, near_dup_cos, knn_dist=knn_dist)
|
|
201
|
+
findings.append(
|
|
202
|
+
Finding(
|
|
203
|
+
"collapse", "near_duplicate_ratio", c["near_duplicate_ratio"],
|
|
204
|
+
_verdict("collapse.near_duplicate_ratio", c["near_duplicate_ratio"]),
|
|
205
|
+
f"{c['near_duplicate_ratio']:.1%} of items have a neighbor with cosine >= {near_dup_cos} "
|
|
206
|
+
f"(exact duplicates {c['exact_duplicate_ratio']:.1%}).",
|
|
207
|
+
c,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
g = rogue_dimensions(x)
|
|
212
|
+
findings.append(
|
|
213
|
+
Finding(
|
|
214
|
+
"rogue_dimensions", "top1_dim_variance_share", g["top1_dim_variance_share"],
|
|
215
|
+
_verdict("rogue_dimensions.top1_dim_variance_share", g["top1_dim_variance_share"]),
|
|
216
|
+
f"dimension {g['rogue_dims'][0]} alone holds {g['top1_dim_variance_share']:.1%} of "
|
|
217
|
+
f"variance (uniform would be {g['expected_share']:.2%}); top 3 dims hold "
|
|
218
|
+
f"{g['top3_dim_variance_share']:.1%}. Rogue dimensions dominate cosine.",
|
|
219
|
+
g,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
nm = norms(x)
|
|
224
|
+
findings.append(
|
|
225
|
+
Finding(
|
|
226
|
+
"norms", "norm_cv", nm["norm_cv"], _verdict("norms.norm_cv", nm["norm_cv"]),
|
|
227
|
+
f"vector norms vary with CV {nm['norm_cv']:.3f} (median {nm['norm_median']:.3f}, "
|
|
228
|
+
f"range {nm['norm_min']:.3f}-{nm['norm_max']:.3f}); {nm['zero_norm_ratio']:.2%} are "
|
|
229
|
+
"zero vectors. Unequal norms bias inner-product search toward long vectors.",
|
|
230
|
+
nm,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if nm["norm_cv"] < 1e-3:
|
|
235
|
+
mm = {"k": int(k), "topk_overlap": 1.0, "queries_fully_agreeing_ratio": 1.0,
|
|
236
|
+
"min_overlap": 1.0, "skipped": "norms are uniform, cosine == inner product"}
|
|
237
|
+
else:
|
|
238
|
+
dot_idx, _ = exact_knn(x, k, metric="dot")
|
|
239
|
+
mm = metric_mismatch(knn_idx, dot_idx)
|
|
240
|
+
findings.append(
|
|
241
|
+
Finding(
|
|
242
|
+
"metric_mismatch", "topk_overlap", mm["topk_overlap"],
|
|
243
|
+
_verdict("metric_mismatch.topk_overlap", mm["topk_overlap"]),
|
|
244
|
+
f"cosine and inner-product top-{k} share {mm['topk_overlap']:.1%} of neighbors; "
|
|
245
|
+
f"{mm['queries_fully_agreeing_ratio']:.1%} of items get identical lists. "
|
|
246
|
+
"Low overlap means the index metric changes the results.",
|
|
247
|
+
mm,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
dc = distance_concentration(knn_dist[:, 0], a["mean_random_cosine"])
|
|
252
|
+
findings.append(
|
|
253
|
+
Finding(
|
|
254
|
+
"distance_concentration", "relative_contrast", dc["relative_contrast"],
|
|
255
|
+
_verdict("distance_concentration.relative_contrast", dc["relative_contrast"]),
|
|
256
|
+
f"a random item is only {dc['relative_contrast']:.2f}x farther than the nearest "
|
|
257
|
+
f"neighbor (median NN distance {dc['median_nn_distance']:.3f}, random "
|
|
258
|
+
f"{dc['mean_random_distance']:.3f}). Near 1.0 means distances carry no signal.",
|
|
259
|
+
dc,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
o = outliers(knn_dist[:, 0])
|
|
264
|
+
findings.append(
|
|
265
|
+
Finding(
|
|
266
|
+
"outliers", "outlier_ratio", o["outlier_ratio"],
|
|
267
|
+
_verdict("outliers.outlier_ratio", o["outlier_ratio"]),
|
|
268
|
+
f"{o['outlier_ratio']:.2%} of items have no neighbor within cosine distance "
|
|
269
|
+
f"{o['cutoff_nn_distance']:.3f} (median {o['median_nn_distance']:.3f}); see "
|
|
270
|
+
"details.sample_ids. Typically junk, empty or foreign-language content.",
|
|
271
|
+
o,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
cn = connectivity(knn_idx)
|
|
276
|
+
findings.append(
|
|
277
|
+
Finding(
|
|
278
|
+
"connectivity", "island_ratio", cn["island_ratio"],
|
|
279
|
+
_verdict("connectivity.island_ratio", cn["island_ratio"]),
|
|
280
|
+
f"{cn['island_ratio']:.2%} of items sit in {cn['n_islands']} small island(s) of the "
|
|
281
|
+
f"k-NN graph (fewer than {cn['min_island_size']} items; {cn['n_components']} "
|
|
282
|
+
"components in total). Graph-based ANN (HNSW) attaches islands to the wrong region "
|
|
283
|
+
"or never reaches them.",
|
|
284
|
+
cn,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
n_queries = 0
|
|
289
|
+
if queries is not None:
|
|
290
|
+
qv = np.asarray(queries, dtype=np.float32)
|
|
291
|
+
if qv.ndim != 2 or qv.shape[1] != x.shape[1]:
|
|
292
|
+
raise ValueError(f"queries must be (m, {x.shape[1]}) to match the corpus")
|
|
293
|
+
if not np.isfinite(qv).all():
|
|
294
|
+
raise ValueError("queries contain NaN or Inf")
|
|
295
|
+
n_queries = int(qv.shape[0])
|
|
296
|
+
qa = query_audit(x, qv, k, knn_dist[:, 0])
|
|
297
|
+
findings.append(
|
|
298
|
+
Finding(
|
|
299
|
+
"ood_queries", "ood_query_ratio", qa["ood_query_ratio"],
|
|
300
|
+
_verdict("queries.ood_query_ratio", qa["ood_query_ratio"]),
|
|
301
|
+
f"{qa['ood_query_ratio']:.1%} of {qa['n_queries']} queries are farther from "
|
|
302
|
+
f"their nearest corpus item (median {qa['median_query_nn_distance']:.3f}) than "
|
|
303
|
+
f"99% of corpus items are from theirs (cutoff {qa['ood_cutoff_distance']:.3f}). "
|
|
304
|
+
"Out-of-distribution queries: wrong model, missing query prefix, or uncovered "
|
|
305
|
+
"topics; see details.ood_sample_ids.",
|
|
306
|
+
qa,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
findings.append(
|
|
310
|
+
Finding(
|
|
311
|
+
"query_hubness", "query_hubness_skewness", qa["query_hubness_skewness"],
|
|
312
|
+
_verdict("queries.query_hubness_skewness", qa["query_hubness_skewness"]),
|
|
313
|
+
f"k-occurrence skewness over query results {qa['query_hubness_skewness']:.2f} "
|
|
314
|
+
f"(max {qa['max_query_k_occurrence']} appearances); queries reach "
|
|
315
|
+
f"{qa['coverage']:.1%} of the corpus in their top-{qa['k']}.",
|
|
316
|
+
{},
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if ann_neighbors is not None:
|
|
321
|
+
r = ann_recall(knn_idx, np.asarray(ann_neighbors))
|
|
322
|
+
findings.append(
|
|
323
|
+
Finding(
|
|
324
|
+
"ann_recall", "recall_at_k", r["recall_at_k"],
|
|
325
|
+
_verdict("ann_recall.recall_at_k", r["recall_at_k"]),
|
|
326
|
+
f"ANN recall@{r['k']} = {r['recall_at_k']:.3f}; "
|
|
327
|
+
f"{r['queries_below_floor_ratio']:.1%} of queries fall below {r['floor']:.0%}.",
|
|
328
|
+
r,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
report = cluster_report(x, clusters, knn_idx, knn_dist, near_dup_cos, seed) if clusters else []
|
|
333
|
+
return Evidence(
|
|
334
|
+
"vecscope", __version__, int(x.shape[0]), int(x.shape[1]), int(k),
|
|
335
|
+
_attach_thresholds(findings),
|
|
336
|
+
n_total=n_total, sampled=sampled, n_queries=n_queries, clusters=report,
|
|
337
|
+
)
|
vecscope/io.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Load embeddings from common on-disk formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _first_vector_key(row: dict) -> str:
|
|
12
|
+
for key, value in row.items():
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return key
|
|
15
|
+
raise ValueError("no list-valued field found in JSON rows")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_vectors(path: str | Path) -> np.ndarray:
|
|
19
|
+
"""Return a 2-D float array with one vector per row.
|
|
20
|
+
|
|
21
|
+
Supported: .npy, .npz (first array), .parquet/.pq (column containing "emb" or "vec"),
|
|
22
|
+
.jsonl (one object per line), .json (array of objects or array of lists), .csv/.tsv/.txt.
|
|
23
|
+
"""
|
|
24
|
+
p = Path(path)
|
|
25
|
+
if not p.exists():
|
|
26
|
+
raise FileNotFoundError(f"embedding file not found: {p}")
|
|
27
|
+
suf = p.suffix.lower()
|
|
28
|
+
if suf == ".npy":
|
|
29
|
+
return np.load(p)
|
|
30
|
+
if suf == ".npz":
|
|
31
|
+
z = np.load(p)
|
|
32
|
+
return z[z.files[0]]
|
|
33
|
+
if suf in {".parquet", ".pq"}:
|
|
34
|
+
try:
|
|
35
|
+
import pyarrow.parquet as pq
|
|
36
|
+
except ImportError as e: # pragma: no cover
|
|
37
|
+
raise ImportError("reading parquet needs pyarrow: pip install pyarrow") from e
|
|
38
|
+
t = pq.read_table(p)
|
|
39
|
+
cols = [c for c in t.column_names if "emb" in c.lower() or "vec" in c.lower()]
|
|
40
|
+
if not cols:
|
|
41
|
+
raise ValueError(f"no column containing 'emb' or 'vec' in {t.column_names}")
|
|
42
|
+
return np.stack(t.column(cols[0]).to_pylist()).astype(np.float32)
|
|
43
|
+
if suf == ".jsonl":
|
|
44
|
+
text = p.read_text(encoding="utf-8")
|
|
45
|
+
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
46
|
+
key = _first_vector_key(rows[0])
|
|
47
|
+
return np.asarray([r[key] for r in rows], dtype=np.float32)
|
|
48
|
+
if suf == ".json":
|
|
49
|
+
rows = json.loads(p.read_text(encoding="utf-8"))
|
|
50
|
+
if not isinstance(rows, list) or not rows:
|
|
51
|
+
raise ValueError("expected a non-empty JSON array")
|
|
52
|
+
if isinstance(rows[0], dict):
|
|
53
|
+
key = _first_vector_key(rows[0])
|
|
54
|
+
rows = [r[key] for r in rows]
|
|
55
|
+
return np.asarray(rows, dtype=np.float32)
|
|
56
|
+
if suf in {".csv", ".tsv", ".txt"}:
|
|
57
|
+
return np.loadtxt(p, delimiter="," if suf == ".csv" else None, dtype=np.float32)
|
|
58
|
+
raise ValueError(f"unsupported embedding file: {p} (use .npy/.npz/.parquet/.jsonl/.json/.csv)")
|
vecscope/mcp_server.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""MCP server exposing vecscope as a tool. Run: `python -m vecscope.mcp_server`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from vecscope.evidence import audit
|
|
8
|
+
from vecscope.io import load_vectors
|
|
9
|
+
|
|
10
|
+
mcp = FastMCP("vecscope")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
def audit_embeddings(
|
|
15
|
+
path: str,
|
|
16
|
+
k: int = 10,
|
|
17
|
+
ann_path: str | None = None,
|
|
18
|
+
queries_path: str | None = None,
|
|
19
|
+
clusters: int = 0,
|
|
20
|
+
near_dup_cos: float = 0.99,
|
|
21
|
+
) -> dict:
|
|
22
|
+
"""Audit an embedding file: hubness, anisotropy, collapse, rogue dimensions, norms,
|
|
23
|
+
metric mismatch, distance concentration, outliers, k-NN graph islands, and optionally
|
|
24
|
+
ANN recall and query-side findings.
|
|
25
|
+
|
|
26
|
+
`path` is a local .npy/.npz/.parquet/.jsonl/.json/.csv with one vector per row.
|
|
27
|
+
`ann_path` is an optional (n x k) integer array of neighbor ids from an ANN index.
|
|
28
|
+
`queries_path` is an optional (m x dim) file of query embeddings.
|
|
29
|
+
`clusters` > 0 attaches a per-cluster report with sample ids.
|
|
30
|
+
Returns deterministic evidence with ok/warn/fail verdicts, thresholds and explanations.
|
|
31
|
+
"""
|
|
32
|
+
x = load_vectors(path)
|
|
33
|
+
ann = load_vectors(ann_path).astype(int) if ann_path else None
|
|
34
|
+
q = load_vectors(queries_path) if queries_path else None
|
|
35
|
+
return audit(x, k=k, ann_neighbors=ann, near_dup_cos=near_dup_cos, queries=q,
|
|
36
|
+
clusters=clusters).to_dict()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def sweep_index(
|
|
41
|
+
path: str,
|
|
42
|
+
k: int = 10,
|
|
43
|
+
queries_path: str | None = None,
|
|
44
|
+
target_recall: float = 0.95,
|
|
45
|
+
device: str = "auto",
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Sweep HNSW (M, ef_search) and IVF (nprobe) parameters on an embedding file and report
|
|
48
|
+
recall@k against exact k-NN, build time and queries per second for each setting, plus
|
|
49
|
+
the fastest configuration that reaches `target_recall`. Needs the faiss extra.
|
|
50
|
+
`device` is "auto" (GPU when the faiss build has one), "cpu" or "gpu"; HNSW is CPU-only.
|
|
51
|
+
"""
|
|
52
|
+
from vecscope.sweep import sweep
|
|
53
|
+
|
|
54
|
+
x = load_vectors(path)
|
|
55
|
+
q = load_vectors(queries_path) if queries_path else None
|
|
56
|
+
return sweep(x, k=k, queries=q, target_recall=target_recall, device=device).to_dict()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
mcp.run()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
vecscope/sweep.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Index-side evidence: how do HNSW and IVF parameters trade recall for speed on this corpus?
|
|
2
|
+
|
|
3
|
+
Requires the optional `faiss-cpu` dependency (`pip install "vecscope[index]"`), or a GPU
|
|
4
|
+
build of faiss (`faiss-gpu-cu12`), in which case the IVF index and the exact baseline run on
|
|
5
|
+
the GPU automatically. HNSW has no GPU implementation in faiss and always runs on the CPU.
|
|
6
|
+
Recall is measured against exact k-NN for a seeded sample of queries drawn from the corpus
|
|
7
|
+
(or a supplied query file). Timings are wall-clock on this machine and only meaningful
|
|
8
|
+
relative to each other.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from vecscope._knn import normalize
|
|
19
|
+
from vecscope.diagnostics.queries import query_knn
|
|
20
|
+
|
|
21
|
+
HNSW_GRID = {"M": (8, 16, 32), "ef_construction": (100,), "ef_search": (16, 32, 64, 128, 256)}
|
|
22
|
+
IVF_NPROBE = (1, 2, 4, 8, 16, 32, 64)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SweepPoint:
|
|
27
|
+
index: str
|
|
28
|
+
params: dict
|
|
29
|
+
recall_at_k: float
|
|
30
|
+
build_seconds: float
|
|
31
|
+
queries_per_second: float
|
|
32
|
+
device: str = "cpu"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SweepResult:
|
|
37
|
+
tool: str
|
|
38
|
+
version: str
|
|
39
|
+
n: int
|
|
40
|
+
dim: int
|
|
41
|
+
k: int
|
|
42
|
+
n_queries: int
|
|
43
|
+
target_recall: float
|
|
44
|
+
points: list[SweepPoint]
|
|
45
|
+
recommendation: dict = field(default_factory=dict)
|
|
46
|
+
device: str = "cpu"
|
|
47
|
+
gpu_name: str | None = None
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def gpu_available(faiss) -> bool:
|
|
54
|
+
"""True when this faiss build has GPU support and at least one GPU is visible."""
|
|
55
|
+
needed = ("StandardGpuResources", "GpuParameterSpace", "index_cpu_to_gpu", "get_num_gpus")
|
|
56
|
+
if not all(hasattr(faiss, name) for name in needed):
|
|
57
|
+
return False # CPU wheels (notably on macOS) ship partial GPU stubs
|
|
58
|
+
try:
|
|
59
|
+
if faiss.get_num_gpus() <= 0:
|
|
60
|
+
return False
|
|
61
|
+
faiss.StandardGpuResources() # fails on a stub build or a broken driver
|
|
62
|
+
except (RuntimeError, AttributeError, TypeError):
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_device(faiss, device: str) -> str:
|
|
68
|
+
if device not in {"auto", "cpu", "gpu"}:
|
|
69
|
+
raise ValueError("device must be 'auto', 'cpu' or 'gpu'")
|
|
70
|
+
has_gpu = gpu_available(faiss)
|
|
71
|
+
if device == "gpu" and not has_gpu:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
"device='gpu' but this faiss build has no GPU support or no GPU is visible; "
|
|
74
|
+
"install a GPU build (e.g. faiss-gpu-cu12) or use device='auto'"
|
|
75
|
+
)
|
|
76
|
+
return "gpu" if (device == "gpu" or (device == "auto" and has_gpu)) else "cpu"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _recall(approx: np.ndarray, exact: np.ndarray) -> float:
|
|
80
|
+
k = exact.shape[1]
|
|
81
|
+
hits = [np.intersect1d(a[:k], e, assume_unique=False).size for a, e in zip(approx, exact)]
|
|
82
|
+
return float(np.mean(hits) / k)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sweep(
|
|
86
|
+
x: np.ndarray,
|
|
87
|
+
k: int = 10,
|
|
88
|
+
queries: np.ndarray | None = None,
|
|
89
|
+
n_queries: int = 1000,
|
|
90
|
+
target_recall: float = 0.95,
|
|
91
|
+
seed: int = 0,
|
|
92
|
+
device: str = "auto",
|
|
93
|
+
) -> SweepResult:
|
|
94
|
+
"""Sweep HNSW and IVF settings. ``device`` is 'auto' (GPU when available), 'cpu' or 'gpu'."""
|
|
95
|
+
try:
|
|
96
|
+
import faiss
|
|
97
|
+
except ImportError as e: # pragma: no cover
|
|
98
|
+
raise ImportError('index sweeps need faiss: pip install "vecscope[index]"') from e
|
|
99
|
+
from vecscope import __version__
|
|
100
|
+
|
|
101
|
+
dev = _resolve_device(faiss, device)
|
|
102
|
+
gpu_res = faiss.StandardGpuResources() if dev == "gpu" else None
|
|
103
|
+
gpu_name = None
|
|
104
|
+
if dev == "gpu":
|
|
105
|
+
try:
|
|
106
|
+
import torch # optional, only for a readable device name
|
|
107
|
+
|
|
108
|
+
gpu_name = torch.cuda.get_device_name(0)
|
|
109
|
+
except (ImportError, RuntimeError, AssertionError):
|
|
110
|
+
gpu_name = "gpu:0"
|
|
111
|
+
|
|
112
|
+
def to_device(index):
|
|
113
|
+
return faiss.index_cpu_to_gpu(gpu_res, 0, index) if dev == "gpu" else index
|
|
114
|
+
|
|
115
|
+
c = normalize(x)
|
|
116
|
+
n, d = c.shape
|
|
117
|
+
rng = np.random.default_rng(seed)
|
|
118
|
+
if queries is None:
|
|
119
|
+
q = c[rng.choice(n, min(n_queries, n), replace=False)]
|
|
120
|
+
else:
|
|
121
|
+
q = normalize(queries)
|
|
122
|
+
if q.shape[0] > n_queries:
|
|
123
|
+
q = q[rng.choice(q.shape[0], n_queries, replace=False)]
|
|
124
|
+
kq = k + 1 if queries is None else k
|
|
125
|
+
if dev == "gpu": # exact baseline on the GPU too
|
|
126
|
+
flat = to_device(faiss.IndexFlatIP(d))
|
|
127
|
+
flat.add(c)
|
|
128
|
+
_, exact_idx = flat.search(q, kq)
|
|
129
|
+
else:
|
|
130
|
+
exact_idx, _ = query_knn(c, q, kq)
|
|
131
|
+
if queries is None: # drop the query itself from its own neighbor list
|
|
132
|
+
exact_idx = exact_idx[:, 1:]
|
|
133
|
+
points: list[SweepPoint] = []
|
|
134
|
+
|
|
135
|
+
def measure(index, name, params):
|
|
136
|
+
t = time.perf_counter()
|
|
137
|
+
_, approx = index.search(q, k + 1 if queries is None else k)
|
|
138
|
+
qps = q.shape[0] / max(time.perf_counter() - t, 1e-9)
|
|
139
|
+
if queries is None:
|
|
140
|
+
approx = approx[:, 1:]
|
|
141
|
+
return name, params, _recall(approx, exact_idx), qps
|
|
142
|
+
|
|
143
|
+
for m in HNSW_GRID["M"]:
|
|
144
|
+
for efc in HNSW_GRID["ef_construction"]:
|
|
145
|
+
index = faiss.IndexHNSWFlat(d, m, faiss.METRIC_INNER_PRODUCT)
|
|
146
|
+
index.hnsw.efConstruction = efc
|
|
147
|
+
t = time.perf_counter()
|
|
148
|
+
index.add(c)
|
|
149
|
+
build = time.perf_counter() - t
|
|
150
|
+
for efs in HNSW_GRID["ef_search"]:
|
|
151
|
+
index.hnsw.efSearch = efs
|
|
152
|
+
name, params, rec, qps = measure(
|
|
153
|
+
index, "hnsw", {"M": m, "ef_construction": efc, "ef_search": efs}
|
|
154
|
+
)
|
|
155
|
+
points.append(SweepPoint(name, params, rec, build, qps, "cpu")) # HNSW: CPU only
|
|
156
|
+
|
|
157
|
+
nlist = int(max(1, min(4 * np.sqrt(n), n // 39 or 1)))
|
|
158
|
+
quantizer = faiss.IndexFlatIP(d)
|
|
159
|
+
index = to_device(faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT))
|
|
160
|
+
t = time.perf_counter()
|
|
161
|
+
index.train(c)
|
|
162
|
+
index.add(c)
|
|
163
|
+
build = time.perf_counter() - t
|
|
164
|
+
for nprobe in IVF_NPROBE:
|
|
165
|
+
if nprobe > nlist:
|
|
166
|
+
break
|
|
167
|
+
if dev == "gpu":
|
|
168
|
+
faiss.GpuParameterSpace().set_index_parameter(index, "nprobe", nprobe)
|
|
169
|
+
else:
|
|
170
|
+
index.nprobe = nprobe
|
|
171
|
+
name, params, rec, qps = measure(index, "ivf", {"nlist": nlist, "nprobe": nprobe})
|
|
172
|
+
points.append(SweepPoint(name, params, rec, build, qps, dev))
|
|
173
|
+
|
|
174
|
+
ok = [p for p in points if p.recall_at_k >= target_recall]
|
|
175
|
+
rec = {}
|
|
176
|
+
if ok:
|
|
177
|
+
best = max(ok, key=lambda p: p.queries_per_second)
|
|
178
|
+
rec = {"index": best.index, "params": best.params, "recall_at_k": best.recall_at_k,
|
|
179
|
+
"queries_per_second": best.queries_per_second,
|
|
180
|
+
"reason": f"fastest configuration with recall@{k} >= {target_recall}"}
|
|
181
|
+
else:
|
|
182
|
+
best = max(points, key=lambda p: p.recall_at_k)
|
|
183
|
+
rec = {"index": best.index, "params": best.params, "recall_at_k": best.recall_at_k,
|
|
184
|
+
"queries_per_second": best.queries_per_second,
|
|
185
|
+
"reason": f"no configuration reached recall@{k} >= {target_recall}; "
|
|
186
|
+
"this is the best one. Expect a hard corpus (see audit findings)."}
|
|
187
|
+
return SweepResult("vecscope", __version__, int(n), int(d), int(k), int(q.shape[0]),
|
|
188
|
+
target_recall, points, rec, device=dev, gpu_name=gpu_name)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_sweep(res: SweepResult) -> str:
|
|
192
|
+
head = (f"vecscope {res.version} sweep n={res.n} dim={res.dim} k={res.k} "
|
|
193
|
+
f"queries={res.n_queries} target_recall={res.target_recall} device={res.device}")
|
|
194
|
+
if res.gpu_name:
|
|
195
|
+
head += f" ({res.gpu_name}; HNSW stays on CPU)"
|
|
196
|
+
lines = [head]
|
|
197
|
+
lines.append(f" {'index':<6}{'params':<44}{'recall':>8}{'build s':>9}{'qps':>10} dev")
|
|
198
|
+
for p in res.points:
|
|
199
|
+
params = ", ".join(f"{a}={b}" for a, b in p.params.items())
|
|
200
|
+
mark = "*" if p.recall_at_k >= res.target_recall else " "
|
|
201
|
+
lines.append(f" {mark}{p.index:<6}{params:<44}{p.recall_at_k:>8.3f}"
|
|
202
|
+
f"{p.build_seconds:>9.2f}{p.queries_per_second:>10.0f} {p.device}")
|
|
203
|
+
r = res.recommendation
|
|
204
|
+
params = ", ".join(f"{a}={b}" for a, b in r["params"].items())
|
|
205
|
+
lines.append(f" recommendation: {r['index']} ({params}) recall={r['recall_at_k']:.3f} "
|
|
206
|
+
f"qps={r['queries_per_second']:.0f} ({r['reason']})")
|
|
207
|
+
return "\n".join(lines)
|