Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions docs/docs/en/src/indexes/ivf.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,107 @@ splits the space into two orthogonal sets of centroids
(`first_order_buckets_count` × `second_order_buckets_count`) for even finer
partitioning on very large corpora.

### Balanced k-means training (`ivf_train_type: "balanced_kmeans"`)

Standard k-means assigns every vector to its nearest centroid without any
capacity constraint, so skewed, duplicated, or unevenly distributed corpora can
produce buckets that differ in size by orders of magnitude — and even empty
ones. Oversized buckets raise search latency and weaken partition pruning.

`balanced_kmeans` keeps the same squared-L2 objective and mean centroid update
as `kmeans`, but every assignment step is capacity-constrained: with `n`
training vectors and `buckets_count` clusters, each final cluster holds either
`floor(n / k)` or `ceil(n / k)` vectors. With `buckets_per_data: 1`, the guarantee
extends to the initial bucket assignment of `Build`, so the stored buckets inherit
the balance, and no cluster is empty when balancing applies with `1 <= k <= n`.

How it works, per iteration:

1. Each vector keeps only its `num_candidates` (8 by default) nearest clusters;
distances are evaluated one vector at a time in fixed parallel blocks, so no
`n × k` distance matrix is ever materialized and peak memory stays
`O(n · candidates + k · dim)` instead of `O(n · k)`.
2. The `n mod k` clusters with the highest unconstrained nearest-centroid demand
receive `ceil(n / k)` seats, the others `floor(n / k)`.
3. Vectors are then placed most-constrained first, in decreasing order of their
*regret* (the cost gap between their best and second-best candidate). Each
vector takes the nearest cluster that still has a free seat; a vector whose
remembered candidates are all full recomputes distances against the clusters
that still have seats. Because the capacities sum to exactly `n`, a seat is
always available.
4. Centroids are re-set to the mean of their assigned vectors and the cycle
repeats until the loss stops improving (`kmeans_iter_count` bounds it).

Complexity and trade-offs. Per iteration the trainer pays the same
`O(n · k · dim)` distance work as standard k-means, plus an `O(n log n)`
ordering; memory is bounded as above. Balancing is a heuristic (regret-greedy
insertion), not an exact transportation solve, so the loss can be somewhat
above an unconstrained k-means: the gap is small on uniform or evenly clustered
data and grows on strongly skewed mixtures, where equal-size buckets must split
tight clusters. Run `cluster_balance_bench` to measure both sides on your own
shapes. What the extra loss buys is a hard bound on bucket size: every bucket
holds at most `ceil(n / k)` vectors, the per-query scanned work is bounded by
`scan_buckets_count × ceil(n / k)`, and tail latency stops depending on how
skewed the corpus happens to be.

Notes:

- `random` and `kmeans` are unchanged; the default remains `kmeans`.
- Cosine indexes normalize the trained centroids exactly as in the `kmeans`
path, and the initial bucket assignment uses the metric's own routing order
(`1 - <x, c>` for IP/cosine, squared L2 for L2).
- `balanced_kmeans` is available only with `partition_strategy_type: "ivf"`;
GNO-IMI always trains plain k-means.
- The trainer runs on the host; `enable_gpu_build` does not accelerate balanced
training or the balanced initial assignment.
- Results are deterministic for a given input and seed, independent of the
thread count.
- Initial `Build` assignment is balanced only with `buckets_per_data: 1`. Other
values use ordinary nearest-centroid routing; balanced centroid training still
applies.
- Compare balance, loss, time, and memory yourself with the reproducible
`cluster_balance_bench` tool (`tools/cluster_balance_bench`).

#### Benchmark tool

`cluster_balance_bench` trains `KMeansCluster` and `BalancedKMeansCluster` on
seeded synthetic datasets: uniform, deliberately skewed, a skewed set whose size
is not divisible by `k`, and a duplicated-vector set. For each trainer and
dataset it reports:

- minimum, maximum, and mean cluster size, size standard deviation, and
coefficient of variation;
- clustering loss (mean squared distance from each vector to its assigned
centroid, recomputed from the returned labels with the same yardstick for both
trainers);
- training time; and
- peak memory allocated by the trainer beyond the dataset, measured through a
recording allocator, with a dense `n × k` float distance matrix shown for
reference.

On Linux, build the tool with `ENABLE_TOOLS=ON` and `ENABLE_CXX11_ABI=ON`:

```bash
make release EXTRA_DEFINED="-DENABLE_TOOLS=ON -DENABLE_CXX11_ABI=ON"
# Or build just this target in an existing configured build directory:
cmake --build <build-dir> --target cluster_balance_bench
```

Usage:

```text
cluster_balance_bench [--dataset all|uniform|skewed|indivisible|duplicates]
[--n 50000] [--dim 64] [--k 256] [--iter 25]
[--seed 42] [--threads N]
```

Every run is fully seeded, including deterministic plain-k-means reductions.
Repeated invocations with the same arguments reproduce the cluster assignments,
centroids, and clustering loss; elapsed time can vary. To preserve this guarantee,
the tool rejects `k >= 10000`: plain k-means would otherwise use approximate HGraph
assignment, whose parallel graph construction is not deterministic. Use a smaller
`k` for reproducible comparisons.

## Quick start

```cpp
Expand Down Expand Up @@ -79,8 +180,8 @@ Build-time parameters live under `index_param`. See
| `buckets_count` | int | `10` | Number of inverted lists (effective for `ivf`) |
| `first_order_buckets_count` | int | `10` | First-level count (effective for `gno_imi`) |
| `second_order_buckets_count` | int | `10` | Second-level count (effective for `gno_imi`) |
| `ivf_train_type` | string | `"kmeans"` | Centroid training: `kmeans` or `random` |
| `kmeans_iter_count` | int | `25` (`ivf`), `30` (`gno_imi`) | KMeans training iterations for centroid training (effective with `ivf_train_type: "kmeans"`). Higher → better bucket quality, longer build. |
| `ivf_train_type` | string | `"kmeans"` | Centroid training: `kmeans`, `balanced_kmeans`, or `random` |
| `kmeans_iter_count` | int | `25` (`ivf`), `30` (`gno_imi`) | KMeans training iterations for centroid training (effective with `ivf_train_type: "kmeans"` or `"balanced_kmeans"`). Higher → better bucket quality, longer build. |
| `route_max_degree` | int | `64` | Routing HGraph maximum degree (effective for `ivf`) |
| `route_ef_construction` | int | `300` | Routing HGraph construction search breadth (effective for `ivf`) |
| `enable_gpu_build` | bool | `false` | Use the CUDA backend while building (effective for `ivf`); see [GPU-accelerated build](#gpu-accelerated-build) |
Expand Down
83 changes: 81 additions & 2 deletions docs/docs/zh/src/indexes/ivf.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,85 @@ IVF 通常是一个更合适的默认选择。
正交中心划分(`first_order_buckets_count` × `second_order_buckets_count`),在超大规模
语料上能得到更精细的分区。

### 均衡 k-means 训练(`ivf_train_type: "balanced_kmeans"`)

普通 k-means 把每条向量分配给最近的中心,不限制桶容量,因此偏斜、包含大量重复或
分布不均的数据集会产出大小悬殊甚至为空的桶:过大的桶会拉高检索延迟、削弱分区裁剪。

`balanced_kmeans` 保持与 `kmeans` 相同的平方 L2 目标与均值中心更新,但每一步分配都
受容量约束:`n` 条训练向量、`buckets_count` 个桶时,每个桶最终恰好容纳
`floor(n / k)` 或 `ceil(n / k)` 条向量。当 `buckets_per_data: 1` 时,该保证还延伸到
`Build` 的首次桶分配,存储的桶继承均衡性;均衡分配生效且 `1 <= k <= n` 时不会出现空桶。

每次迭代的流程:

1. 每条向量只保留最近的 `num_candidates`(默认 8)个候选桶;距离按固定分块并行逐条
计算,永远不会物化 `n × k` 距离矩阵,峰值内存为 `O(n · candidates + k · dim)`
而非 `O(n · k)`。
2. 无约束最近分配吸引票数最多的 `n mod k` 个桶获得 `ceil(n / k)` 个名额,其余桶
各得 `floor(n / k)` 个。
3. 向量按“后悔值”(首选与次选候选的代价差)从大到小依次落位——最容易被挤出的向量
优先保住首选名额;每条向量选择仍有空位且距离最近的候选桶;若候选全部满员,则按需
重算到所有尚有空位桶的距离。由于总容量恰好等于 `n`,名额总是够用。
4. 每个中心更新为其成员的均值,重复上述过程直到损失不再下降(由
`kmeans_iter_count` 限制迭代次数)。

复杂度与取舍。每次迭代与标准 k-means 同样需要 `O(n · k · dim)` 次距离计算,另加
`O(n log n)` 排序;内存如上所述有界。均衡分配采用后悔值贪心启发式而非精确的运输问题
求解,因此损失可能略高于无约束 k-means:在均匀或簇大小均衡的数据上差距很小,在偏斜
严重的数据上差距更大(等大的桶必须拆分紧密的簇)。可用 `cluster_balance_bench`
在自家数据形状上实测两边。换来的收益是桶大小的硬上界:每个桶至多容纳
`ceil(n / k)` 条向量,单次查询的扫描量被 `scan_buckets_count × ceil(n / k)` 封顶,
长尾延迟不再取决于语料有多偏斜。

补充说明:

- `random` 与 `kmeans` 的行为不变,默认仍是 `kmeans`。
- cosine 索引下训练后的中心归一化与 `kmeans` 路径完全一致;首次桶分配沿用各度量自身
的路由顺序(IP/cosine 用 `1 - <x, c>`,L2 用平方距离)。
- `balanced_kmeans` 仅支持 `partition_strategy_type: "ivf"`;GNO-IMI 始终训练普通
k-means。
- 均衡训练与首次分配在 CPU 上执行,`enable_gpu_build` 不会加速它们。
- 给定输入与随机种子时结果确定,且与线程数无关。
- 仅当 `buckets_per_data: 1` 时,`Build` 的首次分配才会均衡;其他取值仍按普通的
最近中心路由分配,但均衡中心训练仍然生效。
- 用可复现的 `cluster_balance_bench` 工具(`tools/cluster_balance_bench`)对比
均衡度、损失、时间与内存。

#### 基准工具

`cluster_balance_bench` 在带随机种子的合成数据集上分别训练 `KMeansCluster` 和
`BalancedKMeansCluster`:均匀数据、刻意偏斜的数据、大小不能整除 `k` 的偏斜数据,以及
含重复向量的数据。工具针对每种训练器和数据集报告:

- 桶大小的最小值、最大值、平均值、标准差和变异系数;
- 聚类损失(每条向量到其分配中心的平方距离均值;根据返回的标签重新计算,两种训练器
使用相同口径);
- 训练耗时;以及
- 训练器在数据集之外分配的峰值内存(通过记录分配器测量),并给出稠密 `n × k` 浮点
距离矩阵的大小作为参照。

在 Linux 上,使用 `ENABLE_TOOLS=ON` 和 `ENABLE_CXX11_ABI=ON` 构建工具:

```bash
make release EXTRA_DEFINED="-DENABLE_TOOLS=ON -DENABLE_CXX11_ABI=ON"
# 或在已有的构建目录中只构建此目标:
cmake --build <build-dir> --target cluster_balance_bench
```

用法:

```text
cluster_balance_bench [--dataset all|uniform|skewed|indivisible|duplicates]
[--n 50000] [--dim 64] [--k 256] [--iter 25]
[--seed 42] [--threads N]
```

每次运行都完全由种子控制,普通 k-means 也使用确定性归约。相同参数重复运行时,聚类
分配、中心和聚类损失均可复现;耗时会因运行环境而异。为保证这一点,工具会拒绝
`k >= 10000`:否则普通 k-means 会使用近似 HGraph 分配,其并行图构建过程不具有确定性。
如需可复现的对比,请使用更小的 `k`。

## 快速开始

```cpp
Expand Down Expand Up @@ -73,8 +152,8 @@ auto result = index->KnnSearch(
| `buckets_count` | int | `10` | 倒排列表数量(`ivf` 策略下生效) |
| `first_order_buckets_count` | int | `10` | 第一级桶数(`gno_imi` 策略下生效) |
| `second_order_buckets_count` | int | `10` | 第二级桶数(`gno_imi` 策略下生效) |
| `ivf_train_type` | string | `"kmeans"` | 中心训练方式:`kmeans` 或 `random` |
| `kmeans_iter_count` | int | `25`(`ivf`)、`30`(`gno_imi`) | KMeans 质心训练的迭代次数(`ivf_train_type: "kmeans"` 时生效)。越大桶划分质量越好,构建越慢。 |
| `ivf_train_type` | string | `"kmeans"` | 中心训练方式:`kmeans`、`balanced_kmeans` 或 `random` |
| `kmeans_iter_count` | int | `25`(`ivf`)、`30`(`gno_imi`) | KMeans 质心训练的迭代次数(`ivf_train_type: "kmeans"` 或 `"balanced_kmeans"` 时生效)。越大桶划分质量越好,构建越慢。 |
| `route_max_degree` | int | `64` | 路由 HGraph 的最大度数(`ivf` 策略下生效) |
| `route_ef_construction` | int | `300` | 路由 HGraph 的构建搜索宽度(`ivf` 策略下生效) |
| `enable_gpu_build` | bool | `false` | 构建时使用 CUDA 后端(`ivf` 策略下生效),参见 [GPU 加速构建](#gpu-加速构建) |
Expand Down
9 changes: 8 additions & 1 deletion src/algorithm/ivf/ivf_build.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,15 @@ IVF::add(const DatasetPtr& base, bool try_optimized_build) {
const auto* attr_sets = base->GetAttributeSets();
const auto* extra_info = base->GetExtraInfos();
const auto extra_info_size = base->GetExtraInfoSize();
// The first Add of a fresh Build is the initial bucket assignment the
// partition strategies can refine (issue #2870); every other Add routes to
// the nearest buckets.
const bool initial_bucket_assignment = try_optimized_build and this->total_elements_ == 0;
auto buckets =
partition_strategy_->ClassifyDatas(vectors, num_element, buckets_per_data_, nullptr);
initial_bucket_assignment
? partition_strategy_->ClassifyDatasForBuild(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] [P2] Train residual quantizers with the balanced assignment

This changes storage assignment, but BucketDataCell::Train still calls ClassifyDatas, so residual quantizers learn a different distribution. For L2 data [0,0,0,100] with k=2, balanced centroids are 0 and 50. SQ8 trains on residuals [0,0,0,50], while Build must encode one zero as -50. That residual clips to 0, reconstructing the point as 50 and producing incorrect distances even when all buckets are scanned. Residual training must account for the balanced assignments used during Build.

vectors, num_element, buckets_per_data_, nullptr)
: partition_strategy_->ClassifyDatas(vectors, num_element, buckets_per_data_, nullptr);

int64_t current_num;
bool need_cal_memory_usage = false;
Expand Down
78 changes: 78 additions & 0 deletions src/algorithm/ivf/ivf_nearest_partition.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include "gpu/gpu_plan.h"
#include "impl/allocator/safe_allocator.h"
#include "impl/blas/blas_function.h"
#include "impl/cluster/balanced_kmeans_cluster.h"
#include "impl/cluster/kmeans_cluster.h"
#include "inner_string_params.h"
#include "query_context.h"
Expand Down Expand Up @@ -120,6 +121,19 @@ IVFNearestPartition::Train(const DatasetPtr dataset) {
dataset->GetNumElements(),
ivf_partition_strategy_param_->GetKMeansIterCount());
memcpy(data.data(), cls.k_centroids_, dim * this->bucket_count_ * sizeof(float));
} else if (ivf_partition_strategy_param_->partition_train_type ==
IVFNearestPartitionTrainerType::BalancedKMeansTrainer) {
// Same sample, iteration budget and distance handling as the kmeans
// branch above; only the assignment step is capacity-constrained. The
// trainer always runs on the host: enable_gpu_build accelerates the
// plain kmeans passes, and a balanced training stays on the host even
// when the flag is on.
BalancedKMeansCluster cls(static_cast<int32_t>(dim), this->allocator_, this->thread_pool_);
cls.Run(this->bucket_count_,
dataset->GetFloat32Vectors(),
dataset->GetNumElements(),
ivf_partition_strategy_param_->GetKMeansIterCount());
memcpy(data.data(), cls.k_centroids_, dim * this->bucket_count_ * sizeof(float));
} else if (ivf_partition_strategy_param_->partition_train_type ==
IVFNearestPartitionTrainerType::RandomTrainer) {
auto selected = select_k_numbers(dataset->GetNumElements(), this->bucket_count_);
Expand Down Expand Up @@ -177,6 +191,70 @@ IVFNearestPartition::ClassifyDatas(const void* datas,
return classify_datas_on_host(datas, count, buckets_per_data, ctx);
}

bool
IVFNearestPartition::HasDistinctBuildAssignment() const {
return this->ivf_partition_strategy_param_->partition_train_type ==
IVFNearestPartitionTrainerType::BalancedKMeansTrainer;
}

Vector<BucketIdType>
IVFNearestPartition::ClassifyDatasForBuild(const void* datas,
int64_t count,
BucketIdType buckets_per_data,
QueryContext* ctx) const {
// Balancing the first assignment is defined for one bucket per vector over a
// trained partition; anything else (including a build that brings fewer
// vectors than buckets, where the trainer has already placed one vector per
// bucket best it can) takes the plain routing.
const bool balance_buckets = this->ivf_partition_strategy_param_->partition_train_type ==

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] balance_buckets requires buckets_per_data == 1. That means if a user enables balanced_kmeans but sets n_probe > 1 (so buckets_per_data_ > 1), the index silently falls back to plain nearest-centroid routing for the first Build assignment. The behavior is internally consistent but surprising from a user's perspective; consider either documenting this limitation in the IVF docs or emitting a logger::warn trace when partition_train_type is BalancedKMeansTrainer and buckets_per_data != 1, so the balance guarantee does not appear to be silently dropped.

IVFNearestPartitionTrainerType::BalancedKMeansTrainer and
buckets_per_data == 1 and count > 0 and datas != nullptr and
this->is_trained_ and this->bucket_count_ > 0;
if (not balance_buckets) {
return this->ClassifyDatas(datas, count, buckets_per_data, ctx);
}

// The layout decides where the centroids are, the same question GetCentroid
// asks; the balanced assignment needs them all at once.
const uint64_t centroid_bytes = static_cast<uint64_t>(this->bucket_count_) *
static_cast<uint64_t>(this->dim_) * sizeof(float);
Vector<float> centroids(this->allocator_);
if (this->use_route_graph_) {
centroids.resize(static_cast<uint64_t>(this->bucket_count_) * this->dim_);
for (BucketIdType b = 0; b < this->bucket_count_; ++b) {
this->route_index_ptr_->GetCodeByInnerId(
b,
reinterpret_cast<uint8_t*>(centroids.data() +
static_cast<uint64_t>(b) * this->dim_));
}
} else {
// A trained scan-layout partition stores exactly this many centroids;
// anything else cannot be balanced and is routed instead.
if (this->centroids_.size() * sizeof(float) != centroid_bytes) {
return this->ClassifyDatas(datas, count, buckets_per_data, ctx);
}
centroids = this->centroids_;
}

// Assign under the index metric: squared L2 for L2SQR indexes, and the
// scan routing key 1 - <x, c> for IP and cosine (cosine centroids were
// normalized during training), the same orders the plain routing would
// follow.
BalancedKMeansCluster cls(
static_cast<int32_t>(this->dim_), this->allocator_, this->thread_pool_);
const auto labels = cls.Assign(this->bucket_count_,
centroids.data(),
static_cast<const float*>(datas),
static_cast<uint64_t>(count),
this->metric_type_);
Comment on lines +245 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Train residual quantizers with balanced bucket assignments

With balanced_kmeans, use_residual: true, and SQ8 base codes, Build now uses capacity-constrained labels, but BucketDataCell::Train still computes training residuals using nearest-centroid ClassifyDatas (src/datacell/bucket_datacell.h:638). For 90 zero vectors and ten vectors equal to 100 with two buckets, the centroids become 0 and 20: SQ8 trains on residuals in [0, 80], while Build generates residual -20 for 40 zero vectors. SQ8 clamps those residuals, reconstructing the affected zeros as 20. Without exact reorder, the resulting distance errors persist even when every bucket is scanned. Train the residual codec using assignments consistent with balanced Build, or reject this combination.

Useful? React with 👍 / 👎.


Vector<BucketIdType> result(count, INVALID_BUCKET_ID, this->allocator_);
for (int64_t i = 0; i < count; ++i) {
result[i] = static_cast<BucketIdType>(labels[i]);
}
return result;
}

Vector<BucketIdType>
IVFNearestPartition::ClassifyDatasForSearch(const void* datas,
int64_t count,
Expand Down
Loading
Loading