Skip to content

Support filtered search in IVF-RaBitQ - #2373

Draft
jamxia155 wants to merge 9 commits into
NVIDIA:pull-request/2291from
jamxia155:ivf-rabitq-filtering
Draft

Support filtered search in IVF-RaBitQ#2373
jamxia155 wants to merge 9 commits into
NVIDIA:pull-request/2291from
jamxia155:ivf-rabitq-filtering

Conversation

@jamxia155

Copy link
Copy Markdown
Contributor

This PR adds support for filtered search in IVF-RaBitQ. Current PR is for bitset filter only.

Also added filtering-related features:

  • Ground truth generation script can now generate files for the pre-filtered ground truth along with the matching bitset.
  • cuVS Bench can now accept matching pre-filtered ground truth and bitset pre-computed as above.

Support bitset filters across all IVF-RaBitQ search modes and kernel paths.

Use infinite distances as the sole invalid-result sentinel, preserving all
finite vector IDs, including UINT32_MAX, and map invalid results to the public
out-of-bounds record.

Add L2 and inner-product coverage across search modes, bit widths, moderate and
1% pass rates, and under-filled result sets. Validate bitset coverage and skip
distance computation for filtered samples.
Extends generate_groundtruth with optional bitset prefiltering so that
exact-NN ground truth can be computed over a filtered subset of the
dataset.

New function create_bitset_filter(n_samples, filter_reject_rate) returns
a packed uint32 numpy array (LSB-first, matching cuVS bitset layout) where
bit i is set iff vector i passes the filter.  Uses a modulo-1000 bucket
scheme: vector i passes when i % 1000 >= round(filter_reject_rate * 1000),
giving a reject rate within 0.1% of the requested value.

calc_truth gains an optional bitset parameter:
- GPU path: slices the packed words for each batch, transfers to device,
  and passes as a cuVS bitset prefilter to brute_force.search
- CPU path: unpacks the same words to a boolean accept_mask and masks
  rejected distances to inf before argpartition

Two new mutually exclusive CLI arguments:
- --bitset <path>: load a pre-saved .npy uint32 bitset from file
- --filter_reject_rate <float>: generate a bitset in memory via
  create_bitset_filter

(cherry picked from commit 09281c1)
The --filter_reject_rate path previously generated a bitset in memory,
used it to compute ground truth, then discarded it.  The benchmark side
(cuvs-bench C++) needs to run searches against the *exact same* bitset
that was used to filter the GT, otherwise recall is computed against a
stale GT.  Persisting the generated bitset is the simplest way to keep
producer and consumer in sync.

The on-disk format is also switched from .npy to the cuVS .bin format
used by the rest of the script (base / query / GT files).  .npy is a
Python-only format and cannot be read by the C++ blob loader.  A single
.bin format gives one round-trip across the Python producer, the Python
--bitset loader, and the C++ benchmark consumer.

With the bitset now persisted, create_bitset_filter no longer needs to
be deterministic for reproducibility.  Switch it to true Bernoulli
sampling via numpy's default_rng to match the C++ generate_bernoulli
helper in cpp/bench/ann/src/common/dataset.hpp -- one less subtle
divergence between the two sides, and avoids the regular-pattern
artifact of the old modulo-1000 scheme.

- --filter_reject_rate now writes the generated bitset to
  <output>/groundtruth.filter.bin via write_bin, with shape
  (ceil(N/32), 1) -- matching the C++ blob<uint32_t> layout used by
  cuvs-bench's dataset loader.
- --bitset loader switched from np.load to memmap_bin_file with
  dtype=np.uint32; the (ceil(N/32), 1) memmap is raveled to 1-D for
  use in calc_truth.
- create_bitset_filter switched to numpy Bernoulli sampling
  (rng.random(n) >= reject_rate) for parity with the C++ side.

(cherry picked from commit deabe08)
Loads a precomputed bitset from disk (cuVS .bin format) and feeds it
through the existing filter plumbing in the IVF-Flat / CAGRA / brute-
force wrappers.  Pairs with the Python generate_groundtruth output at
<gt_dir>/groundtruth.filter.bin so the benchmark searches against the
exact bitset that was used to compute the prefiltered ground truth.

- conf.hpp: new optional 'filter_bitset_file' field in dataset_conf;
  rejected at parse time if 'filtering_rate' is also set.
- dataset.hpp: dataset<> ctor accepts the path and loads via the same
  blob<uint32_t>(file) machinery used for base/query sets.  When this
  path is used, the ground_truth_map is told the GT is pre-filtered;
  it then counts bitset rejections during GT-map construction and logs
  a warning if any occur (signals a bitset/GT mismatch).
- benchmark.hpp: forwards dataset_conf.filter_bitset_file at the
  dataset<T> construction site.

(cherry picked from commit e2d014a)
The two prefilter examples in the --help epilog were written against an
older CLI that took the dataset path as --dataset. It has been a
positional argument for some time, and the surrounding examples were
corrected earlier in this series, leaving these two as the only stale
ones. Copying either of them would fail with an unrecognized-argument
error.

Also drop the mid-word "base.\<newline>fbin" line continuation so all six
examples wrap the same way.
Two related defects in the prefiltered ground truth path.

Batch re-basing was only correct for 32-aligned offsets. calc_truth
builds an index per batch holding rows [i, i + n_batch), so the
prefilter handed to it must be re-based to that batch. Slicing the
bitset by word starts at global bit (i / 32) * 32, which equals i only
when i is a multiple of 32, making correctness depend on the batch size
constant. Replace the word slicing with slice_bitset(), which unpacks,
realigns by i % 32, and repacks with a zero-padded tail, so any offset
works. The GPU and CPU branches now share one implementation instead of
duplicating the arithmetic.

Neither side checked that the bitset covers the dataset. A short file
was silently accepted, since NumPy clamps the out-of-range slice, and
the uncovered rows were read out of bounds. The C++ consumer has the
same hole, where the overrun is past the end of an mmap. Both sides now
check the covered row count up front and fail with the actual numbers.

The check is word-granular, since the file records only a word count. A
bitset short by fewer than 32 rows needs the same number of words and is
indistinguishable from a correct one; those rows read padding zeros, so
they are rejected rather than read out of bounds.

The C++ half is restructured so both filter branches derive the row
count from one place. It stays behind the has_value() guard, so the
base_set_size() header read is still skipped when no filter is
configured.
If fewer vectors pass the filter than the requested k, no query can have
k valid neighbors and the ground truth is padded with filtered-out ids at
infinite distance. That was written out silently. Count the accepted
vectors up front and fail before doing any search work. Only the global
count matters, since results are merged across batches. Padding bits in
the final word are excluded so a bitset with stray padding cannot inflate
the count.

The count uses a byte-wise popcount table rather than unpackbits, which
would allocate eight bytes per bit.

Also add --filter_seed, so a generated bitset can be reproduced without
the saved file. It is deliberately not named --seed: query generation
has its own seed and is unaffected.
@copy-pr-bot

copy-pr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@jamxia155
jamxia155 force-pushed the ivf-rabitq-filtering branch from 94e9ba5 to a90521b Compare July 29, 2026 02:36
@jamxia155 jamxia155 added feature request New feature or request non-breaking Introduces a non-breaking change labels Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant