Skip to content

[BUG]Recall loss while building IVF-PQ indexes with cosine metric #2958

Open

Description

@luliCloud

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

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. In build, 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. In transform / extend, each batch is cast to float and row-normalised before the L2 assignment.
    • Proposed API. Add a field to cuvsIvfPqIndexParams that 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||^2 term 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 touches ivf_pq_build.cuh and ivf_pq_transform.cuh. NVTX ranges ivf_pq::{build,transform}::cosine_kmeans_as_l2 show 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 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).

Activity

  1. luliCloud commented on Oct 28, 2026

    @luliCloud
    Author

    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!

  2. luliCloud commented on Nov 5, 2026

    @luliCloud
    Author

    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) calls ivf_pq::build + ivf_pq::search on 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:

    build / search recall@10 (range over 5 builds)
    CosineExpanded (stock) 0.7097 (0.7077–0.7112)
    L2Expanded on unit vectors 0.7284 (0.7256–0.7319)
    method C index, inner-product search 0.7029 (0.7001–0.7048)
    method C index, searched as L2 (experimental switch) 0.7291 (0.7277–0.7316)
    Lance CPU (through Lance) 0.7336 single / ~0.731 repeats

    Ground 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 L2Expanded rows 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:

    • Lance-read case (~5.5 points): Lance scores the index as L2 and expects mean centroids; cuVS cosine produces unit-length centroids (0.674 vs 0.731 through Lance, as in the description).
    • cuVS's own cosine search (~2 points at 15M): every cosine build is below every L2 build. The coarse-training sample size is not the cause (kmeans_trainset_fraction 0.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||^2 term, 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

    • 2M subset, same dataset, 2048 lists, single builds: cuVS cosine is not worse than L2 (0.7315 vs 0.7243), and the C index searched by cuVS is 0.7363. So the 15M loss grows with size, or with list count. We cannot separate the two yet.
    • GloVe-200 (non-unit, dim 200, 1.18M rows, single builds): stock cosine 0.850, InnerProduct on normalised data 0.847, L2Expanded on 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 CosineExpanded behaviour. 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 (for CosineExpanded the 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 that L2Expanded returns 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions