diff --git a/Cargo.lock b/Cargo.lock
index 57493495b47..1cf9dbd36f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5032,9 +5032,9 @@ dependencies = [
[[package]]
name = "lance-namespace-reqwest-client"
-version = "0.12.0"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af"
+checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1"
dependencies = [
"reqwest 0.12.28",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index ddafb660946..d13da27cb85 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,7 +73,7 @@ lance-io = { version = "=13.0.0-beta.2", path = "./rust/lance-io", default-featu
lance-linalg = { version = "=13.0.0-beta.2", path = "./rust/lance-linalg" }
lance-namespace = { version = "=13.0.0-beta.2", path = "./rust/lance-namespace" }
lance-namespace-impls = { version = "=13.0.0-beta.2", path = "./rust/lance-namespace-impls" }
-lance-namespace-reqwest-client = "0.12.0"
+lance-namespace-reqwest-client = "0.13.0"
lance-select = { version = "=13.0.0-beta.2", path = "./rust/lance-select" }
lance-tokenizer = { version = "=13.0.0-beta.2", path = "./rust/lance-tokenizer" }
lance-table = { version = "=13.0.0-beta.2", path = "./rust/lance-table" }
diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock
index 8c75878c028..77846500018 100644
--- a/java/lance-jni/Cargo.lock
+++ b/java/lance-jni/Cargo.lock
@@ -4288,9 +4288,9 @@ dependencies = [
[[package]]
name = "lance-namespace-reqwest-client"
-version = "0.12.0"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af"
+checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1"
dependencies = [
"reqwest 0.12.28",
"serde",
diff --git a/java/src/main/java/org/lance/ipc/Query.java b/java/src/main/java/org/lance/ipc/Query.java
index 215865310df..76e931dcacb 100644
--- a/java/src/main/java/org/lance/ipc/Query.java
+++ b/java/src/main/java/org/lance/ipc/Query.java
@@ -176,16 +176,14 @@ public Builder setK(int k) {
}
/**
- * Sets the number of probes to load and search.
+ * Sets the maximum number of probes to load and search.
*
- *
This is a convenience method that sets both the minimum and maximum number of probes to
- * the same value.
+ *
The minimum remains unchanged, so the search may stop before reaching this value.
*
* @param nprobes The number of probes.
* @return The Builder instance for method chaining.
*/
public Builder setNprobes(int nprobes) {
- this.minimumNprobes = nprobes;
this.maximumNprobes = Optional.of(nprobes);
return this;
}
diff --git a/java/src/test/java/org/lance/JNITest.java b/java/src/test/java/org/lance/JNITest.java
index 94db13d6dea..f313e574172 100644
--- a/java/src/test/java/org/lance/JNITest.java
+++ b/java/src/test/java/org/lance/JNITest.java
@@ -55,6 +55,15 @@ public void testQuery() {
new Query.Builder().setColumn("column").setKey(new float[] {1.0f, 2.0f, 3.0f}).build();
assertEquals(ApproxMode.NORMAL, defaultQuery.getApproxMode());
+ Query nprobesQuery =
+ new Query.Builder()
+ .setColumn("column")
+ .setKey(new float[] {1.0f, 2.0f, 3.0f})
+ .setNprobes(20)
+ .build();
+ assertEquals(1, nprobesQuery.getMinimumNprobes());
+ assertEquals(Optional.of(20), nprobesQuery.getMaximumNprobes());
+
JniTestHelper.parseQuery(
Optional.of(
new Query.Builder()
diff --git a/python/Cargo.lock b/python/Cargo.lock
index 77a416d9a10..1d7c6c48b81 100644
--- a/python/Cargo.lock
+++ b/python/Cargo.lock
@@ -4435,9 +4435,9 @@ dependencies = [
[[package]]
name = "lance-namespace-reqwest-client"
-version = "0.12.0"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af"
+checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1"
dependencies = [
"reqwest 0.12.28",
"serde",
diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py
index 662e9307f59..ec22d1c3a55 100644
--- a/python/python/lance/dataset.py
+++ b/python/python/lance/dataset.py
@@ -8368,8 +8368,7 @@ def _build_vector_search_query(
metric: str, optional
The distance metric to use (e.g., "L2", "cosine", "dot", "hamming").
nprobes: int, optional
- The number of partitions to search. Sets both minimum_nprobes and
- maximum_nprobes to the same value.
+ The maximum number of partitions to search. The minimum remains unchanged.
minimum_nprobes: int, optional
The minimum number of partitions to search.
maximum_nprobes: int, optional
@@ -8439,15 +8438,6 @@ def _build_vector_search_query(
if maximum_nprobes is not None and int(maximum_nprobes) < 0:
raise ValueError(f"Maximum nprobes must be >= 0 but got {maximum_nprobes}")
- if nprobes is not None:
- if minimum_nprobes is not None or maximum_nprobes is not None:
- raise ValueError(
- "nprobes cannot be set in combination with minimum_nprobes or "
- "maximum_nprobes"
- )
- else:
- minimum_nprobes = nprobes
- maximum_nprobes = nprobes
if (
minimum_nprobes is not None
and maximum_nprobes is not None
@@ -8483,6 +8473,7 @@ def _build_vector_search_query(
"q": q,
"k": k,
"metric": metric,
+ "nprobes": nprobes,
"minimum_nprobes": minimum_nprobes,
"maximum_nprobes": maximum_nprobes,
"refine_factor": refine_factor,
diff --git a/python/python/tests/test_row_addr_prefilter.py b/python/python/tests/test_row_addr_prefilter.py
index 7d3381ebdce..aef647e150f 100644
--- a/python/python/tests/test_row_addr_prefilter.py
+++ b/python/python/tests/test_row_addr_prefilter.py
@@ -46,8 +46,8 @@ def _write(tmp_path: Path, with_index: bool = False) -> lance.LanceDataset:
)
ds = lance.write_dataset(tbl, str(tmp_path / "t.lance"), mode="overwrite")
if with_index:
- # IVF_FLAT with nprobes == num_partitions is exact, so the masked result
- # can be compared against brute force without recall slack.
+ # IVF_FLAT with both probe bounds set to num_partitions is exact, so the
+ # masked result can be compared against brute force without recall slack.
ds.create_index("vector", index_type="IVF_FLAT", num_partitions=4, metric="l2")
return ds
@@ -136,7 +136,13 @@ def test_knn_topk_is_computed_over_masked_rows(
query = np.zeros(DIM, dtype=np.float32)
got = ds.scanner(
- nearest={"column": "vector", "q": query, "k": 5, "nprobes": 4},
+ nearest={
+ "column": "vector",
+ "q": query,
+ "k": 5,
+ "minimum_nprobes": 4,
+ "maximum_nprobes": 4,
+ },
with_row_id=True,
row_addr_allowlist=serialize_row_addrs(allowed),
).to_table()
diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py
index ab5f26379b6..2ae7e16b003 100644
--- a/python/python/tests/test_vector_index.py
+++ b/python/python/tests/test_vector_index.py
@@ -2526,9 +2526,22 @@ def test_vector_index_with_nprobes(indexed_dataset):
}
).explain_plan()
- assert "minimum_nprobes=7" in res
+ assert "minimum_nprobes=1" in res
assert "maximum_nprobes=Some(7)" in res
+ res = indexed_dataset.scanner(
+ nearest={
+ "column": "vector",
+ "q": np.random.randn(128),
+ "k": 10,
+ "nprobes": 10,
+ "minimum_nprobes": 7,
+ }
+ ).explain_plan()
+
+ assert "minimum_nprobes=7" in res
+ assert "maximum_nprobes=Some(10)" in res
+
res = indexed_dataset.scanner(
nearest={
"column": "vector",
diff --git a/python/src/dataset.rs b/python/src/dataset.rs
index 6b442fb3027..e56931b3669 100644
--- a/python/src/dataset.rs
+++ b/python/src/dataset.rs
@@ -5587,7 +5587,6 @@ fn vector_query_params_from_dict(
&& !nprobes.is_none()
{
let extracted: usize = nprobes.extract()?;
- minimum_nprobes = extracted;
maximum_nprobes = Some(extracted);
}
diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs
index e7d91d11e3d..b4ce2f3da43 100644
--- a/rust/lance-namespace-impls/src/dir.rs
+++ b/rust/lance-namespace-impls/src/dir.rs
@@ -1013,6 +1013,29 @@ impl TransactionAlteration {
}
}
+fn apply_probe_bounds(
+ scanner: &mut Scanner,
+ nprobes: Option,
+ minimum_nprobes: Option,
+ maximum_nprobes: Option,
+) -> Result<()> {
+ let parse_probe_count = |name: &str, value: i32| {
+ usize::try_from(value)
+ .map_err(|_| Error::invalid_input(format!("{name} must be non-negative")))
+ };
+
+ if let Some(nprobes) = nprobes {
+ scanner.nprobes(parse_probe_count("nprobes", nprobes)?);
+ }
+ if let Some(minimum_nprobes) = minimum_nprobes {
+ scanner.minimum_nprobes(parse_probe_count("minimum_nprobes", minimum_nprobes)?);
+ }
+ if let Some(maximum_nprobes) = maximum_nprobes {
+ scanner.maximum_nprobes(parse_probe_count("maximum_nprobes", maximum_nprobes)?);
+ }
+ Ok(())
+}
+
impl DirectoryNamespace {
fn manifest_ns_for_read(&self) -> Option<&Arc> {
self.write_manifest_ns
@@ -3170,6 +3193,8 @@ impl DirectoryNamespace {
prefilter: Option,
bypass_vector_index: Option,
nprobes: Option,
+ minimum_nprobes: Option,
+ maximum_nprobes: Option,
ef: Option,
refine_factor: Option,
distance_type: Option<&str>,
@@ -3240,9 +3265,7 @@ impl DirectoryNamespace {
})?;
// ANN parameters — must be applied after nearest().
- if let Some(n) = nprobes {
- scanner.nprobes(n.max(1) as usize);
- }
+ apply_probe_bounds(scanner, nprobes, minimum_nprobes, maximum_nprobes)?;
if let Some(e) = ef {
scanner.ef(e.max(1) as usize);
}
@@ -5029,6 +5052,8 @@ impl LanceNamespace for DirectoryNamespace {
request.query.prefilter,
request.query.bypass_vector_index,
request.query.nprobes,
+ request.query.minimum_nprobes,
+ request.query.maximum_nprobes,
request.query.ef,
request.query.refine_factor,
request.query.distance_type.as_deref(),
@@ -5071,6 +5096,8 @@ impl LanceNamespace for DirectoryNamespace {
request.prefilter,
request.bypass_vector_index,
request.nprobes,
+ request.minimum_nprobes,
+ request.maximum_nprobes,
request.ef,
request.refine_factor,
request.distance_type.as_deref(),
@@ -5443,10 +5470,12 @@ impl LanceNamespace for DirectoryNamespace {
scanner.distance_metric(metric);
}
- // Apply nprobes if specified (maps to minimum_nprobes, matching lancedb behavior)
- if let Some(nprobes) = request.nprobes {
- scanner.minimum_nprobes(nprobes as usize);
- }
+ apply_probe_bounds(
+ &mut scanner,
+ request.nprobes,
+ request.minimum_nprobes,
+ request.maximum_nprobes,
+ )?;
// Apply ef (HNSW search effort) if specified
if let Some(ef) = request.ef {
@@ -14336,6 +14365,77 @@ mod tests {
assert_eq!(total_rows, 2);
}
+ #[tokio::test]
+ async fn test_explain_vector_probe_fields_are_applied_independently() {
+ use lance_namespace::models::ExplainTableQueryPlanRequest;
+
+ let (namespace, temp_dir, table_id) = create_ns_with_vector_table().await;
+ let table_uri = format!("{}/vector_table.lance", temp_dir.to_str().unwrap());
+ let mut dataset = Dataset::open(&table_uri).await.unwrap();
+ dataset
+ .create_index(
+ &["vector"],
+ IndexType::Vector,
+ Some("vector_idx".to_string()),
+ &VectorIndexParams::ivf_flat(1, MetricType::L2),
+ false,
+ )
+ .await
+ .unwrap();
+ let vector = || {
+ Box::new(lance_namespace::models::QueryTableRequestVector {
+ single_vector: Some(vec![0.0, 1.0, 0.0, 0.0]),
+ multi_vector: None,
+ })
+ };
+
+ let query = QueryTableRequest {
+ id: None,
+ k: 2,
+ vector: vector(),
+ nprobes: Some(20),
+ minimum_nprobes: Some(3),
+ maximum_nprobes: Some(10),
+ ..Default::default()
+ };
+ let mut request = ExplainTableQueryPlanRequest::new(query);
+ request.id = Some(table_id.clone());
+
+ let plan = namespace.explain_table_query_plan(request).await.unwrap();
+ assert!(plan.contains("minimum_nprobes=3"), "{plan}");
+ assert!(plan.contains("maximum_nprobes=Some(10)"), "{plan}");
+
+ let query = QueryTableRequest {
+ id: None,
+ k: 2,
+ vector: vector(),
+ nprobes: Some(0),
+ ..Default::default()
+ };
+ let mut request = ExplainTableQueryPlanRequest::new(query);
+ request.id = Some(table_id.clone());
+
+ let plan = namespace.explain_table_query_plan(request).await.unwrap();
+ assert!(plan.contains("minimum_nprobes=1"), "{plan}");
+ assert!(plan.contains("maximum_nprobes=Some(0)"), "{plan}");
+
+ let query = QueryTableRequest {
+ id: None,
+ k: 2,
+ vector: vector(),
+ nprobes: Some(-1),
+ ..Default::default()
+ };
+ let mut request = ExplainTableQueryPlanRequest::new(query);
+ request.id = Some(table_id);
+
+ let err = namespace
+ .explain_table_query_plan(request)
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("nprobes must be non-negative"));
+ }
+
#[tokio::test]
async fn test_namespace_id() {
let (namespace, _temp_dir) = create_test_namespace().await;
diff --git a/rust/lance/src/dataset/mem_wal/memtable/scanner/builder.rs b/rust/lance/src/dataset/mem_wal/memtable/scanner/builder.rs
index 50f79260583..65da17b8c18 100644
--- a/rust/lance/src/dataset/mem_wal/memtable/scanner/builder.rs
+++ b/rust/lance/src/dataset/mem_wal/memtable/scanner/builder.rs
@@ -633,11 +633,9 @@ impl MemTableScanner {
/// Set the number of probes for IVF search.
///
- /// This is a convenience method that sets both minimum and maximum nprobes
- /// to the same value, guaranteeing exactly `n` partitions will be searched.
+ /// The minimum remains unchanged, so fewer than `n` partitions may be searched.
pub fn nprobes(&mut self, n: usize) -> &mut Self {
if let Some(ref mut q) = self.nearest {
- q.nprobes = n;
q.maximum_nprobes = Some(n);
} else {
log::warn!("nprobes is not set because nearest has not been called yet");
diff --git a/rust/lance/src/dataset/mem_wal/scanner/builder.rs b/rust/lance/src/dataset/mem_wal/scanner/builder.rs
index dbd5e9e4a79..71e34cd28f2 100644
--- a/rust/lance/src/dataset/mem_wal/scanner/builder.rs
+++ b/rust/lance/src/dataset/mem_wal/scanner/builder.rs
@@ -30,6 +30,7 @@ use super::planner::LsmScanPlanner;
use super::point_lookup::LsmPointLookupPlanner;
use super::projection::validate_projection_names;
use super::sstable_cache::{DatasetCache, SsTableWarmer};
+use super::vector_search::ProbeBounds;
use crate::dataset::Dataset;
use crate::dataset::mem_wal::util::derived_store_params;
use crate::session::Session;
@@ -45,8 +46,8 @@ struct LsmVectorQuery {
key: Arc,
/// Number of nearest neighbors to fetch per source before the global merge.
k: usize,
- /// Number of IVF partitions to probe on the base arm.
- nprobes: usize,
+ /// IVF partition probe bounds for indexed arms.
+ probe_bounds: ProbeBounds,
/// Re-rank base candidates with exact distances when set (refine factor is
/// treated as a boolean; the LSM merge needs exact base distances).
refine: bool,
@@ -186,10 +187,11 @@ fn key_to_fsl(key: &dyn Array, dim: i32) -> Result {
/// ```
///
/// The query-building methods mirror [`crate::dataset::scanner::Scanner`]:
-/// [`Self::nearest`] (+ [`Self::nprobes`] / [`Self::refine`] /
-/// [`Self::distance_metric`]) for vector search and [`Self::full_text_search`]
-/// for FTS are state setters, and [`Self::create_plan`] dispatches to the right
-/// planner — so an LSM read reads like a normal scan.
+/// [`Self::nearest`] (+ [`Self::nprobes`] / [`Self::minimum_nprobes`] /
+/// [`Self::maximum_nprobes`] / [`Self::refine`] / [`Self::distance_metric`])
+/// for vector search and [`Self::full_text_search`] for FTS are state setters,
+/// and [`Self::create_plan`] dispatches to the right planner — so an LSM read
+/// reads like a normal scan.
pub struct LsmScanner {
// Data sources
base: BaseSource,
@@ -451,7 +453,8 @@ impl LsmScanner {
/// [`crate::dataset::scanner::Scanner::nearest`]; the LSM path supports a
/// single Float32 query vector. When combined with an offset, the LSM path
/// fetches `k + offset` per source before applying the final page. Tune with
- /// [`Self::nprobes`], [`Self::refine`], and [`Self::distance_metric`].
+ /// [`Self::nprobes`], [`Self::minimum_nprobes`], [`Self::maximum_nprobes`],
+ /// [`Self::refine`], and [`Self::distance_metric`].
pub fn nearest(mut self, column: &str, key: &dyn Array, k: usize) -> Result {
if k == 0 {
return Err(Error::invalid_input("k must be positive".to_string()));
@@ -465,18 +468,40 @@ impl LsmScanner {
column: column.to_string(),
key: key.slice(0, key.len()),
k,
- nprobes: 1,
+ probe_bounds: ProbeBounds::default(),
refine: false,
metric_type: None,
});
Ok(self)
}
- /// Number of IVF partitions to probe on the base arm (default 1). No-op
- /// unless [`Self::nearest`] was called.
+ /// Search up to `nprobes` IVF partitions on indexed arms. No-op unless
+ /// [`Self::nearest`] was called. The minimum remains unchanged.
pub fn nprobes(mut self, nprobes: usize) -> Self {
if let Some(q) = self.nearest.as_mut() {
- q.nprobes = nprobes;
+ q.probe_bounds.maximum_nprobes = Some(nprobes);
+ }
+ self
+ }
+
+ /// Set the minimum number of IVF partitions to search on indexed arms.
+ ///
+ /// When unset, the underlying Lance scanner supplies its default. No-op
+ /// unless [`Self::nearest`] was called.
+ pub fn minimum_nprobes(mut self, minimum_nprobes: usize) -> Self {
+ if let Some(q) = self.nearest.as_mut() {
+ q.probe_bounds.minimum_nprobes = Some(minimum_nprobes);
+ }
+ self
+ }
+
+ /// Set the maximum number of IVF partitions to search on indexed arms.
+ ///
+ /// When unset, all partitions may be searched if needed. No-op unless
+ /// [`Self::nearest`] was called.
+ pub fn maximum_nprobes(mut self, maximum_nprobes: usize) -> Self {
+ if let Some(q) = self.nearest.as_mut() {
+ q.probe_bounds.maximum_nprobes = Some(maximum_nprobes);
}
self
}
@@ -607,10 +632,10 @@ impl LsmScanner {
let per_source_k = nearest.k.saturating_add(self.offset.unwrap_or(0));
let overfetch_factor = self.overfetch_factor.unwrap_or(1.0);
let plan = planner
- .plan_search(
+ .plan_search_with_probe_bounds(
&query_fsl,
per_source_k,
- nearest.nprobes,
+ nearest.probe_bounds,
self.projection.as_deref(),
nearest.refine,
overfetch_factor,
@@ -1245,6 +1270,52 @@ mod tests {
]))
}
+ #[test]
+ fn vector_probe_settings_preserve_adaptive_defaults() {
+ use arrow_array::Float32Array;
+ use arrow_schema::{DataType, Field};
+
+ let schema = pk_schema_with(Field::new(
+ "vector",
+ DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4),
+ false,
+ ));
+ let new_scanner = || {
+ LsmScanner::without_base_table(
+ schema.clone(),
+ "memory://",
+ vec![],
+ vec!["id".to_string()],
+ )
+ .nearest(
+ "vector",
+ &Float32Array::from(vec![0.0f32, 1.0, 2.0, 3.0]),
+ 1,
+ )
+ .unwrap()
+ };
+
+ let scanner = new_scanner();
+ let query = scanner.nearest.as_ref().unwrap();
+ assert_eq!(query.probe_bounds.minimum_nprobes, None);
+ assert_eq!(query.probe_bounds.maximum_nprobes, None);
+
+ let scanner = new_scanner().nprobes(20);
+ let query = scanner.nearest.as_ref().unwrap();
+ assert_eq!(query.probe_bounds.minimum_nprobes, None);
+ assert_eq!(query.probe_bounds.maximum_nprobes, Some(20));
+
+ let scanner = new_scanner().minimum_nprobes(20);
+ let query = scanner.nearest.as_ref().unwrap();
+ assert_eq!(query.probe_bounds.minimum_nprobes, Some(20));
+ assert_eq!(query.probe_bounds.maximum_nprobes, None);
+
+ let scanner = new_scanner().maximum_nprobes(20);
+ let query = scanner.nearest.as_ref().unwrap();
+ assert_eq!(query.probe_bounds.minimum_nprobes, None);
+ assert_eq!(query.probe_bounds.maximum_nprobes, Some(20));
+ }
+
/// `LsmScanner::nearest(..).create_plan()` must route through the vector
/// planner (exercising the Scanner-aligned facade, `key_to_fsl`, and
/// `vector_dim`) and surface the in-memory match.
diff --git a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs
index c808a4e3a5a..9c3c97d6bc0 100644
--- a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs
+++ b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs
@@ -36,6 +36,21 @@ use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable};
use crate::session::Session;
use lance_io::object_store::ObjectStoreParams;
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(super) struct ProbeBounds {
+ pub minimum_nprobes: Option,
+ pub maximum_nprobes: Option,
+}
+
+impl ProbeBounds {
+ pub(super) fn maximum(nprobes: usize) -> Self {
+ Self {
+ minimum_nprobes: None,
+ maximum_nprobes: Some(nprobes),
+ }
+ }
+}
+
/// Plans vector search queries over LSM data.
///
/// Each source is independently newest-per-PK before the union — the active
@@ -217,7 +232,7 @@ impl LsmVectorSearchPlanner {
///
/// * `query_vector` - Query vector for KNN search
/// * `k` - Number of nearest neighbors to return
- /// * `nprobes` - Number of IVF partitions to search (for IVF-based indexes)
+ /// * `nprobes` - Maximum number of IVF partitions to search (for IVF-based indexes)
/// * `projection` - Columns to include in output (None = all columns)
/// * `refine_base_table` - When true, the base-table arm re-ranks its
/// candidates with exact distances (refine factor 1). Useful when the base
@@ -240,7 +255,6 @@ impl LsmVectorSearchPlanner {
///
/// An execution plan that returns the top-K nearest neighbors across all
/// LSM levels, with stale results filtered out.
- #[instrument(name = "lsm_vector_search", level = "info", skip_all, fields(k, nprobes, vector_column = %self.vector_column, distance_type = ?self.distance_type))]
pub async fn plan_search(
&self,
query_vector: &FixedSizeListArray,
@@ -249,12 +263,51 @@ impl LsmVectorSearchPlanner {
projection: Option<&[String]>,
refine_base_table: bool,
overfetch_factor: f64,
+ ) -> Result> {
+ if nprobes == 0 {
+ return Err(Error::invalid_input("nprobes must be positive".to_string()));
+ }
+ self.plan_search_with_probe_bounds(
+ query_vector,
+ k,
+ ProbeBounds::maximum(nprobes),
+ projection,
+ refine_base_table,
+ overfetch_factor,
+ )
+ .await
+ }
+
+ #[instrument(name = "lsm_vector_search", level = "info", skip_all, fields(k, minimum_nprobes = ?probe_bounds.minimum_nprobes, maximum_nprobes = ?probe_bounds.maximum_nprobes, vector_column = %self.vector_column, distance_type = ?self.distance_type))]
+ pub(super) async fn plan_search_with_probe_bounds(
+ &self,
+ query_vector: &FixedSizeListArray,
+ k: usize,
+ probe_bounds: ProbeBounds,
+ projection: Option<&[String]>,
+ refine_base_table: bool,
+ overfetch_factor: f64,
) -> Result> {
if k == 0 {
return Err(Error::invalid_input("k must be positive".to_string()));
}
- if nprobes == 0 {
- return Err(Error::invalid_input("nprobes must be positive".to_string()));
+ if probe_bounds.minimum_nprobes == Some(0) {
+ return Err(Error::invalid_input(
+ "minimum_nprobes must be positive".to_string(),
+ ));
+ }
+ if probe_bounds.maximum_nprobes == Some(0) {
+ return Err(Error::invalid_input(
+ "maximum_nprobes must be positive".to_string(),
+ ));
+ }
+ if let (Some(minimum_nprobes), Some(maximum_nprobes)) =
+ (probe_bounds.minimum_nprobes, probe_bounds.maximum_nprobes)
+ && minimum_nprobes > maximum_nprobes
+ {
+ return Err(Error::invalid_input(format!(
+ "minimum_nprobes ({minimum_nprobes}) must not exceed maximum_nprobes ({maximum_nprobes})"
+ )));
}
let sources = self.collector.collect()?;
@@ -319,7 +372,7 @@ impl LsmVectorSearchPlanner {
source,
query_vector,
*fetch_k,
- nprobes,
+ probe_bounds,
projection,
*is_base && refine_base,
))
@@ -442,7 +495,7 @@ impl LsmVectorSearchPlanner {
source: &LsmDataSource,
query_vector: &FixedSizeListArray,
k: usize,
- nprobes: usize,
+ probe_bounds: ProbeBounds,
projection: Option<&[String]>,
refine: bool,
) -> Result> {
@@ -471,7 +524,12 @@ impl LsmVectorSearchPlanner {
let query_arr = single_query_array(query_vector);
scanner.nearest(&self.vector_column, query_arr.as_ref(), k)?;
scanner.distance_range(self.distance_range.0, self.distance_range.1);
- scanner.nprobes(nprobes);
+ if let Some(minimum_nprobes) = probe_bounds.minimum_nprobes {
+ scanner.minimum_nprobes(minimum_nprobes);
+ }
+ if let Some(maximum_nprobes) = probe_bounds.maximum_nprobes {
+ scanner.maximum_nprobes(maximum_nprobes);
+ }
scanner.distance_metric(self.distance_type);
if let Some(ef) = self.ef {
scanner.ef(ef);
@@ -508,7 +566,12 @@ impl LsmVectorSearchPlanner {
let query_arr = single_query_array(query_vector);
scanner.nearest(&self.vector_column, query_arr.as_ref(), k)?;
scanner.distance_range(self.distance_range.0, self.distance_range.1);
- scanner.nprobes(nprobes);
+ if let Some(minimum_nprobes) = probe_bounds.minimum_nprobes {
+ scanner.minimum_nprobes(minimum_nprobes);
+ }
+ if let Some(maximum_nprobes) = probe_bounds.maximum_nprobes {
+ scanner.maximum_nprobes(maximum_nprobes);
+ }
scanner.distance_metric(self.distance_type);
if let Some(ef) = self.ef {
scanner.ef(ef);
@@ -541,7 +604,12 @@ impl LsmVectorSearchPlanner {
}
scanner.nearest(&self.vector_column, query_vector, k)?;
scanner.distance_range(self.distance_range.0, self.distance_range.1);
- scanner.nprobes(nprobes);
+ if let Some(minimum_nprobes) = probe_bounds.minimum_nprobes {
+ scanner.minimum_nprobes(minimum_nprobes);
+ }
+ if let Some(maximum_nprobes) = probe_bounds.maximum_nprobes {
+ scanner.maximum_nprobes(maximum_nprobes);
+ }
scanner.distance_metric(self.distance_type);
if let Some(ef) = self.ef {
scanner.ef(ef);
@@ -792,6 +860,45 @@ mod tests {
err.to_string().contains("nprobes must be positive"),
"expected nprobes validation error, got {err}"
);
+
+ let err = planner
+ .plan_search_with_probe_bounds(
+ &query,
+ 1,
+ ProbeBounds {
+ minimum_nprobes: None,
+ maximum_nprobes: Some(0),
+ },
+ None,
+ false,
+ 1.0,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("maximum_nprobes must be positive"),
+ "expected maximum_nprobes validation error, got {err}"
+ );
+
+ let err = planner
+ .plan_search_with_probe_bounds(
+ &query,
+ 1,
+ ProbeBounds {
+ minimum_nprobes: Some(2),
+ maximum_nprobes: Some(1),
+ },
+ None,
+ false,
+ 1.0,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("minimum_nprobes (2) must not exceed maximum_nprobes (1)"),
+ "expected probe-bound ordering error, got {err}"
+ );
}
#[tokio::test]
diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs
index 28f4d341470..2a6a1f8c75a 100644
--- a/rust/lance/src/dataset/scanner.rs
+++ b/rust/lance/src/dataset/scanner.rs
@@ -2047,13 +2047,12 @@ impl Scanner {
self
}
- /// Configures how many partititions will be searched in the vector index.
+ /// Configures the maximum number of partitions searched in the vector index.
///
- /// This method is a convenience method that sets both [Self::minimum_nprobes] and
- /// [Self::maximum_nprobes] to the same value.
+ /// The minimum remains unchanged, so the search may stop before reaching this
+ /// value when enough results have been found.
pub fn nprobes(&mut self, n: usize) -> &mut Self {
if let Some(q) = self.nearest.as_mut() {
- q.minimum_nprobes = n;
q.maximum_nprobes = Some(n);
} else {
log::warn!("nprobes is not set because nearest has not been called yet");
@@ -2061,14 +2060,10 @@ impl Scanner {
self
}
- /// Configures how many partititions will be searched in the vector index.
- ///
- /// This method is a convenience method that sets both [Self::minimum_nprobes] and
- /// [Self::maximum_nprobes] to the same value.
+ /// Configures the maximum number of partitions searched in the vector index.
#[deprecated(note = "Use nprobes instead")]
pub fn nprobs(&mut self, n: usize) -> &mut Self {
if let Some(q) = self.nearest.as_mut() {
- q.minimum_nprobes = n;
q.maximum_nprobes = Some(n);
} else {
log::warn!("nprobes is not set because nearest has not been called yet");
@@ -9324,7 +9319,7 @@ mod test {
k: usize,
use_index: bool,
distance_range: Option<(Option, Option)>,
- nprobes: Option,
+ fixed_nprobes: Option,
) {
let query_count = query_values.len() / 32;
assert_eq!(batch.num_rows(), query_count * k);
@@ -9338,8 +9333,8 @@ mod test {
// Pin nprobes to match the batch query: the single-query indexed path
// otherwise adaptively expands nprobes, which would make equivalence
// depend on data distribution rather than be guaranteed.
- if let Some(nprobes) = nprobes {
- scan.nprobes(nprobes);
+ if let Some(nprobes) = fixed_nprobes {
+ scan.minimum_nprobes(nprobes).maximum_nprobes(nprobes);
}
if let Some((lower, upper)) = distance_range {
scan.distance_range(lower, upper);
@@ -9799,7 +9794,7 @@ mod test {
// merged across multiple partitions and the batch result is
// deterministically equivalent to repeated single-query search (which
// would otherwise adaptively expand nprobes).
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -9821,7 +9816,7 @@ mod test {
// The batch node loads each probed partition once and scores every query
// that probes it, so it must report the *distinct* partitions read: with
- // 2 partitions and nprobes(2), both queries probe both partitions, so the
+ // 2 partitions and fixed bounds of 2, both queries probe both partitions, so the
// union is 2 -- not the per-query sum (2 queries x 2 = 4), and never 0
// (which is what a dropped metric would show). This guards the observed
// `partitions_searched` against silently regressing to either.
@@ -9851,7 +9846,8 @@ mod test {
.scan()
.nearest("vec", &queries, 2)
.unwrap()
- .nprobes(2)
+ .minimum_nprobes(2)
+ .maximum_nprobes(2)
.distance_range(Some(1.0), None)
.project(&["i"])
.unwrap()
@@ -9918,7 +9914,7 @@ mod test {
let k = 5;
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(1);
+ scan.minimum_nprobes(1).maximum_nprobes(1);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -10057,8 +10053,8 @@ mod test {
.await;
}
- /// `nprobes(0)` is not rejected by the query builder, so `minimum_nprobes ==
- /// maximum_nprobes == 0` slips past the fixed-nprobes gate. The single-query
+ /// `nprobes(0)` is not rejected by the query builder, so `maximum_nprobes == 0`
+ /// reaches the adaptive path. The single-query
/// path then probes nothing and returns an empty result, whereas the batch
/// node would clamp `nprobes` up to one partition — a silent divergence. The
/// scanner must fall back so the per-query loop defines the semantics of
@@ -10139,7 +10135,7 @@ mod test {
// batch-eligible, so the mask is the only thing that forces the fallback.
let mut unmasked = dataset.scan();
unmasked.nearest("vec", &queries, k).unwrap();
- unmasked.nprobes(2);
+ unmasked.minimum_nprobes(2).maximum_nprobes(2);
unmasked.project(&["i"]).unwrap();
let unmasked_plan = unmasked.explain_plan(false).await.unwrap();
assert!(
@@ -10157,7 +10153,7 @@ mod test {
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.with_row_addr_prefilter(RowAddrMask::from_allowed(RowAddrTreeMap::from_iter(
allow.iter().copied(),
)));
@@ -10216,7 +10212,7 @@ mod test {
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
// Request both indexed fragments but select only the segment covering
// fragment 0; fragment 1 is covered only by the unselected segment.
scan.with_fragments(vec![fragments[0].clone(), fragments[1].clone()]);
@@ -10310,7 +10306,8 @@ mod test {
scan.nearest("vec", &queries, k).unwrap();
// Probe every partition so both paths are exact regardless of centroid
// proximity, and so the batch spans multiple streaming chunks.
- scan.nprobes(num_partitions);
+ scan.minimum_nprobes(num_partitions)
+ .maximum_nprobes(num_partitions);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -10347,7 +10344,7 @@ mod test {
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -10393,7 +10390,7 @@ mod test {
let mut scan = dataset.scan();
scan.nearest("vec", &queries, 2).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -10423,7 +10420,7 @@ mod test {
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.filter("i > 100").unwrap();
scan.prefilter(true);
scan.project(&["i"]).unwrap();
@@ -10455,7 +10452,8 @@ mod test {
.scan()
.nearest("vec", &query, k)
.unwrap()
- .nprobes(2)
+ .minimum_nprobes(2)
+ .maximum_nprobes(2)
.filter("i > 100")
.unwrap()
.prefilter(true)
@@ -10506,7 +10504,7 @@ mod test {
let k = 3;
let mut scan = dataset.scan();
scan.nearest("vec", &queries, k).unwrap();
- scan.nprobes(2);
+ scan.minimum_nprobes(2).maximum_nprobes(2);
scan.project(&["i"]).unwrap();
let plan = scan.explain_plan(false).await.unwrap();
@@ -16285,6 +16283,31 @@ full_filter=name LIKE Utf8(\"test%2\"), refine_filter=name LIKE Utf8(\"test%2\")
);
}
+ #[tokio::test]
+ async fn test_knn_probe_setters_preserve_independent_fields() {
+ let test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false)
+ .await
+ .unwrap();
+ let query_vector = Float32Array::from(vec![0.0; 32]);
+ let mut scanner = test_ds.dataset.scan();
+ scanner.nearest("vec", &query_vector, 5).unwrap();
+
+ scanner.nprobes(20);
+ let query = scanner.nearest_mut().unwrap();
+ assert_eq!(query.minimum_nprobes, 1);
+ assert_eq!(query.maximum_nprobes, Some(20));
+
+ scanner.minimum_nprobes(5);
+ let query = scanner.nearest_mut().unwrap();
+ assert_eq!(query.minimum_nprobes, 5);
+ assert_eq!(query.maximum_nprobes, Some(20));
+
+ scanner.maximum_nprobes(10);
+ let query = scanner.nearest_mut().unwrap();
+ assert_eq!(query.minimum_nprobes, 5);
+ assert_eq!(query.maximum_nprobes, Some(10));
+ }
+
#[tokio::test]
async fn test_ivf_pq_query_parallelism_returns_same_results() {
let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false)
diff --git a/rust/lance/src/dataset/tests/dataset_migrations.rs b/rust/lance/src/dataset/tests/dataset_migrations.rs
index 9e2d2078307..a10a2319d07 100644
--- a/rust/lance/src/dataset/tests/dataset_migrations.rs
+++ b/rust/lance/src/dataset/tests/dataset_migrations.rs
@@ -285,7 +285,8 @@ async fn test_v0_8_14_invalid_index_fragment_bitmap(
let batches = scan
.nearest("vector", &query_vec, 2000)
.unwrap()
- .nprobes(4)
+ .minimum_nprobes(4)
+ .maximum_nprobes(4)
.prefilter(true)
.try_into_stream()
.await
diff --git a/rust/lance/src/dataset/tests/dataset_overlay_index_masking.rs b/rust/lance/src/dataset/tests/dataset_overlay_index_masking.rs
index 6695940ccfb..d4fb926cdce 100644
--- a/rust/lance/src/dataset/tests/dataset_overlay_index_masking.rs
+++ b/rust/lance/src/dataset/tests/dataset_overlay_index_masking.rs
@@ -652,7 +652,8 @@ async fn test_vector_batch_falls_back_on_overlay(#[values(false, true)] stable_r
scanner
.nearest("vec", queries.as_ref(), 3)
.unwrap()
- .nprobes(1)
+ .minimum_nprobes(1)
+ .maximum_nprobes(1)
.project(&["id"])
.unwrap();
let plan = scanner.explain_plan(false).await.unwrap();
@@ -667,7 +668,8 @@ async fn test_vector_batch_falls_back_on_overlay(#[values(false, true)] stable_r
scanner
.nearest("vec", queries.as_ref(), 3)
.unwrap()
- .nprobes(1)
+ .minimum_nprobes(1)
+ .maximum_nprobes(1)
.project(&["id"])
.unwrap();
let plan = scanner.explain_plan(false).await.unwrap();
diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs
index 415958a438a..c1e07192bf6 100644
--- a/rust/lance/src/index.rs
+++ b/rust/lance/src/index.rs
@@ -11397,7 +11397,8 @@ mod tests {
.scan()
.nearest("vector", &Float32Array::from(query_vector), 10)
.unwrap()
- .nprobes(2)
+ .minimum_nprobes(2)
+ .maximum_nprobes(2)
.try_into_batch()
.await
.unwrap();
diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs
index 245c861de72..40172a40f5a 100644
--- a/rust/lance/src/index/append.rs
+++ b/rust/lance/src/index/append.rs
@@ -1502,7 +1502,8 @@ mod tests {
.unwrap()
.nearest("vector", query, 1)
.unwrap()
- .nprobes(num_probes)
+ .minimum_nprobes(num_probes)
+ .maximum_nprobes(num_probes)
.refine(1)
.try_into_batch()
.await
@@ -2541,7 +2542,8 @@ mod tests {
.unwrap()
.nearest("vector", array.value(0).as_primitive::(), 2)
.unwrap()
- .nprobes(2)
+ .minimum_nprobes(2)
+ .maximum_nprobes(2)
.refine(1);
let fanout_plan = fanout_scanner.explain_plan(true).await.unwrap();
assert!(
@@ -2563,7 +2565,8 @@ mod tests {
.unwrap()
.nearest("vector", array.value(0).as_primitive::(), 1)
.unwrap()
- .nprobes(2)
+ .minimum_nprobes(2)
+ .maximum_nprobes(2)
.refine(1)
.with_index_segments(vec![segment.uuid])
.unwrap();
@@ -2867,7 +2870,8 @@ mod tests {
.unwrap()
.nearest("vector", &query, 5)
.unwrap()
- .nprobes(1)
+ .minimum_nprobes(1)
+ .maximum_nprobes(1)
.try_into_batch()
.await
.unwrap();
diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs
index d4f3134c2d4..afecd76081d 100644
--- a/rust/lance/src/index/vector/ivf/v2.rs
+++ b/rust/lance/src/index/vector/ivf/v2.rs
@@ -5697,7 +5697,8 @@ mod tests {
.scan()
.nearest("vector", query.as_primitive::(), PQ_MATRIX_K)
.unwrap()
- .nprobes(nlist)
+ .minimum_nprobes(nlist)
+ .maximum_nprobes(nlist)
.with_row_id()
.try_into_batch()
.await
@@ -7226,7 +7227,8 @@ mod tests {
.scan()
.nearest(vector_column, query.as_primitive::(), k)
.unwrap()
- .nprobes(nlist)
+ .minimum_nprobes(nlist)
+ .maximum_nprobes(nlist)
.with_row_id()
.try_into_batch()
.await
@@ -8674,7 +8676,8 @@ mod tests {
.with_row_id()
.nearest("vector", &q, 10)
.unwrap()
- .nprobes(4)
+ .minimum_nprobes(4)
+ .maximum_nprobes(4)
.project(&["_rowid"])
.unwrap()
.try_into_batch()
@@ -8721,7 +8724,8 @@ mod tests {
.scan()
.nearest("vector", &q, 10)
.unwrap()
- .nprobes(4)
+ .minimum_nprobes(4)
+ .maximum_nprobes(4)
.project(&["_rowid"])
.unwrap()
.try_into_batch()