Skip to content

Commit cd574f2

Browse files
authored
fix(rust): change Index::search to take &self instead of self (#1839)
## Summary The `search` methods on CAGRA, IVF-PQ, IVF-Flat, and Brute Force indexes were taking `self` by value, consuming the index and preventing reuse. This required users to rebuild the index after every search operation, making real-time/high-throughput search impractical. ## Changes Changed the method signature from `self` to `&self` in: - `cuvs::cagra::Index::search` - `cuvs::ivf_pq::Index::search` - `cuvs::ivf_flat::Index::search` - `cuvs::brute_force::Index::search` ## Before ```rust let index = Index::build(...)?; index.search(...)?; // First search - OK index.search(...)?; // COMPILE ERROR: use of moved value ``` ## After ```rust let index = Index::build(...)?; index.search(...)?; // First search - OK index.search(...)?; // Second search - OK index.search(...)?; // Third search - OK ``` ## Rationale The underlying C FFI functions (`cuvsCagraSearch`, `cuvsIvfPqSearch`, etc.) do not mutate the index during search - they only read from it. Therefore, taking a shared reference (`&self`) is safe and more ergonomic. This change makes the Rust API consistent with other ANN libraries: - `faiss-rs`: `index.search(&query, k)` - `hnsw_rs`: `hnsw.search(&query, k, ef)` - `annoy-rs`: `index.get_nns_by_vector(&v, n)` ## Testing The existing unit tests in the repository continue to pass with this change. The tests use the `search` method exactly once per index, so they work with both the old and new signature. Fixes #1838 Authors: - Zachary Bennett (https://github.com/zbennett10) - Anupam (https://github.com/aamijar) - Ben Frederickson (https://github.com/benfred) Approvers: - Ben Frederickson (https://github.com/benfred) URL: #1839
1 parent 02192e4 commit cd574f2

File tree

4 files changed

+185
-9
lines changed

4 files changed

+185
-9
lines changed

rust/cuvs/src/brute_force.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
//! Brute Force KNN
@@ -62,7 +62,7 @@ impl Index {
6262
/// * `neighbors` - Matrix in device memory that receives the indices of the nearest neighbors
6363
/// * `distances` - Matrix in device memory that receives the distances of the nearest neighbors
6464
pub fn search(
65-
self,
65+
&self,
6666
res: &Resources,
6767
queries: &ManagedTensor,
6868
neighbors: &ManagedTensor,
@@ -89,7 +89,7 @@ impl Index {
8989
impl Drop for Index {
9090
fn drop(&mut self) {
9191
if let Err(e) = check_cuvs(unsafe { ffi::cuvsBruteForceIndexDestroy(self.0) }) {
92-
write!(stderr(), "failed to call cagraIndexDestroy {:?}", e)
92+
write!(stderr(), "failed to call bruteForceIndexDestroy {:?}", e)
9393
.expect("failed to write to stderr");
9494
}
9595
}
@@ -172,4 +172,11 @@ mod tests {
172172
fn test_l2() {
173173
test_bfknn(DistanceType::L2Expanded);
174174
}
175+
176+
// NOTE: brute_force multiple-search test is omitted here because the C++
177+
// brute_force::index stores a non-owning view into the dataset. Building
178+
// from device data via `build()` drops the ManagedTensor after the call,
179+
// leaving a dangling pointer. A follow-up PR will add dataset lifetime
180+
// enforcement (DatasetOwnership<'a>) to make this safe.
181+
// See: https://github.com/rapidsai/cuvs/issues/1838
175182
}

rust/cuvs/src/cagra/index.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2024, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -59,7 +59,7 @@ impl Index {
5959
/// * `neighbors` - Matrix in device memory that receives the indices of the nearest neighbors
6060
/// * `distances` - Matrix in device memory that receives the distances of the nearest neighbors
6161
pub fn search(
62-
self,
62+
&self,
6363
res: &Resources,
6464
params: &SearchParams,
6565
queries: &ManagedTensor,
@@ -167,4 +167,59 @@ mod tests {
167167
.set_compression(CompressionParams::new().unwrap());
168168
test_cagra(build_params);
169169
}
170+
171+
/// Test that an index can be searched multiple times without rebuilding.
172+
/// This validates that search() takes &self instead of self.
173+
#[test]
174+
fn test_cagra_multiple_searches() {
175+
let res = Resources::new().unwrap();
176+
let build_params = IndexParams::new().unwrap();
177+
178+
// Create a random dataset
179+
let n_datapoints = 256;
180+
let n_features = 16;
181+
let dataset =
182+
ndarray::Array::<f32, _>::random((n_datapoints, n_features), Uniform::new(0., 1.0));
183+
184+
// Build the index once
185+
let index =
186+
Index::build(&res, &build_params, &dataset).expect("failed to create cagra index");
187+
188+
let search_params = SearchParams::new().unwrap();
189+
let k = 5;
190+
191+
// Perform multiple searches on the same index
192+
for search_iter in 0..3 {
193+
let n_queries = 4;
194+
let queries = dataset.slice(s![0..n_queries, ..]);
195+
let queries = ManagedTensor::from(&queries).to_device(&res).unwrap();
196+
197+
let mut neighbors_host = ndarray::Array::<u32, _>::zeros((n_queries, k));
198+
let neighbors = ManagedTensor::from(&neighbors_host)
199+
.to_device(&res)
200+
.unwrap();
201+
202+
let mut distances_host = ndarray::Array::<f32, _>::zeros((n_queries, k));
203+
let distances = ManagedTensor::from(&distances_host)
204+
.to_device(&res)
205+
.unwrap();
206+
207+
// This should work on every iteration because search() takes &self
208+
index
209+
.search(&res, &search_params, &queries, &neighbors, &distances)
210+
.expect(&format!("search iteration {} failed", search_iter));
211+
212+
// Copy back to host memory
213+
distances.to_host(&res, &mut distances_host).unwrap();
214+
neighbors.to_host(&res, &mut neighbors_host).unwrap();
215+
216+
// Verify results are consistent across searches
217+
assert_eq!(
218+
neighbors_host[[0, 0]],
219+
0,
220+
"iteration {}: first query should find itself",
221+
search_iter
222+
);
223+
}
224+
}
170225
}

rust/cuvs/src/ivf_flat/index.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -59,7 +59,7 @@ impl Index {
5959
/// * `neighbors` - Matrix in device memory that receives the indices of the nearest neighbors
6060
/// * `distances` - Matrix in device memory that receives the distances of the nearest neighbors
6161
pub fn search(
62-
self,
62+
&self,
6363
res: &Resources,
6464
params: &SearchParams,
6565
queries: &ManagedTensor,
@@ -157,4 +157,61 @@ mod tests {
157157
assert_eq!(neighbors_host[[2, 0]], 2);
158158
assert_eq!(neighbors_host[[3, 0]], 3);
159159
}
160+
161+
/// Test that an index can be searched multiple times without rebuilding.
162+
/// This validates that search() takes &self instead of self.
163+
#[test]
164+
fn test_ivf_flat_multiple_searches() {
165+
let build_params = IndexParams::new().unwrap().set_n_lists(64);
166+
let res = Resources::new().unwrap();
167+
168+
// Create a random dataset
169+
let n_datapoints = 1024;
170+
let n_features = 16;
171+
let dataset =
172+
ndarray::Array::<f32, _>::random((n_datapoints, n_features), Uniform::new(0., 1.0));
173+
174+
let dataset_device = ManagedTensor::from(&dataset).to_device(&res).unwrap();
175+
176+
// Build the index once
177+
let index = Index::build(&res, &build_params, dataset_device)
178+
.expect("failed to create ivf-flat index");
179+
180+
let search_params = SearchParams::new().unwrap();
181+
let k = 5;
182+
183+
// Perform multiple searches on the same index
184+
for search_iter in 0..3 {
185+
let n_queries = 4;
186+
let queries = dataset.slice(s![0..n_queries, ..]);
187+
let queries = ManagedTensor::from(&queries).to_device(&res).unwrap();
188+
189+
let mut neighbors_host = ndarray::Array::<i64, _>::zeros((n_queries, k));
190+
let neighbors = ManagedTensor::from(&neighbors_host)
191+
.to_device(&res)
192+
.unwrap();
193+
194+
let mut distances_host = ndarray::Array::<f32, _>::zeros((n_queries, k));
195+
let distances = ManagedTensor::from(&distances_host)
196+
.to_device(&res)
197+
.unwrap();
198+
199+
// This should work on every iteration because search() takes &self
200+
index
201+
.search(&res, &search_params, &queries, &neighbors, &distances)
202+
.expect(&format!("search iteration {} failed", search_iter));
203+
204+
// Copy back to host memory
205+
distances.to_host(&res, &mut distances_host).unwrap();
206+
neighbors.to_host(&res, &mut neighbors_host).unwrap();
207+
208+
// Verify results are consistent
209+
assert_eq!(
210+
neighbors_host[[0, 0]],
211+
0,
212+
"iteration {}: first query should find itself",
213+
search_iter
214+
);
215+
}
216+
}
160217
}

rust/cuvs/src/ivf_pq/index.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2024, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

@@ -59,7 +59,7 @@ impl Index {
5959
/// * `neighbors` - Matrix in device memory that receives the indices of the nearest neighbors
6060
/// * `distances` - Matrix in device memory that receives the distances of the nearest neighbors
6161
pub fn search(
62-
self,
62+
&self,
6363
res: &Resources,
6464
params: &SearchParams,
6565
queries: &ManagedTensor,
@@ -151,4 +151,61 @@ mod tests {
151151
assert_eq!(neighbors_host[[2, 0]], 2);
152152
assert_eq!(neighbors_host[[3, 0]], 3);
153153
}
154+
155+
/// Test that an index can be searched multiple times without rebuilding.
156+
/// This validates that search() takes &self instead of self.
157+
#[test]
158+
fn test_ivf_pq_multiple_searches() {
159+
let build_params = IndexParams::new().unwrap().set_n_lists(64);
160+
let res = Resources::new().unwrap();
161+
162+
// Create a random dataset
163+
let n_datapoints = 1024;
164+
let n_features = 16;
165+
let dataset =
166+
ndarray::Array::<f32, _>::random((n_datapoints, n_features), Uniform::new(0., 1.0));
167+
168+
let dataset_device = ManagedTensor::from(&dataset).to_device(&res).unwrap();
169+
170+
// Build the index once
171+
let index = Index::build(&res, &build_params, dataset_device)
172+
.expect("failed to create ivf-pq index");
173+
174+
let search_params = SearchParams::new().unwrap();
175+
let k = 5;
176+
177+
// Perform multiple searches on the same index
178+
for search_iter in 0..3 {
179+
let n_queries = 4;
180+
let queries = dataset.slice(s![0..n_queries, ..]);
181+
let queries = ManagedTensor::from(&queries).to_device(&res).unwrap();
182+
183+
let mut neighbors_host = ndarray::Array::<i64, _>::zeros((n_queries, k));
184+
let neighbors = ManagedTensor::from(&neighbors_host)
185+
.to_device(&res)
186+
.unwrap();
187+
188+
let mut distances_host = ndarray::Array::<f32, _>::zeros((n_queries, k));
189+
let distances = ManagedTensor::from(&distances_host)
190+
.to_device(&res)
191+
.unwrap();
192+
193+
// This should work on every iteration because search() takes &self
194+
index
195+
.search(&res, &search_params, &queries, &neighbors, &distances)
196+
.expect(&format!("search iteration {} failed", search_iter));
197+
198+
// Copy back to host memory
199+
distances.to_host(&res, &mut distances_host).unwrap();
200+
neighbors.to_host(&res, &mut neighbors_host).unwrap();
201+
202+
// Verify results are consistent
203+
assert_eq!(
204+
neighbors_host[[0, 0]],
205+
0,
206+
"iteration {}: first query should find itself",
207+
search_iter
208+
);
209+
}
210+
}
154211
}

0 commit comments

Comments
 (0)