diff --git a/docs/docs/en/src/SUMMARY.md b/docs/docs/en/src/SUMMARY.md
index ceac850926..45a690a640 100644
--- a/docs/docs/en/src/SUMMARY.md
+++ b/docs/docs/en/src/SUMMARY.md
@@ -98,6 +98,7 @@
- [HDF5 Dataset Format](resources/dataset_format.md)
- [Index Analysis Tool](resources/analyze_index.md)
- [Compatibility Check Tool](resources/check_compatibility.md)
+- [Dataset Feature Visualization Tool](resources/dataset_profiler.md)
# Resources
diff --git a/docs/docs/en/src/resources/dataset_profiler.md b/docs/docs/en/src/resources/dataset_profiler.md
new file mode 100644
index 0000000000..b1e3373b73
--- /dev/null
+++ b/docs/docs/en/src/resources/dataset_profiler.md
@@ -0,0 +1,185 @@
+# Dataset Feature Visualization (`dataset_profiler`)
+
+VSAG ships tools for evaluating index performance (`eval_performance`) and for visualizing the
+serialized layout of an index (`visualize_index`). The **dataset profiler** completes the picture
+from the other side: it analyzes and visualizes the *feature characteristics of an input vector
+dataset* — the vector norms, per-dimension value distributions, sparsity, duplicates, sampled
+distance distributions, outliers, and anisotropy that influence index selection, quantization
+quality, recall, and search performance.
+
+It is a pure-Python tool (`tools/dataset_profiler`) that understands the same dense and sparse
+HDF5 files consumed by `eval_performance`, and it produces two artifacts:
+
+- a **versioned JSON report** with every metric tagged by definition, scope (full or sampled),
+ and sample size;
+- a **self-contained interactive HTML report** (inline CSS/JS/SVG, opens offline);
+
+plus an **optional AI-assisted interpretation** that explains the *aggregate metrics* — never
+raw vectors — and phrases each suggestion as a hypothesis backed by cited metric keys.
+
+## Installation
+
+The profiler requires Python 3.10+ with NumPy and h5py; it is not part of the C++ build.
+
+```bash
+cd tools/dataset_profiler
+pip install -r requirements.txt
+```
+
+## Quick start
+
+```bash
+# Profile the train split with the default deterministic settings.
+python -m dataset_profiler /path/to/my_dataset.hdf5
+```
+
+By default this writes `my_dataset.profile.json` and `my_dataset.profile.html` next to the
+input file. Use `--out-json` / `--out-html` to place them elsewhere (pass `-` to `--out-json`
+to print the report to stdout instead).
+
+A richer invocation:
+
+```bash
+python -m dataset_profiler my_dataset.hdf5 \
+ --split train --seed 42 \
+ --sample-size 512 --pairwise-sample-size 256 \
+ --metric euclidean --clusters 8 \
+ --out-json my_dataset.profile.json --out-html my_dataset.profile.html
+```
+
+`tools/dataset_profiler/examples/make_toy_datasets.py` regenerates the bundled demo datasets
+and reports end to end, and the `tools/dataset_profiler/examples/output/` directory contains
+the committed example JSON/HTML files.
+
+## Supported input formats
+
+The tool reads the layouts defined in [HDF5 Dataset Format](dataset_format.md):
+
+- **Dense** (`type` absent or `"dense"`): `/` is an `(N, D)` matrix of `INT8` or
+ `FLOAT32` values.
+- **Sparse** (`type="sparse"`): `/` is a flat byte stream of
+ `uint32 len | uint32 ids[len] | float32 vals[len]` records, optionally indexed by
+ `/_offsets`. Sparse dimensionality is inferred as `max(feature id) + 1`.
+
+For dense one-byte unsigned HDF5 integers, the profiler uses HDF5's numeric conversion to signed
+`INT8` (values above 127 saturate at 127), matching `eval_performance`'s `H5::PredType::ALPHA_I8`
+reads. The report dtype and all numeric statistics therefore use the same values as the evaluator.
+
+`multi_vector` files are rejected with an explanatory error. Data is streamed in bounded
+chunks: the profiler never materializes a complete dataset, and pairwise work is capped by
+the configured sample size, so no `O(N²)` matrix is ever allocated over the full dataset.
+Dense block sizes are derived from a working-memory budget rather than a fixed row count,
+sparse per-dimension accumulators stop at 10,000,000 inferred dimensions (the other metrics
+are still reported), and Euclidean distances switch to direct coordinate differences when
+the fast expanded form would cancel catastrophically. Full norm and sparse nnz storage (8 bytes
+per vector each) each have a 256 MiB retained-state budget. Exact-duplicate detection retains a
+16-byte digest per vector but uses a peak-aware budget of 64 bytes per vector, also covering
+`np.unique`'s sorted copy, sort index, unique output, and counts, against the same 256 MiB
+full-split state budget. It is available through 4,194,304 vectors
+(`256 × 1024 × 1024 / 64`, approximately 4.19 million) and becomes unavailable above that
+threshold. If the row-count estimate exceeds a budget, the corresponding full-split norms/outliers,
+nnz distribution, or exact-duplicate fields are explicitly marked unavailable with a reason; no
+partial result is presented as full-dataset coverage, and streaming/sampled metrics are still
+reported. Sparse offsets are validated in bounded windows; an index over 64 MiB is not retained and
+the reader falls back to bounded sequential decoding.
+
+To profile arrays you only have in memory — for example the same arrays you pass to
+`pyvsag` — first export them with h5py to the layout above; see
+[tools/dataset_profiler/examples/README.md](https://github.com/antgroup/vsag/tree/main/tools/dataset_profiler/examples).
+
+## Metrics
+
+Every metric in the JSON report carries a human-readable `definition`, a `scope` of `full`,
+`sample`, or `mixed`, and the `sample_size` used. The HTML report repeats this provenance per
+section.
+
+| Metric | Scope | Description |
+| --- | --- | --- |
+| Dataset summary | full | Count, dimensionality, element type, file attributes. |
+| `norms` | full | L2 norm distribution of every vector. |
+| `dimensions` | full | Per-coordinate mean, std, min, max, zero ratio; top-variance and top-zero dimensions listed separately; long series are bucket-averaged and flagged. |
+| `sparsity` | full | Zero/non-zero counts and density; NaN/Inf are excluded from finite non-zero counts and reported separately. For sparse files also stored entries and nnz-per-vector distribution. |
+| `invalid_values` | full | NaN/Inf counts (values and affected vectors). Invalid entries are excluded from numeric summaries and treated as zero elsewhere. |
+| `outliers` | full | Robust norm outliers via modified z-score (median/MAD, IQR fallback). |
+| `duplicates` | mixed | Exact duplicates (full, byte-identical) plus a near-duplicate indicator over sampled pairs (`distance ≤ epsilon × median sampled distance`); inner-product inputs use non-negative Euclidean distance for this indicator. |
+| `pairwise_distances` | sample | Distance distribution between pairs of a bounded random sample. |
+| `nearest_neighbor_distances` | sample | Per sampled vector, distance to the nearest other sampled vector. |
+| `anisotropy` | sample | Centered-covariance eigenvalue concentration: top-1/top-10 share, effective rank, mean pairwise cosine. |
+| `clustering` | sample | Deterministic PCA-2 projection (scatter) and optional seeded k-means summary (`--clusters K`). |
+
+## Sampling and reproducibility
+
+- All sampling uses an explicit `--seed` (default 42) with a dedicated PCG64 generator; the
+ method string is recorded in the report.
+- Sample indices are drawn with Floyd's algorithm (bounded by the sample size, streaming-safe);
+ a seeded permutation selects the pairwise subset without favoring low-index rows.
+- `--sample-size` bounds distribution/reduction work; `--pairwise-sample-size` bounds the
+ distance matrix; both effective sizes are reported alongside the requested ones. Dense sample
+ materialization is additionally capped at 256 MiB for its float64 matrix; reaching that cap
+ produces a warning and reduced or unavailable sampled metrics.
+- Two runs with identical input, configuration, sample sizes, and seed produce byte-identical
+ JSON reports (keys sorted, fixed indent). Sparse results are identical whether or not the
+ file provides `*_offsets`.
+
+## Privacy and offline behavior
+
+- The deterministic JSON and HTML reports are generated entirely offline. The HTML inlines all
+ assets and contacts no external service when opened.
+- Raw vectors are never written into the report and never sent to an AI provider: the AI
+ request contains only aggregate metrics, sampling configuration, warnings, and bounded
+ non-sensitive metadata. Per-vector entries such as the PCA projection points and cluster
+ labels are filtered out of that payload.
+- AI credentials come only from environment variables, are transmitted exclusively over https
+ in the `Authorization` header, and are never included in reports, command output, or error
+ messages (errors are redacted). HTTP redirects are rejected so credentials cannot be forwarded.
+
+## Optional AI-assisted interpretation
+
+AI analysis is off by default. Enable it with `--ai` (or by setting
+`VSAG_DATASET_PROFILER_AI=1`) and configure it through the environment:
+
+```bash
+export VSAG_AI_API_KEY=... # bearer token (fallback: OPENAI_API_KEY)
+export VSAG_AI_MODEL=... # required model name
+export VSAG_AI_BASE_URL=... # optional https OpenAI-compatible endpoint
+python -m dataset_profiler my_dataset.hdf5 --ai
+```
+
+The provider receives the aggregate profile and must return a structured analysis:
+`summary`, `findings`, `recommendations` (each citing evidence keys that exist in the report
+and phrased as hypotheses with a `validation` step), `limitations`, and
+`additional_measurements`. The profiler validates the response before merging it into the
+report as `ai_analysis`; malformed responses, unknown evidence references, and provider
+failures never block report generation — they are surfaced as an `ai_analysis.status =
+"error"` entry in the JSON and a visible warning box in the HTML, plus a terminal warning.
+
+### Validating AI recommendations
+
+Treat every recommendation as a hypothesis, not a decision:
+
+1. Reproduce the measurement yourself — open the HTML report and check the cited metric keys
+ and their scope/sample size.
+2. Run the suggested experiment in `eval_performance` (see the [Evaluation Tool](eval.md)) with
+ your real query set and ground truth; AI interpretation never replaces a reproducible
+ benchmark.
+3. Re-profile after any data transformation (normalization, rotation, pruning) and compare
+ the before/after metric keys cited by the recommendation.
+
+## Tests and reference
+
+Run the tool's unit and integration tests with:
+
+```bash
+cd tools/dataset_profiler && python -m pytest tests -q
+```
+
+Design notes — metric selection, sampling internals, privacy boundaries, and the AI
+prompt/response schema — are in
+[`tools/dataset_profiler/TECHNICAL_REPORT.md`](https://github.com/antgroup/vsag/tree/main/tools/dataset_profiler/TECHNICAL_REPORT.md).
+
+## Related
+
+- [HDF5 Dataset Format](dataset_format.md)
+- [Evaluation Tool](eval.md)
+- [Index Analysis Tool](analyze_index.md)
+- [FAQ: how data distribution affects vector search](faq.md)
diff --git a/docs/docs/zh/src/SUMMARY.md b/docs/docs/zh/src/SUMMARY.md
index f720493ae7..ac32950d5b 100644
--- a/docs/docs/zh/src/SUMMARY.md
+++ b/docs/docs/zh/src/SUMMARY.md
@@ -98,6 +98,7 @@
- [HDF5 数据集格式](resources/dataset_format.md)
- [索引分析工具](resources/analyze_index.md)
- [兼容性检查工具](resources/check_compatibility.md)
+- [数据集特征可视化工具](resources/dataset_profiler.md)
# 资源
diff --git a/docs/docs/zh/src/resources/dataset_profiler.md b/docs/docs/zh/src/resources/dataset_profiler.md
new file mode 100644
index 0000000000..02f6436b55
--- /dev/null
+++ b/docs/docs/zh/src/resources/dataset_profiler.md
@@ -0,0 +1,159 @@
+# 数据集特征可视化(`dataset_profiler`)
+
+VSAG 已提供索引性能评测工具(`eval_performance`)与索引序列化结构可视化工具
+(`visualize_index`)。**数据集特征分析工具**从另一个角度补全了信息:它分析并可视化*输入向量
+数据集本身的特征*——向量范数、各维度数值分布、稀疏度、重复数据、采样距离分布、异常值与各向异性,
+这些因素会显著影响索引选择、量化质量、召回率与检索性能。
+
+该工具是纯 Python 实现(位于 `tools/dataset_profiler`),直接读取 `eval_performance` 使用的稠密
+与稀疏 HDF5 文件,输出两类产物:
+
+- **带版本号的 JSON 报告**:每项指标都注明定义、统计范围(全量或采样)以及样本数量;
+- **自包含的交互式 HTML 报告**:CSS/JS/SVG 全部内联,可离线打开;
+
+另有**可选的 AI 辅助解读**:只解释*聚合指标*(绝不接触原始向量),并把每条建议表述为附有指标
+依据的待验证假设。
+
+## 安装
+
+工具需要 Python 3.10+ 以及 NumPy、h5py,不参与 C++ 构建。
+
+```bash
+cd tools/dataset_profiler
+pip install -r requirements.txt
+```
+
+## 快速开始
+
+```bash
+# 使用默认确定性配置分析 train 分片
+python -m dataset_profiler /path/to/my_dataset.hdf5
+```
+
+默认在输入文件旁生成 `my_dataset.profile.json` 与 `my_dataset.profile.html`;用
+`--out-json` / `--out-html` 指定输出位置(`--out-json -` 表示将 JSON 打印到标准输出)。
+
+更完整的调用示例:
+
+```bash
+python -m dataset_profiler my_dataset.hdf5 \
+ --split train --seed 42 \
+ --sample-size 512 --pairwise-sample-size 256 \
+ --metric euclidean --clusters 8 \
+ --out-json my_dataset.profile.json --out-html my_dataset.profile.html
+```
+
+`tools/dataset_profiler/examples/make_toy_datasets.py` 可端到端重新生成自带的演示数据与报告;
+`tools/dataset_profiler/examples/output/` 目录中保存了示例 JSON/HTML 文件。
+
+## 支持的数据格式
+
+工具读取[HDF5 数据集格式](dataset_format.md)中定义的两种布局:
+
+- **稠密**(无 `type` 属性或 `type="dense"`):`/` 是 `(N, D)` 的 `INT8` 或 `FLOAT32`
+ 矩阵;
+- **稀疏**(`type="sparse"`):`/` 是扁平字节流,每条记录为
+ `uint32 len | uint32 ids[len] | float32 vals[len]`,可选地由 `/_offsets` 索引。稀疏
+ 维度按 `max(特征 id) + 1` 推断。
+
+对于稠密的一字节无符号 HDF5 整数,分析工具通过 HDF5 数值转换读取为有符号 `INT8`(大于 127
+的值饱和为 127),与 `eval_performance` 使用 `H5::PredType::ALPHA_I8` 读取的行为一致。因此,报告中的
+dtype 与所有数值统计均使用 evaluator 实际看到的值。
+
+`multi_vector` 文件会被拒绝并给出说明。数据按有界分块流式读取:工具不会把完整数据集载入内存,
+两两距离计算受采样规模约束,绝不会为完整数据集分配 `O(N²)` 矩阵。稠密数据的分块行数由工作内存
+预算推导而非固定行数;稀疏每维累加器在推断维度超过 10,000,000 时停止(其余指标仍会输出);
+当快速展开式计算会出现灾难性抵消时,欧氏距离改用坐标直接相减。全量范数与稀疏 nnz 存储
+(各每向量 8 字节)各有 256 MiB 保留状态预算。精确重复检测为每个向量保留 16 字节摘要,但按
+每向量 64 字节的峰值内存进行预算,额外覆盖 `np.unique` 的排序副本、排序索引、去重输出和计数,
+并使用同样的 256 MiB 全量状态预算。因此,精确重复检测在不超过 4,194,304 个向量
+(`256 × 1024 × 1024 / 64`,约 419 万)时可用,超过该阈值后变为不可用。
+如果按行数估算超过任一预算,对应的全量范数/异常值、nnz 分布或精确重复字段会明确标记为不可用
+并给出原因,不会把部分结果伪装成全量统计;其余流式与采样指标仍会输出。稀疏 offsets 采用有界
+窗口校验;超过 64 MiB 的索引不会保留在内存中,读取器会回退到有界顺序解码。
+
+如果数据只存在于内存中(例如准备传给 `pyvsag` 的数组),可先用 h5py 按上述布局导出为 HDF5,
+参见 `tools/dataset_profiler/examples/README.md`。
+
+## 指标
+
+JSON 报告中每项指标都带有人类可读的 `definition`、`scope`(`full`、`sample` 或 `mixed`)以及
+所用 `sample_size`;HTML 报告在每一节中重复这些溯源信息。
+
+| 指标 | 范围 | 说明 |
+| --- | --- | --- |
+| 数据集概况 | 全量 | 向量数、维度、元素类型、文件属性。 |
+| `norms` | 全量 | 每个向量的 L2 范数分布。 |
+| `dimensions` | 全量 | 每个坐标维度的均值、标准差、最小/最大值、零值比例;单独列出方差最高与零值比例最高的维度;超长序列会按桶平均并标注。 |
+| `sparsity` | 全量 | 零值/非零值计数与密度;NaN/Inf 不计入有限非零值,单独统计。稀疏文件还统计存储条目数与每向量非零元素(nnz)分布。 |
+| `invalid_values` | 全量 | NaN/Inf 的数量(按值与受影响向量)。非法值不参与数值统计,在其余计算中按 0 处理。 |
+| `outliers` | 全量 | 基于稳健修正 z 分数(median/MAD,IQR 兜底)的范数异常向量。 |
+| `duplicates` | 混合 | 精确重复(全量,字节级一致),外加基于采样配对的近重复指示指标(`距离 ≤ epsilon × 采样距离中位数`);内积输入在该指标中使用非负的欧氏距离。 |
+| `pairwise_distances` | 采样 | 有界随机样本内两两距离的分布。 |
+| `nearest_neighbor_distances` | 采样 | 每个采样向量到样本内最近邻的距离。 |
+| `anisotropy` | 采样 | 中心化协方差特征值集中度:最大特征值占比、前 10 占比、有效秩、平均两两余弦相似度。 |
+| `clustering` | 采样 | 确定性 PCA-2 投影散点图;可选的带种子 k-means 摘要(`--clusters K`)。 |
+
+## 采样与可复现性
+
+- 所有采样均使用显式 `--seed`(默认 42)与独立 PCG64 生成器;采样方法字符串记录在报告中。
+- 样本下标用 Floyd 算法抽取(内存/时间受样本量约束,适配流式扫描);再通过带种子的随机排列
+ 选择两两距离子集,避免偏向低下标行。
+- `--sample-size` 约束分布与降维类计算,`--pairwise-sample-size` 约束距离矩阵;报告中同时记录
+ 请求值与生效值。稠密样本的 float64 矩阵另有 256 MiB 物化上限;达到上限时会发出警告,并缩减
+ 采样规模或将采样指标标记为不可用。
+- 相同输入、配置、采样规模与随机种子两次运行生成的 JSON 报告逐字节一致(键排序、固定缩进)。
+ 稀疏文件的分析结果与是否提供 `*_offsets` 无关。
+
+## 隐私与离线行为
+
+- 确定性 JSON/HTML 报告完全离线生成;HTML 内联全部资源,打开时不访问任何外部服务。
+- 原始向量既不写入报告,也绝不发送给 AI 服务:AI 请求只包含聚合指标、采样配置、警告与有限的
+ 非敏感元数据。PCA 投影点、聚类标签等逐向量字段会从该负载中过滤掉。
+- AI 凭据只来自环境变量,仅通过 https 的 `Authorization` 头传输,且绝不写入报告、命令输出或
+ 错误信息(错误消息会被脱敏)。工具拒绝 HTTP 重定向,避免凭据被转发到其他端点。
+
+## 可选的 AI 辅助解读
+
+AI 分析默认关闭。使用 `--ai`(或设置 `VSAG_DATASET_PROFILER_AI=1`)显式启用,并通过环境变量
+配置:
+
+```bash
+export VSAG_AI_API_KEY=... # 访问令牌(兜底:OPENAI_API_KEY)
+export VSAG_AI_MODEL=... # 必填的模型名
+export VSAG_AI_BASE_URL=... # 可选,https 的 OpenAI 兼容端点
+python -m dataset_profiler my_dataset.hdf5 --ai
+```
+
+AI 收到聚合画像后必须返回结构化分析:`summary`、`findings`、`recommendations`(每条都要引用
+报告中真实存在的指标字段,以“假设 + `validation` 验证步骤”的形式表述)、`limitations` 与
+`additional_measurements`。分析器在把结果并入报告的 `ai_analysis` 字段前会做校验;格式错误的
+响应、虚构的指标引用以及服务失败都不会阻碍报告生成——它们在 JSON 中呈现为
+`ai_analysis.status = "error"` 条目,在 HTML 中呈现为醒目警告框,同时在终端打印警告。
+
+### 如何验证 AI 建议
+
+请把每条建议当作假设而非结论:
+
+1. 自行复核指标证据——打开 HTML 报告,核对其引用的指标键及统计范围/样本量。
+2. 用 `eval_performance`(见[性能评估工具](eval.md))配合真实查询集与 ground truth 复现实验;
+ AI 解读永远不能替代可复现的基准测试。
+3. 任何数据变换(归一化、旋转、维度剪枝)之后重新画像,并对比建议所引用的前后指标键。
+
+## 测试与参考
+
+运行工具的单元测试与集成测试:
+
+```bash
+cd tools/dataset_profiler && python -m pytest tests -q
+```
+
+设计说明(指标选择、采样内部机制、隐私边界、AI 提示与响应结构)见
+`tools/dataset_profiler/TECHNICAL_REPORT.md`。
+
+## 相关文档
+
+- [HDF5 数据集格式](dataset_format.md)
+- [性能评估工具](eval.md)
+- [索引分析工具](analyze_index.md)
+- [FAQ:数据分布如何影响向量检索](faq.md)
diff --git a/tools/README.md b/tools/README.md
index d98103d7e8..f455e8745c 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -9,6 +9,7 @@ nearby entry points for local development and AI coding agents.
| `check_compatibility` | [check_compatibility/README.md](check_compatibility/README.md) | [docs/docs/en/src/resources/check_compatibility.md](../docs/docs/en/src/resources/check_compatibility.md) | |
| `eval_performance` | [eval/README.md](eval/README.md) | [docs/docs/en/src/resources/eval.md](../docs/docs/en/src/resources/eval.md) | |
| `autotune` | [autotune/README.md](autotune/README.md) | [docs/docs/en/src/resources/autotune.md](../docs/docs/en/src/resources/autotune.md) | |
+| `dataset_profiler` | [dataset_profiler/README.md](dataset_profiler/README.md) | [docs/docs/en/src/resources/dataset_profiler.md](../docs/docs/en/src/resources/dataset_profiler.md) | |
Chinese documentation is available under `docs/docs/zh/src/resources/` and on the Chinese website
paths under .
diff --git a/tools/dataset_profiler/README.md b/tools/dataset_profiler/README.md
new file mode 100644
index 0000000000..ae48edb6ef
--- /dev/null
+++ b/tools/dataset_profiler/README.md
@@ -0,0 +1,76 @@
+# VSAG Dataset Profiler (`dataset_profiler`)
+
+A reproducible tool that profiles and visualizes the **feature characteristics of an input
+vector dataset** — dimension distributions, value statistics, norms, sparsity, duplicates,
+sampled pairwise distances, outliers, anisotropy, and optional clustering — for the dense and
+sparse HDF5 formats already used by VSAG's evaluation tooling (and by the same arrays you hand
+to `pyvsag`).
+
+The canonical documentation lives in the website source tree.
+
+- Local English docs:
+ [docs/docs/en/src/resources/dataset_profiler.md](../../docs/docs/en/src/resources/dataset_profiler.md)
+- Local Chinese docs:
+ [docs/docs/zh/src/resources/dataset_profiler.md](../../docs/docs/zh/src/resources/dataset_profiler.md)
+- HDF5 dataset format:
+ [docs/docs/en/src/resources/dataset_format.md](../../docs/docs/en/src/resources/dataset_format.md)
+- Website:
+- Chinese website:
+- Design notes (metrics, sampling, privacy, AI schema):
+ [TECHNICAL_REPORT.md](TECHNICAL_REPORT.md)
+- Example datasets and reports: [examples/](examples/README.md)
+
+## Quick start
+
+Requires Python 3.10+ with `numpy` and `h5py` (see [requirements.txt](requirements.txt)). No
+C++ build is needed.
+
+```bash
+cd tools/dataset_profiler
+pip install -r requirements.txt
+
+# Deterministic JSON + self-contained HTML, written next to the input file
+python -m dataset_profiler /path/to/my_dataset.hdf5 --split train --seed 42
+
+# Bounded sampling, explicit metric, k-means over the PCA-2 projection
+python -m dataset_profiler my_dataset.hdf5 \
+ --sample-size 512 --pairwise-sample-size 256 \
+ --metric euclidean --clusters 8 \
+ --out-json my_dataset.profile.json --out-html my_dataset.profile.html
+```
+
+Regenerate the bundled demo end to end with:
+
+```bash
+python examples/make_toy_datasets.py
+```
+
+## AI-assisted interpretation (optional)
+
+The deterministic reports never require network access. The optional AI layer summarizes the
+**aggregate metrics** (never raw vectors) and cites metric keys as evidence. Enable it
+explicitly with `--ai` and configure it entirely through environment variables:
+
+```bash
+export VSAG_AI_API_KEY=... # bearer token; never stored in reports or logs
+export VSAG_AI_MODEL=... # model name (OpenAI-compatible endpoint)
+export VSAG_AI_BASE_URL=... # optional, https only
+python -m dataset_profiler my_dataset.hdf5 --ai
+```
+
+If the provider is unavailable the JSON/HTML reports are still generated and a clear warning
+is printed. AI output is validated: every finding/recommendation must reference metric keys
+that exist in the report, and recommendations are phrased as hypotheses to validate with
+`tools/eval`. See [TECHNICAL_REPORT.md](TECHNICAL_REPORT.md) for the prompt/response schema.
+
+## Tests
+
+```bash
+pip install pytest
+python -m pytest tests -q
+```
+
+## Repository checks
+
+This tool is pure Python and does not affect the C++ library, so `make fmt`/`make lint`/
+`make test` are not impacted by changes here; run the pytest suite above instead.
diff --git a/tools/dataset_profiler/README_zh.md b/tools/dataset_profiler/README_zh.md
new file mode 100644
index 0000000000..c61841097a
--- /dev/null
+++ b/tools/dataset_profiler/README_zh.md
@@ -0,0 +1,66 @@
+# VSAG 数据集特征分析工具(`dataset_profiler`)
+
+一个可复现的数据集特征分析与可视化工具:分析并可视化输入向量数据集的**特征**——各维度数值分布、
+数值统计、向量范数、稀疏度、重复数据、采样向量间距离、异常值、各向异性以及可选的聚类摘要。
+支持 VSAG 评测工具(以及 `pyvsag` 工作流)现有的稠密和稀疏 HDF5 数据格式。
+
+规范文档位于网站文档源码树中。
+
+- 本地中文文档:
+ [docs/docs/zh/src/resources/dataset_profiler.md](../../docs/docs/zh/src/resources/dataset_profiler.md)
+- 本地英文文档:
+ [docs/docs/en/src/resources/dataset_profiler.md](../../docs/docs/en/src/resources/dataset_profiler.md)
+- HDF5 数据格式:
+ [docs/docs/zh/src/resources/dataset_format.md](../../docs/docs/zh/src/resources/dataset_format.md)
+- 中文网站:
+- 英文网站:
+- 技术报告(指标、采样、隐私与 AI 结构说明):[TECHNICAL_REPORT.md](TECHNICAL_REPORT.md)
+- 示例数据与示例报告:[examples/](examples/README.md)
+
+## 快速开始
+
+需要 Python 3.10+,并安装 `numpy` 与 `h5py`(见 [requirements.txt](requirements.txt)),无需 C++
+构建。
+
+```bash
+cd tools/dataset_profiler
+pip install -r requirements.txt
+
+# 生成确定性 JSON 与自包含 HTML(默认写到输入文件旁边)
+python -m dataset_profiler /path/to/my_dataset.hdf5 --split train --seed 42
+
+# 限定采样规模、指定距离度量、并对 PCA-2 投影做 k-means
+python -m dataset_profiler my_dataset.hdf5 \
+ --sample-size 512 --pairwise-sample-size 256 \
+ --metric euclidean --clusters 8 \
+ --out-json my_dataset.profile.json --out-html my_dataset.profile.html
+```
+
+重新生成本目录自带的演示数据与报告:
+
+```bash
+python examples/make_toy_datasets.py
+```
+
+## AI 辅助解读(可选)
+
+确定性报告的生成完全不需要网络。可选的 AI 层只基于**聚合指标**(绝不发送原始向量)生成解读,
+并引用指标字段作为依据。通过 `--ai` 显式启用,并且完全通过环境变量配置:
+
+```bash
+export VSAG_AI_API_KEY=... # 访问令牌;绝不写入报告或日志
+export VSAG_AI_MODEL=... # 模型名(OpenAI 兼容接口)
+export VSAG_AI_BASE_URL=... # 可选,仅允许 https
+python -m dataset_profiler my_dataset.hdf5 --ai
+```
+
+AI 服务不可用时,JSON/HTML 报告仍会正常生成,并在终端给出明确警告。AI 输出会经过校验:每项
+发现/建议必须引用报告中真实存在的指标字段,且建议以“待通过 `tools/eval` 基准验证的假设”的形式
+呈现。提示与响应结构见 [TECHNICAL_REPORT.md](TECHNICAL_REPORT.md)。
+
+## 测试
+
+```bash
+pip install pytest
+python -m pytest tests -q
+```
diff --git a/tools/dataset_profiler/TECHNICAL_REPORT.md b/tools/dataset_profiler/TECHNICAL_REPORT.md
new file mode 100644
index 0000000000..2d37594e49
--- /dev/null
+++ b/tools/dataset_profiler/TECHNICAL_REPORT.md
@@ -0,0 +1,177 @@
+# Dataset Feature Visualization Tool — Technical Report
+
+This report documents the design decisions behind `tools/dataset_profiler`
+(`vsag-dataset-profiler`), implemented for issue #2862: metric selection, the
+sampling design, privacy boundaries, the AI prompt/response schema, the
+validation strategy, and known limitations.
+
+## 1. Metric selection
+
+The profiler computes the metric families requested in issue #2862. Every
+metric carries a `definition`, a `scope` (`full`, `sample`, or `mixed`), and
+the `sample_size` when applicable, so full-dataset facts and estimates are
+never conflated.
+
+| Metric | Scope | Why it matters for index choice |
+| --- | --- | --- |
+| `norms` | full | Norm spread drives quantization range selection (SQ/PQ/FastScan) and the value of normalization or OPQ-style rotation. |
+| `dimensions` (per-coordinate mean/std/min/max/zero-ratio + top lists) | full | Shows scale mismatch between coordinates (a warning sign for Euclidean indexes) and dimensions carrying no signal (candidates for pruning). |
+| `sparsity` | full | Exact-zero ratio for dense files; stored entries, non-zero counts per vector (`nnz`), and density for sparse files. Predicts SINDI-style sparse index behavior and compression headroom. |
+| `invalid_values` | full | NaN/Inf counts per dataset and per affected vector; indexes and quantizers silently degrade on invalid input. |
+| `duplicates` | full (exact) + sample (near) | Exact duplicates are byte-identical vectors (full coverage via hashing). Near-duplicates are a bounded indicator: sampled pairs closer than `epsilon x median(sampled duplicate-detection distance)`, using Euclidean distance for IP inputs to keep the threshold non-negative. |
+| `pairwise_distances` | sample | Distribution shape (median, spread, tails) informs distance-based thresholds and graph vs. IVF trade-offs. |
+| `nearest_neighbor_distances` | sample | Small NN-distance gaps relative to typical distances indicate hard recall problems for graph indexes. |
+| `outliers` | full | Robust modified z-score over norms (median/MAD, IQR fallback) — norm outliers distort shared quantization ranges. |
+| `anisotropy` | sample | Eigenvalue concentration of the centered sample covariance (top-1 share, top-10 share, effective rank, mean pairwise cosine). Highly anisotropic data hurts cosine/IP quality and motivates transformation or different quantization. |
+| `clustering` | sample + optional | Deterministic PCA-2 projection of the sample (scatter plot) and optional seeded k-means summary when `--clusters K` is given. |
+
+Excluded on purpose: ground-truth/kNN recall estimation (requires the VSAG
+C++ library and belongs to `tools/eval`), and query-distribution analysis
+(no queries exist in the dataset files).
+
+## 2. Sampling design
+
+Requirements: reproducible reports, no `O(N^2)` allocation over the full
+dataset, and bounded memory for streaming sparse files.
+
+* **Generator**: `numpy.random.Generator(PCG64(seed))`, seeded explicitly by
+ `--seed` (default 42). No global NumPy state is used or modified.
+* **Index selection**: Floyd's algorithm (`sample_indices`) draws `k` distinct
+ indices from `[0, n)` with memory/time bounded by `k`, not `n` — important
+ for sparse files without `*_offsets`, which are scanned streaming.
+* **Pairwise bound**: a seeded permutation of the uniformly random sample
+ selects `min(pairwise_sample_size, sample_size, N)` rows for the pairwise
+ matrix. Sorting the selected positions afterward restores deterministic row
+ order without biasing the subset toward low dataset indices. The report
+ records requested vs. effective sizes and the exact method string.
+* **Streaming**: dense files are read in row blocks sized from a 128 MiB
+ working-byte budget (at most 65536 rows), so a wide FLOAT32 matrix cannot
+ retain gigabytes per block; sparse files are decoded record-by-record from
+ bounded byte chunks (default 64 MiB).
+ Full-dataset statistics (norms, per-dimension aggregates, duplicates,
+ invalid counts, sparsity) come from a single streaming pass. Norm state is
+ preallocated once (8 bytes per vector), never accumulated as block lists and
+ concatenated. Norm state and sparse nnz state (8 bytes per vector each) have
+ an explicit 256 MiB state budget. Exact-duplicate detection preallocates a
+ 16-byte digest row per vector and uses a peak-aware 64-byte-per-vector budget
+ that also covers `np.unique`'s sorted copy, sort index, unique output, and
+ counts. Each row-count estimate is checked before allocation; when it exceeds
+ the budget, the affected full-split norms/outliers, nnz distribution, or
+ exact-duplicate fields are marked unavailable with a reason and warning,
+ while streaming totals and sampled metrics still appear. Duplicate groups
+ are counted with NumPy over the fixed-width digest rows rather than a Python
+ `Counter`.
+* **Distance computations**: Euclidean pairwise distances use the expanded
+ norm/dot form when it is numerically stable and fall back to direct
+ coordinate differences for pairs whose norms dwarf their distance (for
+ example rows near `1e10` differing by `1`, where the expansion rounds to
+ zero). The sparse Euclidean branch always subtracts coordinates directly.
+ The mean pairwise cosine is derived from normalized-vector sums
+ (`||sum(u)||² - sum(||u||²)`) instead of an `O(M²)` Gram matrix.
+* **Per-dimension arrays**: sparse absent coordinates contribute implicit zeros
+ to per-dimension statistics. Sparse accumulation stops at dimensionality
+ 10,000,000 (other metrics continue) to bound memory. Series longer than
+ `max_dimension_points` (default 1024) are bucket-averaged for storage, with
+ `source_length`, `bucket_size`, and `downsampled` flags recorded so charts
+ stay honest.
+* **Determinism**: two runs with the same file, configuration, sample sizes,
+ and seed produce byte-identical JSON (`sort_keys`, fixed indent,
+ `allow_nan=False`). The committed example reports under `examples/output/`
+ are byte-reproducible. The one environment-dependent piece is SVD sign
+ convention (BLAS-dependent), which only affects projection scatter
+ orientation, not the scalar anisotropy statistics.
+* **Sparse offsets**: results must not change between the `*_offsets`-indexed
+ and scan-only encodings of the same byte stream. Offset validation reads
+ bounded windows. An index larger than 64 MiB is not retained; the reader
+ uses the bounded sequential decoder and cross-checks its decoded record
+ count. Tests assert full-dataset metric equality between the paths and
+ exercise the oversized-index fallback.
+* **Invalid values**: NaN/Inf entries are excluded from numeric summaries and
+ treated as zero in norms, pairwise math, and PCA input (documented per
+ metric). An all-zero vector is a *valid* vector: it contributes to counts,
+ norms (0), and the all-zero-vector counter, but is excluded from outlier
+ detection by the robust method's behavior.
+
+## 3. Privacy boundaries
+
+* **Default offline**: JSON/HTML generation never opens a network connection.
+* **Aggregate-only AI payload**: the request is built from the report —
+ aggregate metrics, sampling config, warnings, and non-sensitive metadata
+ (split name, vector type, dtype, count/dim, distance). A field allowlist
+ drops per-vector entries (`clustering.projection_points`, which holds one
+ PCA coordinate pair per sampled vector, and `clustering.labels`, which holds
+ one cluster assignment per sampled vector). Raw vectors are not in the
+ report and therefore structurally cannot be sent; tests assert that source
+ rows never appear in the request body and that those keys are absent.
+* **Credentials**: the API key is read from the environment (`VSAG_AI_API_KEY`,
+ fallback `OPENAI_API_KEY`, or a custom variable via `VSAG_AI_KEY_ENV`),
+ sent only in the `Authorization` header over https (plaintext URLs are
+ rejected), and never written to reports. Redirects are rejected so the
+ bearer token cannot be forwarded to another endpoint. Error messages are
+ redacted (`sk-…` patterns and bearer tokens) and truncated; tests assert keys
+ cannot leak through provider failures.
+* **Self-contained HTML**: all CSS/JS/SVG are inlined; opening a report
+ contacts no external service.
+
+## 4. AI prompt/response schema
+
+The adapter is provider-neutral (any OpenAI-compatible `/chat/completions`
+endpoint) and temperature is fixed at 0.
+
+Request: the system-style instructions plus
+`{summary, findings[], recommendations[], limitations[], additional_measurements[]}`, where every
+finding/recommendation is `{title, detail, evidence: [metric key], confidence: low|medium|high}`
+and every recommendation additionally carries `validation` (how to test it in a reproducible
+`tools/eval` run). The prompt embeds the list of allowed evidence keys (leaf paths enumerated
+from `metrics.*`) and the aggregate JSON payload.
+
+Validation (client side, `parse_and_validate_response`):
+
+* response must parse as a single JSON object (markdown fences tolerated);
+* `summary` non-empty string; `findings`/`recommendations` non-empty lists (<= 10 items);
+* every item needs non-empty `evidence`, and **each cited key must resolve inside the report's
+ `metrics` subtree** — invented keys fail the whole response;
+* recommendations must carry a `validation` string;
+* `confidence` restricted to `low|medium|high`; strings are length-bounded.
+
+Any rejection degrades to `ai_analysis.status = "error"` with a redacted
+message: deterministic reports are still produced and a warning is printed
+(issue acceptance criteria 6 and 7).
+
+## 5. Validation strategy
+
+* **Unit tests** (`tests/`): sampling determinism; stats helpers; both HDF5
+ layouts incl. corrupt/edge encodings; all profiler metric families incl.
+ determinism, sample bounds, metric aliasing, invalid values, and the
+ offsets/scan equivalence invariant; HTML self-containment and escaping; AI
+ schema validation and failure modes; CLI exit codes and output handling.
+* **Integration**: the CLI is exercised end to end against generated dense and
+ sparse fixtures; `make_toy_datasets.py` reproduces the committed example
+ reports byte-for-byte.
+* **Manual spot checks**: rendered charts were opened locally and cross-checked
+ against direct NumPy computations on the toy datasets (counts, zero ratios,
+ duplicate groups).
+
+## 6. Limitations
+
+* Sampled metrics (pairwise, NN distances, anisotropy, clustering) are
+ estimates: their report entries carry `scope: sample` and the sample size,
+ and the HTML repeats this per section.
+* Near-duplicate detection is a bounded *indicator* over the sample, not a
+ full-dataset guarantee.
+* The duplicate hash includes invalid bytes as-is (byte equality, not numeric
+ equality). Dense one-byte unsigned input is numerically converted by HDF5 to
+ signed `INT8` before hashing and numeric statistics, matching the evaluator's
+ `ALPHA_I8` reads; values above 127 saturate at 127 rather than wrapping.
+* Full norm quantiles/outliers, sparse nnz distributions, and exact duplicate
+ counts are unavailable when their estimated per-row state exceeds the 256 MiB
+ per-structure budget; the report marks these fields unavailable rather than
+ returning partial results.
+* k-means is an O(n·k) Lloyd loop over the 2-D projection; it is intended for
+ structure hints, not production clustering.
+* Sparse dimensionality is inferred as `max(id) + 1`; a file with id gaps at
+ the top reports a smaller dim than its producer may have assumed (this is
+ what the evaluation loader implies as well).
+* The AI layer follows the repository's issue #2862 boundary: it explains
+ measured metrics and does not inspect raw data, and its suggestions are
+ explicitly hypotheses to validate with `tools/eval` benchmarks.
diff --git a/tools/dataset_profiler/dataset_profiler/__init__.py b/tools/dataset_profiler/dataset_profiler/__init__.py
new file mode 100644
index 0000000000..b0d7e729ff
--- /dev/null
+++ b/tools/dataset_profiler/dataset_profiler/__init__.py
@@ -0,0 +1,30 @@
+# Copyright 2024-present the vsag project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Deterministic profiling and visualization for VSAG-compatible HDF5 datasets.
+
+The public entry point is :func:`profile_dataset` in :mod:`dataset_profiler.profiler`;
+the command-line wrapper lives in :mod:`dataset_profiler.cli`.
+"""
+
+from __future__ import annotations
+
+TOOL_NAME = "vsag-dataset-profiler"
+__version__ = "0.1.0"
+SCHEMA_VERSION = "1.0"
+
+from .profiler import profile_dataset # noqa: E402 (after constants: report.py imports them back)
+from .report import build_report # noqa: E402
+
+__all__ = ["SCHEMA_VERSION", "TOOL_NAME", "__version__", "build_report", "profile_dataset"]
diff --git a/tools/dataset_profiler/dataset_profiler/__main__.py b/tools/dataset_profiler/dataset_profiler/__main__.py
new file mode 100644
index 0000000000..f52fc90a78
--- /dev/null
+++ b/tools/dataset_profiler/dataset_profiler/__main__.py
@@ -0,0 +1,24 @@
+# Copyright 2024-present the vsag project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Enable ``python -m dataset_profiler``."""
+
+from __future__ import annotations
+
+import sys
+
+from .cli import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/dataset_profiler/dataset_profiler/ai.py b/tools/dataset_profiler/dataset_profiler/ai.py
new file mode 100644
index 0000000000..b6e67965f3
--- /dev/null
+++ b/tools/dataset_profiler/dataset_profiler/ai.py
@@ -0,0 +1,631 @@
+# Copyright 2024-present the vsag project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Provider-neutral AI-assisted interpretation of a dataset profile.
+
+The adapter accepts the *structured, aggregated* profile report produced by
+:func:`dataset_profiler.profiler.profile_dataset` and asks a language model to
+explain it. Raw vectors are never part of the request: the payload contains
+only aggregate metrics, sampling configuration, and bounded non-sensitive
+metadata.
+
+Configuration is explicit and environment-based so that credentials never end
+up in generated reports, command output, source code, or logs:
+
+* ``VSAG_AI_API_KEY`` (fallback: ``OPENAI_API_KEY``) -- bearer token.
+* ``VSAG_AI_MODEL`` -- model name required when AI is enabled.
+* ``VSAG_AI_BASE_URL`` (fallback: ``OPENAI_BASE_URL``) -- OpenAI-compatible
+ endpoint root, defaulting to ``https://api.openai.com/v1``.
+* ``VSAG_AI_KEY_ENV`` -- name of the environment variable holding the key
+ (used when a caller wants a custom variable).
+
+Every finding and recommendation returned by the model must reference metric
+keys that exist in the report; responses that do not parse, omit evidence, or
+invent evidence keys are rejected. Provider failures never prevent the
+deterministic reports from being written: :func:`analyze_report` degrades to a
+``{"status": "error", ...}`` entry with a redacted message.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import urllib.error
+import urllib.request
+from typing import Any, Dict, List, Optional, Sequence
+
+AI_SCHEMA_VERSION = "1.0"
+DEFAULT_BASE_URL = "https://api.openai.com/v1"
+DEFAULT_TIMEOUT_SECONDS = 60.0
+MAX_LIST_ITEMS = 10
+MAX_TEXT_LENGTH = 2000
+VALID_CONFIDENCE = ("low", "medium", "high")
+
+_ERROR_REDACTIONS = (
+ re.compile(r"sk-[A-Za-z0-9_\-]{8,}"),
+ re.compile(r"Bearer\s+[A-Za-z0-9._\-]{8,}"),
+)
+
+# Field allowlists for the aggregate AI payload. Anything not listed here is
+# never forwarded to the provider; arbitrary HDF5 object names and per-vector
+# fields such as ``clustering.projection_points`` and ``clustering.labels`` are
+# excluded.
+_DATASET_METADATA_FIELDS = (
+ "vector_type",
+ "dtype",
+ "count",
+ "dim",
+ "dim_inferred",
+ "distance_metric",
+ "has_offsets",
+)
+_AGGREGATE_METRIC_FIELDS: Dict[str, tuple] = {
+ "invalid_values": (
+ "definition",
+ "scope",
+ "sample_size",
+ "count",
+ "total_values",
+ "invalid_values",
+ "vectors_with_invalid",
+ "invalid_ratio",
+ "vectors_with_invalid_ratio",
+ ),
+ "norms": (
+ "definition",
+ "scope",
+ "sample_size",
+ "available",
+ "reason",
+ "count",
+ "stats",
+ "histogram",
+ "non_finite_norms",
+ ),
+ "outliers": (
+ "definition",
+ "scope",
+ "sample_size",
+ "available",
+ "reason",
+ "count",
+ "outlier_count",
+ "outlier_ratio",
+ "median_norm",
+ "mad_norm",
+ "threshold",
+ "max_over_median_norm",
+ "method",
+ ),
+ "dimensions": (
+ "definition",
+ "scope",
+ "sample_size",
+ "dim",
+ "summary",
+ "top_variance_dimensions",
+ "top_zero_dimensions",
+ "available",
+ "reason",
+ ),
+ "sparsity": (
+ "definition",
+ "scope",
+ "sample_size",
+ "representation",
+ "count",
+ "dim",
+ "cells",
+ "total_values",
+ "zero_values",
+ "non_zero_values",
+ "stored_entries",
+ "explicit_zero_entries",
+ "zero_ratio",
+ "density",
+ "nnz_per_vector",
+ "nnz_histogram",
+ "nnz_available",
+ "nnz_reason",
+ "all_zero_vectors",
+ "all_zero_vector_ratio",
+ ),
+ "duplicates": (
+ "definition",
+ "scope",
+ "sample_size",
+ "exact",
+ "near_duplicate",
+ "scope_note",
+ ),
+ "pairwise_distances": (
+ "definition",
+ "scope",
+ "sample_size",
+ "metric",
+ "pair_count",
+ "requested_pairwise_sample_size",
+ "stats",
+ "histogram",
+ "matrix_is_bounded",
+ "dense_sample_materialized",
+ "sparse_sparse_distance_loops",
+ ),
+ "nearest_neighbor_distances": (
+ "definition",
+ "scope",
+ "sample_size",
+ "available",
+ "reason",
+ "stats",
+ "histogram",
+ ),
+ "anisotropy": (
+ "definition",
+ "scope",
+ "sample_size",
+ "available",
+ "reason",
+ "method",
+ "top1_eigenvalue_share",
+ "top10_eigenvalue_share",
+ "effective_rank",
+ "mean_pairwise_cosine",
+ "explained_variance_ratio",
+ "interpretation",
+ ),
+ "clustering": (
+ "definition",
+ "scope",
+ "sample_size",
+ "available",
+ "reason",
+ "method",
+ "explained_variance_ratio_pc1",
+ "explained_variance_ratio_pc2",
+ "clustering_available",
+ "n_clusters",
+ "cluster_sizes",
+ "inertia",
+ "iterations",
+ ),
+}
+
+
+class AIAnalysisError(RuntimeError):
+ """Raised for configuration, transport, or response-validation failures."""
+
+
+class _RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
+ """Reject redirects so bearer credentials cannot reach another endpoint."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ raise AIAnalysisError(
+ "AI provider redirects are disabled; refusing HTTP %s redirect" % code
+ )
+
+
+# ---------------------------------------------------------------------------
+# Prompt construction
+# ---------------------------------------------------------------------------
+_SYSTEM_INSTRUCTIONS = (
+ "You are interpreting a deterministic statistical profile of a vector "
+ "dataset produced by the VSAG dataset profiler. You did NOT receive raw "
+ "vectors; you receive only aggregate metrics. Respond with a single JSON "
+ "object and nothing else, using exactly this schema:\n"
+ "{\n"
+ ' "summary": "",\n'
+ ' "findings": [{"title": str, "detail": str, "evidence": [metric_key],\n'
+ ' "confidence": "low"|"medium"|"high"}],\n'
+ ' "recommendations": [{"title": str, "detail": str, "evidence": [metric_key],\n'
+ ' "validation": "",\n'
+ ' "confidence": "low"|"medium"|"high"}],\n'
+ ' "limitations": [str],\n'
+ " \"additional_measurements\": [str]\n"
+ "}\n"
+ "Rules:\n"
+ "1. Every finding and recommendation must cite one or more evidence keys "
+ "from the provided list; never cite keys that are not listed.\n"
+ "2. Distinguish full-dataset metrics from sampled metrics; sampled "
+ "conclusions are hypotheses about the sample.\n"
+ "3. Recommendations must be phrased as hypotheses to validate with "
+ "reproducible VSAG benchmarks, never as guaranteed optimal "
+ "configurations.\n"
+ "4. Report uncertainty honestly in limitations and additional_measurements."
+)
+
+
+def evidence_metric_keys(report: Dict[str, Any]) -> List[str]:
+ """Return dotted ``metrics.*`` keys that evidence references may use."""
+ from .stats import metric_keys
+
+ return metric_keys(report)
+
+
+def _resolve_evidence_key(report: Dict[str, Any], key: str) -> bool:
+ """Return True when ``key`` is a ``metrics.*`` path present in the report."""
+ if not key.startswith("metrics."):
+ return False
+ node: Any = report.get("metrics", {})
+ for part in key.split(".")[1:]:
+ if isinstance(node, dict) and part in node:
+ node = node[part]
+ else:
+ return False
+ return True
+
+
+def aggregate_payload(report: Dict[str, Any]) -> Dict[str, Any]:
+ """Build the bounded, aggregate-only request payload from a report.
+
+ Only aggregate metrics, sampling configuration, warnings, and non-sensitive
+ metadata are included. The full file path and any per-vector raw content
+ are never sent.
+ """
+ dataset = report.get("dataset") or {}
+ return {
+ "schema_version": report.get("schema_version"),
+ "tool": report.get("tool"),
+ "split": report.get("input", {}).get("split"),
+ "dataset": {
+ key: dataset[key] for key in _DATASET_METADATA_FIELDS if key in dataset
+ },
+ "config": report.get("config"),
+ "metrics": _aggregate_metrics(report.get("metrics") or {}),
+ "warnings": report.get("warnings"),
+ }
+
+
+def _aggregate_metrics(metrics: Dict[str, Any]) -> Dict[str, Any]:
+ """Filter a metrics tree down to the aggregate allowlist.
+
+ Per-vector payloads are excluded on purpose: ``clustering.projection_points``
+ holds one PCA coordinate pair per sampled vector and ``clustering.labels``
+ holds one cluster assignment per sampled vector, which together with the
+ accompanying summaries can expose individual rows.
+ """
+ payload: Dict[str, Any] = {}
+ for name, metric in metrics.items():
+ allowed = _AGGREGATE_METRIC_FIELDS.get(name)
+ if allowed is None or not isinstance(metric, dict):
+ continue
+ payload[name] = {key: metric[key] for key in allowed if key in metric}
+ return payload
+
+
+def build_prompt(report: Dict[str, Any]) -> str:
+ """Render the user prompt sent to the provider (JSON payload + evidence keys)."""
+ payload = aggregate_payload(report)
+ keys = evidence_metric_keys(report)
+ parts = [
+ _SYSTEM_INSTRUCTIONS,
+ "",
+ "Allowed evidence metric keys:",
+ json.dumps(keys, sort_keys=True),
+ "",
+ "Aggregate profile payload:",
+ json.dumps(payload, sort_keys=True, allow_nan=False),
+ ]
+ return "\n".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# Response parsing / validation
+# ---------------------------------------------------------------------------
+_FENCE_RE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$", re.MULTILINE)
+
+
+def _extract_json_object(text: str) -> Dict[str, Any]:
+ if not isinstance(text, str) or not text.strip():
+ raise AIAnalysisError("empty AI response")
+ cleaned = _FENCE_RE.sub("", text.strip())
+ try:
+ parsed = json.loads(cleaned)
+ except json.JSONDecodeError:
+ start = cleaned.find("{")
+ end = cleaned.rfind("}")
+ if start == -1 or end <= start:
+ raise AIAnalysisError("AI response is not a JSON object")
+ try:
+ parsed = json.loads(cleaned[start : end + 1])
+ except json.JSONDecodeError as exc:
+ raise AIAnalysisError("AI response is not valid JSON: %s" % exc)
+ if not isinstance(parsed, dict):
+ raise AIAnalysisError("AI response JSON must be an object")
+ return parsed
+
+
+def _clean_text(value: Any, field: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise AIAnalysisError("AI response field %r must be a non-empty string" % field)
+ return value.strip()[:MAX_TEXT_LENGTH]
+
+
+def _clean_item(
+ item: Any, report: Dict[str, Any], field: str, index: int, require_validation: bool
+) -> Dict[str, Any]:
+ where = "%s[%d]" % (field, index)
+ if not isinstance(item, dict):
+ raise AIAnalysisError("AI response %s must be an object" % where)
+ entry: Dict[str, Any] = {
+ "title": _clean_text(item.get("title"), where + ".title"),
+ "detail": _clean_text(item.get("detail"), where + ".detail"),
+ }
+ evidence = item.get("evidence")
+ if not isinstance(evidence, list) or not evidence:
+ raise AIAnalysisError("AI response %s must cite non-empty evidence" % where)
+ keys: List[str] = []
+ for key in evidence:
+ if not isinstance(key, str) or not _resolve_evidence_key(report, key):
+ raise AIAnalysisError(
+ "AI response %s cites unknown evidence key %r" % (where, key)
+ )
+ if key not in keys:
+ keys.append(key)
+ entry["evidence"] = keys
+ confidence = item.get("confidence", "medium")
+ if confidence not in VALID_CONFIDENCE:
+ raise AIAnalysisError(
+ "AI response %s.confidence must be one of %s"
+ % (where, ", ".join(VALID_CONFIDENCE))
+ )
+ entry["confidence"] = confidence
+ if require_validation:
+ entry["validation"] = _clean_text(
+ item.get("validation"), where + ".validation"
+ )
+ return entry
+
+
+def _clean_string_list(value: Any, field: str) -> List[str]:
+ if value is None:
+ return []
+ if not isinstance(value, list) or len(value) > MAX_LIST_ITEMS:
+ raise AIAnalysisError("AI response %r must be a list of at most %d items" % (field, MAX_LIST_ITEMS))
+ return [_clean_text(item, "%s item" % field) for item in value]
+
+
+def parse_and_validate_response(text: str, report: Dict[str, Any]) -> Dict[str, Any]:
+ """Parse a raw provider response into the validated ``ai_analysis`` block.
+
+ Raises :class:`AIAnalysisError` when the response is malformed, cites
+ evidence keys that do not exist in the report, or omits required fields.
+ """
+ parsed = _extract_json_object(text)
+ result: Dict[str, Any] = {"summary": _clean_text(parsed.get("summary"), "summary")}
+
+ for field, require_validation in (("findings", False), ("recommendations", True)):
+ items = parsed.get(field)
+ if not isinstance(items, list) or not items:
+ raise AIAnalysisError("AI response %r must be a non-empty list" % field)
+ if len(items) > MAX_LIST_ITEMS:
+ raise AIAnalysisError("AI response %r must have at most %d items" % (field, MAX_LIST_ITEMS))
+ result[field] = [
+ _clean_item(item, report, field, index, require_validation)
+ for index, item in enumerate(items)
+ ]
+ result["limitations"] = _clean_string_list(parsed.get("limitations"), "limitations")
+ result["additional_measurements"] = _clean_string_list(
+ parsed.get("additional_measurements"), "additional_measurements"
+ )
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Providers
+# ---------------------------------------------------------------------------
+class MockProvider:
+ """Deterministic provider that replays a canned response (tests/demos).
+
+ ``secrets`` implements the same public ``redaction_secrets`` provider
+ contract as network-backed providers, allowing failure-path tests to verify
+ redaction without relying on private implementation attributes.
+ """
+
+ name = "mock"
+
+ def __init__(
+ self,
+ response: str,
+ model: str = "mock-model",
+ secrets: str | Sequence[str] = (),
+ ) -> None:
+ self._response = response
+ self.model = model
+ self.redaction_secrets = (
+ (secrets,) if isinstance(secrets, str) else tuple(secrets)
+ )
+ self.last_prompt: Optional[str] = None
+
+ def complete(self, prompt: str) -> str:
+ self.last_prompt = prompt
+ return self._response
+
+
+class OpenAICompatibleProvider:
+ """Calls an OpenAI-compatible ``/chat/completions`` endpoint over HTTPS."""
+
+ name = "openai-compatible"
+
+ def __init__(
+ self,
+ api_key: str,
+ model: str,
+ base_url: str = DEFAULT_BASE_URL,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ ) -> None:
+ if not api_key:
+ raise AIAnalysisError("AI analysis enabled but no API key was found in the environment")
+ if any(ord(character) < 33 or ord(character) > 126 for character in api_key):
+ # HTTP authentication headers require visible ASCII. Reject an
+ # accidentally retained newline before urllib can include the full
+ # Authorization value in a transport exception.
+ raise AIAnalysisError(
+ "AI API key must contain only visible ASCII characters; remove "
+ "any accidental whitespace or control characters"
+ )
+ if not model:
+ raise AIAnalysisError("AI analysis enabled but no model was configured (set VSAG_AI_MODEL or pass --ai-model)")
+ if not base_url.startswith("https://"):
+ raise AIAnalysisError(
+ "AI base URL must use https so the API key is never sent in the clear"
+ )
+ self._api_key = api_key
+ # Public provider contract: analyze_report redacts these literal values
+ # from provider exceptions without inspecting private attributes.
+ self.redaction_secrets = (api_key,)
+ self.model = model
+ self.base_url = base_url.rstrip("/")
+ self.timeout = float(timeout)
+ self._opener = urllib.request.build_opener(_RejectRedirectHandler())
+
+ def complete(self, prompt: str) -> str:
+ request = urllib.request.Request(
+ self.base_url + "/chat/completions",
+ data=json.dumps(
+ {
+ "model": self.model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": 0,
+ },
+ sort_keys=True,
+ ).encode("utf-8"),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + self._api_key,
+ },
+ method="POST",
+ )
+ try:
+ with self._opener.open(request, timeout=self.timeout) as response:
+ body = json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ raise AIAnalysisError(
+ "AI provider returned HTTP %s for %s" % (exc.code, self.base_url)
+ )
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ raise AIAnalysisError("AI provider is unreachable: %s" % type(exc).__name__)
+ except json.JSONDecodeError:
+ raise AIAnalysisError("AI provider returned a non-JSON response body")
+ try:
+ return str(body["choices"][0]["message"]["content"])
+ except (KeyError, IndexError, TypeError) as exc:
+ raise AIAnalysisError("AI provider response is missing choices[0].message.content")
+
+
+def provider_from_env(
+ env: Optional[Dict[str, str]] = None,
+ model: Optional[str] = None,
+ base_url: Optional[str] = None,
+ key_env: Optional[str] = None,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+) -> OpenAICompatibleProvider:
+ """Build a provider from the environment; credentials are never persisted."""
+ env = os.environ if env is None else env
+ key_name = key_env or env.get("VSAG_AI_KEY_ENV") or "VSAG_AI_API_KEY"
+ api_key = env[key_name] if key_name in env else env.get("OPENAI_API_KEY") or ""
+ resolved_model = model or env.get("VSAG_AI_MODEL") or ""
+ resolved_base = base_url or env.get("VSAG_AI_BASE_URL") or env.get("OPENAI_BASE_URL") or DEFAULT_BASE_URL
+ return OpenAICompatibleProvider(
+ api_key=api_key,
+ model=resolved_model,
+ base_url=resolved_base,
+ timeout=timeout,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+def _redact(message: str, secrets: Sequence[str] = ()) -> str:
+ # Provider exceptions can contain either the literal header value or its
+ # repr/JSON-escaped form. Redact the configured credential itself instead
+ # of relying on a provider-specific token prefix or character set.
+ variants = set()
+ for secret in secrets:
+ if not secret:
+ continue
+ candidates = (
+ secret,
+ secret.strip(),
+ json.dumps(secret, ensure_ascii=True)[1:-1],
+ repr(secret)[1:-1],
+ secret.encode("unicode_escape").decode("ascii"),
+ )
+ variants.update(candidate for candidate in candidates if candidate)
+ for secret in sorted(variants, key=len, reverse=True):
+ message = message.replace(secret, "[redacted]")
+ for pattern in _ERROR_REDACTIONS:
+ message = pattern.sub("[redacted]", message)
+ return message[:500]
+
+
+def analyze_report(
+ report: Dict[str, Any],
+ provider: Optional[Any] = None,
+ env: Optional[Dict[str, str]] = None,
+ **provider_kwargs: Any
+) -> Dict[str, Any]:
+ """Produce the ``ai_analysis`` block for ``report``.
+
+ Never raises: configuration, transport, and validation failures are folded
+ into a ``{"status": "error"}`` block so the deterministic JSON and HTML
+ reports are still written. Providers document literal credentials for error
+ redaction through a public ``redaction_secrets`` sequence attribute.
+ """
+ if provider is None:
+ try:
+ provider = provider_from_env(env=env, **provider_kwargs)
+ except AIAnalysisError as exc:
+ return {"status": "error", "provider": None, "error": _redact(str(exc))}
+ name = getattr(provider, "name", type(provider).__name__)
+ model = getattr(provider, "model", None)
+ configured_secrets = getattr(provider, "redaction_secrets", ())
+ if isinstance(configured_secrets, str):
+ configured_secrets = (configured_secrets,)
+ secrets = (
+ [secret for secret in configured_secrets if isinstance(secret, str)]
+ if isinstance(configured_secrets, Sequence)
+ else []
+ )
+ try:
+ raw = provider.complete(build_prompt(report))
+ block = parse_and_validate_response(raw, report)
+ except AIAnalysisError as exc:
+ return {
+ "status": "error",
+ "provider": name,
+ "model": model,
+ "error": _redact(str(exc), secrets),
+ }
+ except Exception as exc: # defensive: providers may raise transport errors
+ return {
+ "status": "error",
+ "provider": name,
+ "model": model,
+ "error": _redact(
+ "unexpected AI failure: %s: %s" % (type(exc).__name__, exc),
+ secrets,
+ ),
+ }
+ block.update(
+ {
+ "status": "ok",
+ "provider": name,
+ "model": model,
+ "ai_schema_version": AI_SCHEMA_VERSION,
+ }
+ )
+ return block
diff --git a/tools/dataset_profiler/dataset_profiler/cli.py b/tools/dataset_profiler/dataset_profiler/cli.py
new file mode 100644
index 0000000000..425327258d
--- /dev/null
+++ b/tools/dataset_profiler/dataset_profiler/cli.py
@@ -0,0 +1,208 @@
+# Copyright 2024-present the vsag project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Command-line interface for the VSAG dataset profiler.
+
+The deterministic JSON and HTML reports are always produced offline. AI
+interpretation is opt-in through ``--ai`` (or ``VSAG_DATASET_PROFILER_AI=1``)
+and configured exclusively through environment variables so API keys never
+appear in arguments, reports, or logs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from . import __version__
+from .ai import analyze_report
+from .hdf5 import DEFAULT_CHUNK_BYTES, DatasetFormatError
+from .html_report import render_html
+from .profiler import VALID_METRICS, profile_dataset
+from .stats import dump_json
+
+_TRUE_VALUES = ("1", "true", "yes", "on")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="dataset_profiler",
+ description=(
+ "Profile and visualize the feature characteristics of a "
+ "VSAG-compatible HDF5 dataset (dense or sparse)."
+ ),
+ )
+ parser.add_argument("input", help="path to the .hdf5 dataset file")
+ parser.add_argument("--split", default="train", help="dataset split to profile (default: train)")
+ parser.add_argument("--seed", type=int, default=42, help="explicit random seed (default: 42)")
+ parser.add_argument(
+ "--sample-size",
+ type=int,
+ default=1024,
+ help="max vectors drawn for sampled metrics (default: 1024)",
+ )
+ parser.add_argument(
+ "--pairwise-sample-size",
+ type=int,
+ default=512,
+ help="max vectors used for the bounded pairwise-distance matrix (default: 512)",
+ )
+ parser.add_argument(
+ "--metric",
+ choices=VALID_METRICS,
+ default=None,
+ help="distance metric for pairwise statistics (default: file 'distance' attribute)",
+ )
+ parser.add_argument(
+ "--clusters",
+ type=int,
+ default=0,
+ help="optional k-means cluster count on the PCA-2 projection (0 disables)",
+ )
+ parser.add_argument(
+ "--near-duplicate-epsilon",
+ type=float,
+ default=1e-3,
+ help="relative pairwise-distance threshold for near-duplicate indicators (default: 1e-3)",
+ )
+ parser.add_argument(
+ "--max-chunk-bytes",
+ type=int,
+ default=DEFAULT_CHUNK_BYTES,
+ help="streaming read chunk size for sparse records (default: 64 MiB)",
+ )
+ parser.add_argument(
+ "--max-dimension-points",
+ type=int,
+ default=1024,
+ help="max per-dimension points stored per series (default: 1024)",
+ )
+ parser.add_argument(
+ "--out-json",
+ metavar="PATH",
+ default=None,
+ help="write the JSON report here; pass '-' to print to stdout (default: .profile.json)",
+ )
+ parser.add_argument(
+ "--out-html",
+ metavar="PATH",
+ default=None,
+ help=(
+ "write the self-contained HTML report here (default: .profile.html; "
+ "omitted when --out-json is given without --out-html)"
+ ),
+ )
+ parser.add_argument(
+ "--ai",
+ action="store_true",
+ help="enable optional AI-assisted interpretation (also enabled by VSAG_DATASET_PROFILER_AI=1)",
+ )
+ parser.add_argument("--ai-model", default=None, help="AI model name (default: $VSAG_AI_MODEL)")
+ parser.add_argument(
+ "--ai-base-url", default=None, help="OpenAI-compatible base URL (default: $VSAG_AI_BASE_URL)"
+ )
+ parser.add_argument(
+ "--ai-key-env",
+ default=None,
+ help="name of the environment variable holding the API key (default: $VSAG_AI_KEY_ENV or VSAG_AI_API_KEY)",
+ )
+ parser.add_argument(
+ "--ai-timeout", type=float, default=60.0, help="AI request timeout in seconds (default: 60)"
+ )
+ parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
+ return parser
+
+
+def _ai_requested(args: argparse.Namespace, env: Dict[str, str]) -> bool:
+ if args.ai:
+ return True
+ return env.get("VSAG_DATASET_PROFILER_AI", "").strip().lower() in _TRUE_VALUES
+
+
+def _default_output_path(input_path: str, suffix: str) -> Path:
+ return Path(input_path).with_name(Path(input_path).stem + ".profile." + suffix)
+
+
+def _write_text(path: Path, text: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+
+
+def main(argv: Optional[List[str]] = None, env: Optional[Dict[str, str]] = None) -> int:
+ env = os.environ if env is None else env
+ args = build_parser().parse_args(argv)
+
+ try:
+ report = profile_dataset(
+ args.input,
+ split=args.split,
+ seed=args.seed,
+ sample_size=args.sample_size,
+ pairwise_sample_size=args.pairwise_sample_size,
+ metric=args.metric,
+ clusters=args.clusters,
+ near_duplicate_relative_epsilon=args.near_duplicate_epsilon,
+ max_chunk_bytes=args.max_chunk_bytes,
+ max_dimension_points=args.max_dimension_points,
+ )
+ except (DatasetFormatError, ValueError, OSError) as exc:
+ print("error: %s" % exc, file=sys.stderr)
+ return 1
+
+ if _ai_requested(args, env):
+ analysis = analyze_report(
+ report,
+ model=args.ai_model,
+ base_url=args.ai_base_url,
+ key_env=args.ai_key_env,
+ timeout=args.ai_timeout,
+ env=env,
+ )
+ report["ai_analysis"] = analysis
+ if analysis.get("status") != "ok":
+ print(
+ "warning: AI analysis failed (%s); the deterministic JSON and HTML "
+ "reports were still generated" % analysis.get("error", "unknown error"),
+ file=sys.stderr,
+ )
+
+ for warning in report.get("warnings", []):
+ print("warning: %s" % warning, file=sys.stderr)
+
+ json_text = dump_json(report)
+ if args.out_json == "-":
+ sys.stdout.write(json_text)
+ else:
+ json_path = Path(args.out_json) if args.out_json else _default_output_path(args.input, "json")
+ _write_text(json_path, json_text)
+ print("wrote %s" % json_path, file=sys.stderr)
+
+ if args.out_html:
+ html_path = Path(args.out_html)
+ elif args.out_json is None:
+ html_path = _default_output_path(args.input, "html")
+ else:
+ html_path = None
+ if html_path is not None:
+ _write_text(html_path, render_html(report))
+ print("wrote %s" % html_path, file=sys.stderr)
+
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ sys.exit(main())
diff --git a/tools/dataset_profiler/dataset_profiler/hdf5.py b/tools/dataset_profiler/dataset_profiler/hdf5.py
new file mode 100644
index 0000000000..9c022772a9
--- /dev/null
+++ b/tools/dataset_profiler/dataset_profiler/hdf5.py
@@ -0,0 +1,490 @@
+# Copyright 2024-present the vsag project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Readers for the HDF5 layouts consumed by VSAG's evaluation tooling.
+
+Two layouts are supported, matching ``docs/docs/en/src/resources/dataset_format.md``:
+
+* **dense** (``type`` missing or ``"dense"``): ``/`` is an ``(N, D)``
+ matrix of ``INT8`` or ``FLOAT32`` values. One-byte unsigned integers are
+ numerically converted to ``INT8`` by HDF5 to match the evaluator's
+ ``ALPHA_I8`` reads (out-of-range values saturate at 127).
+* **sparse** (``type="sparse"``): ``/`` is a flat ``INT8`` byte stream.
+ Each record is ``uint32 len | uint32 ids[len] | float32 vals[len]`` in
+ little-endian order. ``/_offsets`` is an optional ``UINT64`` array of
+ record byte offsets with a trailing sentinel.
+
+The reader deliberately streams data in bounded chunks. Callers never need to
+materialize a complete dataset in memory.
+"""
+
+from __future__ import annotations
+
+import struct
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, Iterator, List, Optional, Sequence, Tuple
+
+import numpy as np
+
+DEFAULT_CHUNK_BYTES = 64 * 1024 * 1024
+_OFFSET_INDEX_BUDGET_BYTES = DEFAULT_CHUNK_BYTES
+_OFFSET_VALIDATION_ROWS = 1024 * 1024
+_LEN_STRUCT = struct.Struct(" Optional[str]:
+ """Read a scalar string attribute from an HDF5 object."""
+ if name not in h5obj.attrs:
+ return None
+ value = h5obj.attrs[name]
+ if isinstance(value, np.ndarray):
+ if value.size != 1:
+ raise DatasetFormatError(
+ "attribute %r is not a scalar string (shape=%s)" % (name, value.shape)
+ )
+ value = value.item()
+ elif isinstance(value, np.generic):
+ value = value.item()
+ if isinstance(value, bytes):
+ return value.decode("utf-8")
+ return str(value)
+
+
+class HDF5Dataset:
+ """Streaming reader for one split of a VSAG-compatible HDF5 dataset."""
+
+ def __init__(self, path: str, split: str = "train") -> None:
+ import h5py
+
+ self.path = Path(path)
+ if not self.path.is_file():
+ raise DatasetFormatError("input file does not exist: %s" % self.path)
+ self.split = split
+ self._file = h5py.File(str(self.path), "r")
+ self._dataset = None
+ self._offsets = None
+ self._metadata: Optional[DatasetMetadata] = None
+ try:
+ self._metadata = self._read_metadata()
+ except Exception:
+ self._file.close()
+ raise
+
+ # -- lifecycle -----------------------------------------------------
+ def __enter__(self) -> "HDF5Dataset":
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ self.close()
+
+ def close(self) -> None:
+ if self._file is not None:
+ self._file.close()
+ self._file = None
+
+ @property
+ def metadata(self) -> DatasetMetadata:
+ assert self._metadata is not None
+ return self._metadata
+
+ # -- metadata ------------------------------------------------------
+ def _read_metadata(self) -> DatasetMetadata:
+ f = self._file
+ vector_type = read_str_attr(f, "type") or "dense"
+ vector_type = vector_type.strip().lower()
+ if vector_type == "multi_vector":
+ raise DatasetFormatError(
+ "multi_vector datasets are not supported by the dataset profiler; "
+ "profile the underlying flat sub-vector matrix or a dense/sparse export"
+ )
+ if vector_type not in ("dense", "sparse"):
+ raise DatasetFormatError(
+ "unsupported HDF5 dataset type %r (expected 'dense' or 'sparse')"
+ % vector_type
+ )
+ if self.split not in f:
+ raise DatasetFormatError(
+ "missing required HDF5 dataset '/%s'" % self.split
+ )
+
+ dset = f[self.split]
+ dtype = _describe_dtype(dset.dtype)
+ attributes = {}
+ for key in ("type", "distance", "multi_vector_dim"):
+ value = read_str_attr(f, key)
+ if value is not None:
+ attributes[key] = value
+ distance = attributes.get("distance")
+ warnings: List[str] = []
+
+ count: Optional[int] = None
+ dim: Optional[int] = None
+ dim_inferred = False
+ has_offsets = False
+
+ if vector_type == "dense":
+ if len(dset.shape) != 2:
+ raise DatasetFormatError(
+ "dense dataset '/%s' must be 2-D, got shape %s"
+ % (self.split, dset.shape)
+ )
+ count, dim = int(dset.shape[0]), int(dset.shape[1])
+ if count <= 0:
+ raise DatasetFormatError("dense dataset '/%s' is empty" % self.split)
+ if dim <= 0:
+ raise DatasetFormatError(
+ "dense dataset '/%s' has non-positive dimensionality" % self.split
+ )
+ else:
+ if len(dset.shape) != 1:
+ raise DatasetFormatError(
+ "sparse dataset '/%s' must be a flat 1-D byte stream, got shape %s"
+ % (self.split, dset.shape)
+ )
+ offsets_name = self.split + "_offsets"
+ if offsets_name in f:
+ offsets_dset = f[offsets_name]
+ offset_count = _validate_offsets(
+ offsets_dset, int(dset.shape[0]), offsets_name
+ )
+ count = offset_count - 1
+ has_offsets = True
+ offset_bytes = offset_count * np.dtype(np.uint64).itemsize
+ if offset_bytes <= _OFFSET_INDEX_BUDGET_BYTES:
+ self._offsets = np.asarray(offsets_dset[()], dtype=np.uint64)
+ else:
+ warnings.append(
+ "sparse '/%s' exceeds the %d-byte retained-index budget; "
+ "using the bounded sequential decoder"
+ % (offsets_name, _OFFSET_INDEX_BUDGET_BYTES)
+ )
+ else:
+ warnings.append(
+ "sparse dataset has no '/%s_offsets'; records are counted by scanning"
+ % self.split
+ )
+ if count == 0:
+ raise DatasetFormatError("sparse dataset '/%s' is empty" % self.split)
+ dim_inferred = True
+ warnings.append(
+ "sparse dimensionality is inferred as max(feature id) + 1 during profiling"
+ )
+
+ return DatasetMetadata(
+ path=self.path,
+ file_name=self.path.name,
+ file_size_bytes=int(self.path.stat().st_size),
+ vector_type=vector_type,
+ split=self.split,
+ dtype=dtype,
+ count=count,
+ dim=dim,
+ dim_inferred=dim_inferred,
+ distance=distance,
+ has_offsets=has_offsets,
+ datasets=sorted(str(name) for name in f.keys()),
+ attributes=attributes,
+ warnings=warnings,
+ )
+
+ # -- dense ---------------------------------------------------------
+ def iter_dense_blocks(
+ self, block_rows: int = 65536
+ ) -> Iterator[Tuple[int, np.ndarray]]:
+ """Yield ``(start_row, block)`` slices of the dense matrix."""
+ if self.metadata.vector_type != "dense":
+ raise DatasetFormatError("iter_dense_blocks requires a dense dataset")
+ if block_rows <= 0:
+ raise ValueError("block_rows must be positive")
+ dset = self._file[self.split]
+ total = int(dset.shape[0])
+ for start in range(0, total, block_rows):
+ end = min(start + block_rows, total)
+ yield start, _read_evaluator_dense(dset, slice(start, end))
+
+ def read_dense_rows(self, indices: Sequence[int]) -> np.ndarray:
+ """Read selected rows from a dense matrix (indices must be sorted)."""
+ if self.metadata.vector_type != "dense":
+ raise DatasetFormatError("read_dense_rows requires a dense dataset")
+ idx = np.asarray(indices, dtype=np.int64)
+ if idx.size == 0:
+ return np.empty((0, int(self.metadata.dim or 0)), dtype=np.float64)
+ if np.any(idx[1:] <= idx[:-1]):
+ raise ValueError("dense row indices must be strictly increasing")
+ dset = self._file[self.split]
+ return _read_evaluator_dense(dset, idx.tolist())
+
+ # -- sparse --------------------------------------------------------
+ def iter_sparse_records(
+ self, max_chunk_bytes: int = DEFAULT_CHUNK_BYTES
+ ) -> Iterator[Tuple[int, np.ndarray, np.ndarray]]:
+ """Yield ``(index, ids, values)`` for every sparse vector in the split."""
+ if self.metadata.vector_type != "sparse":
+ raise DatasetFormatError("iter_sparse_records requires a sparse dataset")
+ if max_chunk_bytes < 16:
+ raise ValueError("max_chunk_bytes is too small")
+ if self._offsets is not None:
+ yield from self._iter_sparse_with_offsets(max_chunk_bytes)
+ else:
+ # With no index, or with an index too large to retain, decode the
+ # byte stream directly. An oversized index still supplies record
+ # boundaries and the count, which the decoder cross-checks in windows.
+ yield from self._iter_sparse_without_offsets(
+ max_chunk_bytes,
+ expected_count=self.metadata.count if self.metadata.has_offsets else None,
+ validate_offsets=self.metadata.has_offsets,
+ )
+
+ def _iter_sparse_with_offsets(
+ self, max_chunk_bytes: int
+ ) -> Iterator[Tuple[int, np.ndarray, np.ndarray]]:
+ dset = self._file[self.split]
+ offsets = self._offsets
+ assert offsets is not None
+ total_records = len(offsets) - 1
+ start = 0
+ while start < total_records:
+ limit = int(offsets[start]) + int(max_chunk_bytes)
+ end = int(np.searchsorted(offsets, limit, side="right")) - 1
+ if end <= start:
+ end = start + 1
+ end = min(end, total_records)
+ byte_start = int(offsets[start])
+ byte_end = int(offsets[end])
+ chunk = np.asarray(dset[byte_start:byte_end])
+ view = memoryview(chunk).cast("B")
+ pos = 0
+ for index in range(start, end):
+ record_size = int(offsets[index + 1]) - int(offsets[index])
+ ids, values, consumed = _decode_one_record(view, pos, record_size)
+ yield index, ids, values
+ pos += consumed
+ if pos != view.nbytes:
+ raise DatasetFormatError(
+ "sparse byte stream is inconsistent with '/%s_offsets'" % self.split
+ )
+ start = end
+
+ def _iter_sparse_without_offsets(
+ self,
+ max_chunk_bytes: int,
+ expected_count: Optional[int] = None,
+ validate_offsets: bool = False,
+ ) -> Iterator[Tuple[int, np.ndarray, np.ndarray]]:
+ dset = self._file[self.split]
+ offset_dset = self._file[self.split + "_offsets"] if validate_offsets else None
+ offset_window = np.empty(0, dtype=np.uint64)
+ offset_window_start = 0
+ total_bytes = int(dset.shape[0])
+ buffer = bytearray()
+ index = 0
+ position = 0
+ while position < total_bytes:
+ end = min(position + max_chunk_bytes, total_bytes)
+ buffer.extend(np.asarray(dset[position:end]).tobytes())
+ position = end
+ offset = 0
+ while True:
+ if offset + _LEN_SIZE > len(buffer):
+ break
+ (length,) = _LEN_STRUCT.unpack_from(buffer, offset)
+ record_size = _LEN_SIZE + 8 * int(length)
+ record_start = position - len(buffer) + offset
+ remaining = total_bytes - record_start
+ if record_size > remaining:
+ raise DatasetFormatError(
+ "sparse record is truncated: len=%d requires %d byte(s), "
+ "but only %d byte(s) remain"
+ % (int(length), record_size, remaining)
+ )
+ if offset + record_size > len(buffer):
+ break
+ ids, values, consumed = _decode_one_record(buffer, offset, record_size)
+ if offset_dset is not None:
+ # Keep only one bounded offset window, but check both boundaries
+ # of every decoded record before exposing it to the caller.
+ if index + 1 >= offset_window_start + offset_window.size:
+ offset_window_start = (
+ index // _OFFSET_VALIDATION_ROWS
+ ) * _OFFSET_VALIDATION_ROWS
+ offset_window_end = min(
+ offset_window_start + _OFFSET_VALIDATION_ROWS + 1,
+ int(offset_dset.shape[0]),
+ )
+ offset_window = np.asarray(
+ offset_dset[offset_window_start:offset_window_end],
+ dtype=np.uint64,
+ )
+ window_index = index - offset_window_start
+ stored_start = int(offset_window[window_index])
+ stored_end = int(offset_window[window_index + 1])
+ decoded_end = record_start + consumed
+ if record_start != stored_start or decoded_end != stored_end:
+ raise DatasetFormatError(
+ "sparse decoded record %d has byte range [%d, %d), but "
+ "'/%s_offsets' contains [%d, %d)"
+ % (
+ index,
+ record_start,
+ decoded_end,
+ self.split,
+ stored_start,
+ stored_end,
+ )
+ )
+ yield index, ids, values
+ index += 1
+ offset += consumed
+ del buffer[:offset]
+ if buffer:
+ raise DatasetFormatError(
+ "sparse byte stream has %d trailing byte(s) that do not form a "
+ "complete record" % len(buffer)
+ )
+ if expected_count is not None and index != expected_count:
+ raise DatasetFormatError(
+ "sparse decoded record count (%d) does not match '/%s_offsets' (%d)"
+ % (index, self.split, expected_count)
+ )
+
+ def read_sparse_rows(
+ self, indices: Sequence[int], max_chunk_bytes: int = DEFAULT_CHUNK_BYTES
+ ) -> Dict[int, Tuple[np.ndarray, np.ndarray]]:
+ """Read selected sparse vectors into a mapping ``index -> (ids, values)``."""
+ wanted = set(int(i) for i in indices)
+ if not wanted:
+ return {}
+ out: Dict[int, Tuple[np.ndarray, np.ndarray]] = {}
+ for index, ids, values in self.iter_sparse_records(max_chunk_bytes):
+ if index in wanted:
+ out[index] = (ids.copy(), values.copy())
+ if len(out) == len(wanted):
+ break
+ missing = wanted.difference(out)
+ if missing:
+ raise DatasetFormatError(
+ "sparse dataset ended before sampled indices: %s"
+ % sorted(missing)[:5]
+ )
+ return out
+
+
+def _read_evaluator_dense(dset, selection) -> np.ndarray:
+ """Read dense values with the evaluator's HDF5 destination datatype."""
+ dtype = np.dtype(dset.dtype)
+ if dtype.kind == "u" and dtype.itemsize == 1:
+ # ALPHA_I8 is a signed destination type. HDF5 performs a numeric
+ # conversion and saturates uint8 values above 127; viewing the bytes
+ # would instead wrap them to negative values.
+ return np.asarray(dset.astype(np.int8)[selection])
+ return np.asarray(dset[selection])
+
+
+def _validate_offsets(dset, total_bytes: int, name: str) -> int:
+ """Validate an on-disk offset index using bounded read windows."""
+ if len(dset.shape) != 1:
+ raise DatasetFormatError("'/%s' must be a 1-D offset array" % name)
+ count = int(dset.shape[0])
+ if count < 1:
+ raise DatasetFormatError(
+ "'/%s' must contain at least the trailing sentinel" % name
+ )
+ first = int(np.asarray(dset[0], dtype=np.uint64))
+ last = int(np.asarray(dset[count - 1], dtype=np.uint64))
+ if first != 0:
+ raise DatasetFormatError("'/%s' must start at byte offset 0" % name)
+ if last != int(total_bytes):
+ raise DatasetFormatError(
+ "'/%s' sentinel (%d) does not match the sparse byte stream size (%d)"
+ % (name, last, int(total_bytes))
+ )
+ previous = first
+ for start in range(1, count, _OFFSET_VALIDATION_ROWS):
+ end = min(start + _OFFSET_VALIDATION_ROWS, count)
+ window = np.asarray(dset[start:end], dtype=np.uint64)
+ if int(window[0]) < previous or np.any(window[1:] < window[:-1]):
+ raise DatasetFormatError("'/%s' must be non-decreasing" % name)
+ previous = int(window[-1])
+ return count
+
+
+def _describe_dtype(dtype: np.dtype) -> str:
+ dtype = np.dtype(dtype)
+ if dtype.kind in ("i", "u") and dtype.itemsize == 1:
+ return "INT8"
+ if dtype.kind == "f" and dtype.itemsize == 4:
+ return "FLOAT32"
+ raise DatasetFormatError(
+ "unsupported HDF5 element type %r; expected INT8 or FLOAT32" % dtype
+ )
+
+
+def _decode_one_record(
+ buffer, offset: int, expected_size: Optional[int] = None
+) -> Tuple[np.ndarray, np.ndarray, int]:
+ """Decode one sparse record starting at ``offset``.
+
+ Returns ``(ids, values, consumed_bytes)``. ``expected_size`` is supplied
+ when byte offsets are available and must match the encoded record exactly.
+ """
+ view = memoryview(buffer).cast("B")
+ if offset + _LEN_SIZE > view.nbytes:
+ raise DatasetFormatError("sparse record is truncated before its length header")
+ (length,) = _LEN_STRUCT.unpack_from(view, offset)
+ record_size = _LEN_SIZE + 8 * int(length)
+ if expected_size is not None and record_size != expected_size:
+ raise DatasetFormatError(
+ "sparse record size mismatch: offsets say %d byte(s), record header "
+ "requires %d" % (expected_size, record_size)
+ )
+ if offset + record_size > view.nbytes:
+ raise DatasetFormatError(
+ "sparse record is truncated: len=%d requires %d byte(s)"
+ % (int(length), record_size)
+ )
+ ids_start = offset + _LEN_SIZE
+ vals_start = ids_start + 4 * int(length)
+ if length:
+ ids = np.frombuffer(view, dtype=" str:
+ """Render a complete standalone HTML document for ``report``."""
+ metrics = report.get("metrics", {})
+ dataset = report.get("dataset", {})
+ config = report.get("config", {})
+ file_name = _report_file_name(report)
+ body: List[str] = []
+ body.append(_header(report, dataset, config))
+ body.append(_overview_cards(report, metrics, dataset))
+ body.append(_norms_section(metrics.get("norms", {})))
+ body.append(_dimensions_section(metrics.get("dimensions", {})))
+ body.append(_sparsity_section(metrics.get("sparsity", {})))
+ body.append(_duplicates_section(metrics.get("duplicates", {})))
+ body.append(_distance_sections(metrics))
+ body.append(_anisotropy_section(metrics.get("anisotropy", {})))
+ body.append(_clustering_section(metrics.get("clustering", {})))
+ body.append(_ai_section(report.get("ai_analysis")))
+ body.append(_warnings_section(report.get("warnings", [])))
+ body.append(_footer())
+
+ return (
+ "\n"
+ '\n\n'
+ '\n'
+ '\n'
+ '\n'
+ "VSAG dataset profile: %s\n"
+ "\n"
+ "\n\n"
+ '\n'
+ "%s\n"
+ "\n"
+ "\n\n"
+ ) % (
+ _e(file_name),
+ _CSS,
+ "\n".join(part for part in body if part),
+ _JS,
+ )
+
+
+def _report_file_name(report: Dict[str, Any]) -> str:
+ """Return the input file name stored under ``report.input``."""
+ return str(report.get("input", {}).get("file_name") or "dataset")
+
+
+# ---------------------------------------------------------------------------
+# Page sections
+# ---------------------------------------------------------------------------
+def _header(report: Dict[str, Any], dataset: Dict[str, Any], config: Dict[str, Any]) -> str:
+ title = "Dataset Feature Profile"
+ subtitle = "%s · %s vectors · %s dimensions · %s" % (
+ _e(_report_file_name(report)),
+ _fmt(dataset.get("count")),
+ _fmt(dataset.get("dim")),
+ _e(str(dataset.get("vector_type", "dense")).upper()),
+ )
+ meta = (
+ 'schema %s'
+ 'seed %s'
+ 'sample %s'
+ 'pairwise sample %s'
+ 'metric %s'
+ % (
+ _e(str(report.get("schema_version"))),
+ _e(str(config.get("seed"))),
+ _fmt(config.get("effective_sample_size")),
+ _fmt(config.get("effective_pairwise_sample_size")),
+ _e(str(config.get("metric"))),
+ )
+ )
+ return (
+ ''
+ "
%s
%s
"
+ '
%s
'
+ '
Deterministic offline report. No raw vectors were sent anywhere '
+ "while generating this file.
' % _e(metric.get("interpretation", "")),
+ )
+
+
+def _clustering_section(metric: Dict[str, Any]) -> str:
+ if not metric:
+ return ""
+ if not metric.get("available"):
+ return _section(
+ "Projection and clustering",
+ metric,
+ '
%s
' % _e(metric.get("reason", "unavailable")),
+ )
+ points = metric.get("projection_points", []) or []
+ labels = metric.get("labels") if metric.get("clustering_available") else None
+ charts = _chart_scatter(points, labels, "PCA-2 projection of the seeded sample")
+ if metric.get("clustering_available"):
+ sizes = metric.get("cluster_sizes", []) or []
+ charts += _chart_bars(
+ ["C%d" % index for index in range(len(sizes))],
+ sizes,
+ "Cluster size",
+ "k-means cluster sizes on the PCA-2 projection",
+ "#0891b2",
+ )
+ charts += _table(
+ [
+ ("Clusters", _fmt(metric.get("n_clusters"))),
+ ("Inertia", _fmt(metric.get("inertia"))),
+ ("Iterations", _fmt(metric.get("iterations"))),
+ ]
+ )
+ else:
+ charts += '
%s
' % _e(metric.get("reason", "clustering disabled"))
+ return _section("Projection and clustering", metric, charts)
+
+
+def _ai_section(analysis: Optional[Dict[str, Any]]) -> str:
+ if not analysis:
+ return (
+ '
AI-assisted interpretation
'
+ '
Disabled. The deterministic JSON and HTML reports were '
+ "generated without network access.
"
+ )
+ status = analysis.get("status")
+ if status != "ok":
+ return (
+ '
AI-assisted interpretation
'
+ '
AI analysis unavailable. '
+ "The deterministic reports are still complete. %s
"
+ '
Reason: %s
'
+ % (
+ _e(str(analysis.get("provider") or "provider")),
+ _e(str(analysis.get("error") or "unknown error")),
+ )
+ )
+ findings = analysis.get("findings", []) or []
+ recommendations = analysis.get("recommendations", []) or []
+ parts = [
+ '
AI-assisted interpretation
',
+ '
Provider: %s · model: %s. AI output is derived only from the '
+ "aggregate metrics in this report. Recommendations are hypotheses to validate "
+ "with reproducible VSAG benchmarks, not guaranteed optimal configurations.
Definition: Exact duplicate detection by hashing each complete vector; near-duplicate pairs use Euclidean distance for inner-product inputs and the chosen distance otherwise, below a relative threshold derived from the sampled duplicate-detection distance median.
Unique vectors
118
Duplicate vectors (exact)
2
Duplicate ratio (exact)
1.667%
Duplicate groups
1
Largest duplicate group
3
Near-duplicate (sampled)
Near-duplicate sampled pairs
0
Near-duplicate pair ratio
0.000%
Threshold
0.01586
Median sampled pairwise distance
15.86
Pairs sampled
496
exact duplicate detection covers the full split; the near-duplicate indicator covers the bounded pairwise sample
+
Pairwise distance distribution
Scope: sampleSample size: 32
Definition: Distribution of distances between distinct vectors in a bounded random sample. The full M x M matrix is allocated only for the configured sample size, never for the complete dataset.
Definition: Eigenvalue concentration of the centered sample covariance: the largest eigenvalue share, top-10 share, effective rank, and mean pairwise cosine similarity. High top-1 share indicates a dominant direction.
PC variance share.
Explained variance ratio of leading principal componentsPC1: 0.994974PC1PC2: 0.00489536PC2PC3: 6.9245e-05PC3PC4: 9.13283e-06PC4PC5: 7.263e-06PC5PC6: 6.62456e-06PC6PC7: 6.29738e-06PC7PC8: 6.21929e-06PC8PC9: 5.03303e-06PC9PC10: 4.58376e-06PC100.9950
Top-1 eigenvalue share
0.995
Top-10 eigenvalue share
1
Effective rank
1.033
Mean pairwise cosine
0.08111
top1_eigenvalue_share near 1 indicates a dominant direction; effective_rank near 1 indicates nearly one-dimensional variation
+
Projection and clustering
Scope: sampleSample size: 64
Definition: Optional deterministic PCA-2 projection of the sample and optional k-means cluster summary over that projection.
k-means cluster sizes on the PCA-2 projectionC0: 1C0C1: 40C1C2: 11C2C3: 12C3400
Clusters
4
Inertia
2334
Iterations
3
+
AI-assisted interpretation
Disabled. The deterministic JSON and HTML reports were generated without network access.
+
Warnings
sampled vectors contain NaN/Inf entries; sampled metrics replace them with 0.0, which can distort pairwise, nearest-neighbor, anisotropy, and clustering results
Definition: Count and fraction of finite exactly-zero entries; non-zero values and density count finite non-zero entries, while NaN/Inf values are reported separately in invalid_values. For sparse vectors this also reports non-zero entries per vector and density = nnz / (count * dim).
Definition: Exact duplicate detection by hashing each complete vector; near-duplicate pairs use Euclidean distance for inner-product inputs and the chosen distance otherwise, below a relative threshold derived from the sampled duplicate-detection distance median.
Unique vectors
81
Duplicate vectors (exact)
1
Duplicate ratio (exact)
1.220%
Duplicate groups
1
Largest duplicate group
2
Near-duplicate (sampled)
Near-duplicate sampled pairs
0
Near-duplicate pair ratio
0.000%
Threshold
0.00214
Median sampled pairwise distance
2.14
Pairs sampled
496
exact duplicate detection covers the full split; the near-duplicate indicator covers the bounded pairwise sample
+
Pairwise distance distribution
Scope: sampleSample size: 32
Definition: Distribution of distances between distinct vectors in a bounded random sample. The full M x M matrix is allocated only for the configured sample size, never for the complete dataset.
Definition: Eigenvalue concentration of the centered sample covariance: the largest eigenvalue share, top-10 share, effective rank, and mean pairwise cosine similarity. High top-1 share indicates a dominant direction.
PC variance share.
Explained variance ratio of leading principal componentsPC1: 0.052015PC1PC2: 0.0486358PC2PC3: 0.0441679PC3PC4: 0.0423136PC4PC5: 0.0407092PC5PC6: 0.0375497PC6PC7: 0.0363682PC7PC8: 0.0348908PC8PC9: 0.0332851PC9PC10: 0.032031PC100.052010
Top-1 eigenvalue share
0.05201
Top-10 eigenvalue share
0.402
Effective rank
44.49
Mean pairwise cosine
0.03976
top1_eigenvalue_share near 1 indicates a dominant direction; effective_rank near 1 indicates nearly one-dimensional variation
+
Projection and clustering
Scope: sampleSample size: 64
Definition: Optional deterministic PCA-2 projection of the sample and optional k-means cluster summary over that projection.