Repository navigation
Description
Issue cuvs
Describe the bug
Building a cosine IVF-PQ index with cuVS gives lower recall than Lance's CPU path on the same data. 15M vectors, dim 2048, 3755 lists, 256 PQ sub-vectors, recall@10, nprobes 100: cuVS 0.674 vs Lance CPU 0.731. At nprobes 1 the two are equal, so clustering and probe selection are healthy.
Root cause. In cosine mode ivf_pq_build.cuh trains the coarse k-means with CosineExpanded on a row-normalised training set and then row-normalises the centroids to unit length. The centroids are no longer cluster means. Lance CPU treats cosine as L2 on unit vectors and keeps plain means: when the metric is cosine, it first applies a NormalizeTransformer to row-normalise the vectors, then switches the distance type from cosine to L2.
Steps/Code to reproduce bug
Measured through Lance (cuVS builds the index, Lance searches it), on a 15M-vector unit-norm set (coyo-ve-base), dim 2048, create_index(index_type="IVF_PQ", metric="cosine", num_partitions=3755, num_sub_vectors=256, num_bits=8), recall@10 against exact cosine ground truth, 1000 queries. The GPU path is the Lance cuVS backend https://github.com/luliCloud/lance-cuvs/tree/cuvs-local-methods-ab-pool (backend cuvs_local). The only difference between the "cosine" and "C" rows below is CUVS_IVF_PQ_COSINE_KMEANS_L2 (0 vs 1) in the cuVS build linked under Method C.
A cuVS-only reproduction (cuvsIvfPqBuild + cuvsIvfPqSearch with metric=CosineExpanded vs the same data pre-normalised with L2Expanded) is not included yet.
Expected behavior
Cosine IVF-PQ should reach the same recall as L2 IVF-PQ on the row-normalised vectors, i.e. the coarse centroids should be the cluster means.
Environment details
- Environment location: Docker container (internal image) on bare-metal 8x H200 (sm_90)
- Method of cuVS install: from source,
./build.sh libcuvs, cuVS 26.12.00 (lance-localbranch of https://github.com/jamxia155/cuvs, commitfbdc9b0a), CUDA 13.3 - Lance at commit
dc08fbfc4(CPU reference, unchanged between runs)
Additional context
Three ways to get "L2 on unit vectors with plain-mean centroids" (what Lance CPU does)
Compared against the current cuVS cosine mode (cuVS 26.12.00, CUDA 13.3).
- Baseline, cuVS cosine (unchanged): coarse k-means with
CosineExpanded, then the centroids are row-normalised to unit length (not cluster means). - Methods A and B, outside cuVS (lance-cuvs normalises, asks cuVS for L2; no cuVS change):
- Method A (
auto): lance-cuvs sends L2 to cuVS only if the training sample is already unit length. This requires scanning the sample to check the norms, which works for the method but is not practical at scale. On non-unit data it falls back to cosine, since L2 is not applicable there. - Method B (
normalize): lance-cuvs normalises the training sample and every transform batch with a CUDA kernel, then always runs cuVS in L2, so it also works on non-unit data. An nsys analysis is still needed to see whether the normalisation kernel overlaps with compute and host-to-device transfer or adds to the critical path. - Method C, in cuVS (prototype, potential cuVS PR): the user-facing metric stays
CosineExpanded. Internally, a cosine IVF-PQ index is built as L2 on unit-length vectors. Inbuild, the coarse k-means fit/predict use L2 on the row-normalised training sample (already normalised in stock cuVS), and the centroid normalisation is skipped, so the centroids stay cluster means. Intransform/extend, each batch is cast to float and row-normalised before the L2 assignment. - Proposed API. Add a field to
cuvsIvfPqIndexParamsthat says the input vectors are already normalised (name open, e.g.data_normalized), and let cuVS choose the internal path from it, instead of exposing a "use L2 for cosine k-means" switch. If set, cuVS skips the per-batch normalisation; if not set, cuVS normalises internally. The same L2-on-normalised-vectors path with mean centroids is used in both cases, because the recall loss also appears on non-unit input (2M non-unit rows: cosine 0.678 vs 0.744 with the change). The choice must be stored in the index and in the serialisation, because search has to know which centroid convention an index uses (it needs a||c||^2term when the centroids are not unit length). - Prototype. The patch is opt-in through the env var
CUVS_IVF_PQ_COSINE_KMEANS_L2=1, which the patch adds (stock cuVS has no such option); it is only a stand-in for the field above. It touchesivf_pq_build.cuhandivf_pq_transform.cuh. NVTX rangesivf_pq::{build,transform}::cosine_kmeans_as_l2show it is active: present in the C run, absent in the control. - Note. Method C has not been validated through cuVS's own
search; our recall results come from cuVS-built indexes searched through Lance.
- Method A (
Method B ties the fix to Lance (extra normalisation pass per batch in lance-cuvs); C fixes it for every cuVS user.
Recall@10 vs nprobes
15M unit vectors, dim 2048, 3755 lists, 1000 queries, one build per cell, cuVS 26.12 + CUDA 13.3 (top1nn backend cutlass; the auto backend gives the same picture). The Lance CPU row is from an earlier CPU run; the CPU path does not depend on CUDA or cuVS.
| nprobes | 1 | 5 | 10 | 20 | 50 | 100 |
|---|---|---|---|---|---|---|
| cosine (baseline) | 0.4618 | 0.6332 | 0.6586 | 0.6681 | 0.6719 | 0.6726 |
A: outside, auto |
0.4729 | 0.6620 | 0.6928 | 0.7093 | 0.7187 | 0.7213 |
B: outside, normalize |
0.4793 | 0.6665 | 0.6987 | 0.7139 | 0.7232 | 0.7266 |
| C: in cuVS | 0.4867 | 0.6734 | 0.7003 | 0.7152 | 0.7248 | 0.7278 |
| Lance CPU | 0.4466 | 0.6597 | 0.6958 | 0.7177 | 0.7297 | 0.7336 |
| C minus cosine | +0.025 | +0.040 | +0.042 | +0.047 | +0.053 | +0.055 |
The gap is not an artefact of where probing stops: it is +0.04 at nprobes 5 and 10 and about +0.05 from nprobes 20 on, and every curve flattens (between nprobes 50 and 100: cosine 0.6719 to 0.6726, C 0.7248 to 0.7278, CPU 0.7297 to 0.7336). nprobes above 100 was not measured. Sampling error is about +-0.5 points.
Timing
1B rows (7.8 TB), 32768 lists, create_index wall time, single runs, same GPU:
| config | cosine | A: auto | B: normalize | C: in cuVS |
|---|---|---|---|---|
| glibc, registration cache off | 3212 s | 2914 s | 2922 s | 2957 s |
The three L2 variants take similar time and are 8-10% faster than cosine, so this is the cost of cosine versus L2 assignment, not something specific to one method. For reference, a separate Lance CPU run of the same build (same dataset and parameters, hardware not verified to match) took 17756 s.
Appendix: potential root cause analysis
Measured on a random 100K sample of the 15M unit-norm dataset (dim 2048, 3755 lists, 256 PQ sub-vectors); all three models are trained and evaluated on the same unit vectors. r = x - c is the residual of a vector against its coarse centroid; PQ encodes r.
| Lance CPU | cuVS cosine (unit centroids) | cuVS with mean centroids | |
|---|---|---|---|
Mean squared residual ‖r‖²: how far vectors sit from their centroids |
0.608 | 0.730 | 0.594 |
| Per-list mean residual (off-centre) length: how far the centroid is from the true cluster mean | 0.165 | 0.397 | 0.153 |
| PQ squared error: information lost when PQ encodes the residual | 0.190 | 0.218 | 0.186 |
PQ error relative to ‖r‖²: fraction of the residual PQ fails to recover (codebook quality) |
31.3% | 29.9% | 31.3% |
The PQ codebook is not worse: PQ loses the same fraction (about 30%) in all three setups. But with unit-length centroids the residuals are off-centre and their mean squared length is about 20% larger (0.730 vs 0.608), so the absolute encoding error is about 15% higher (0.218 vs 0.190).
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNeeds Triage
Activity
luliCloud commentedon Oct 28, 2026
Hi team, James Xia and I have been investigating this issue offline for a while and already have some initial ideas for a fix. Could you please assign this issue to us (@luliCloud and @jamxia155 )? Thanks!
luliCloud commentedon Nov 5, 2026
Update with measurements since the description was written. Two things changed: the cuVS-only reproduction now exists, and one claim in the description needs correcting.
1. cuVS-only reproduction (no Lance), 15M
cosine_ivfpq_recall_host.cu(attached) callsivf_pq::build+ivf_pq::searchon all 15,380,795 rows (dim 2048, 3755 lists, pq_dim 256, 1000 queries, exact brute-force cosine truth, cuVS 26.12.00 + CUDA 13.3, H200). Mean of 5 builds, recall@10 at nprobes 100:CosineExpanded(stock)L2Expandedon unit vectorsGround truth. All rows in this table, including the method C rows and the Lance CPU row, are scored against the same reference: the exact top-10 by cosine similarity, computed by brute force over all 15M rows, with each query's own row excluded. On this dataset that is also the exact L2 ground truth: every base vector has unit norm (max deviation of ‖x‖ from 1 is 1.25e-6) and the queries are rows of the base, so ‖x − q‖² = 2 − 2·cos(x, q) ranks the same way. We checked this with an exact brute-force L2 top-10 for the same 1000 queries; it is identical to the cosine truth for all 1000 queries, in the same order. So the difference between the cosine and
L2Expandedrows comes from the index, not from the reference. Lance CPU uses cosine as the metric; internally it normalises the vectors and searches with L2, which on this data gives the same ranking.So there are two separate effects:
kmeans_trainset_fraction0.0625: cosine 0.708, L2 0.727).2. Correction: Method C alone does not fix cuVS's own search
The description says C "fixes it for every cuVS user". That is only true for clients that score as L2, like Lance. Searched by cuVS with inner product, the C index scores 0.703, no better than stock cosine (0.710). Scored as L2 it reaches 0.729 ≈
L2Expanded. So an opt-in mode would have to cover build, transform/extend and search (plus a conversion back to cosine distance if cosine values are wanted).I also withdraw two points from the proposed API: I have no evidence that search needs a
||c||^2term, and therefore no evidence that the choice has to be stored in the index or the serialisation. We have not tested serialisation.3. The effect depends on the data
InnerProducton normalised data 0.847,L2Expandedon normalised data 0.801. Here C (0.841 with cuVS scoring, 0.803 scored as L2) is not better than the current build, and unit-length centroids read as L2 (0.830) beat mean centroids (0.803). I do not have a Lance CPU number for this dataset.I therefore do not think the evidence supports changing the default
CosineExpandedbehaviour. What I would suggest is a documentation note for IVF-PQ along these lines: "If your dataset is already normalised, L2 and cosine are mathematically equivalent. In an approximate index like IVF-PQ the two metrics can still give different recall, because they take different code paths (forCosineExpandedthe centroids are normalised to unit length and are not cluster means, and the search scores with inner product). If recall is what you care about, consider testing L2 on normalised data in place of cosine. Note thatL2Expandedreturns the squared L2 distance; for unit vectors, cosine distance = L2² / 2." Which one is better depends on the data (see above: L2 is better at 15M on coyo, but not at 2M or on GloVe-200), so the note should say to measure rather than promise a gain. An opt-in mode along the lines of C that covers build, transform/extend and search would be optional on top of that.Caveats
bbbc186b).