Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
4 changes: 2 additions & 2 deletions java/lance-jni/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions java/src/main/java/org/lance/ipc/Query.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>This is a convenience method that sets both the minimum and maximum number of probes to
* the same value.
* <p>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;
}
Expand Down
9 changes: 9 additions & 0 deletions java/src/test/java/org/lance/JNITest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 2 additions & 11 deletions python/python/lance/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions python/python/tests/test_row_addr_prefilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
15 changes: 14 additions & 1 deletion python/python/tests/test_vector_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion python/src/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
114 changes: 107 additions & 7 deletions rust/lance-namespace-impls/src/dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,29 @@ impl TransactionAlteration {
}
}

fn apply_probe_bounds(
scanner: &mut Scanner,
nprobes: Option<i32>,
minimum_nprobes: Option<i32>,
maximum_nprobes: Option<i32>,
) -> 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<manifest::ManifestNamespace>> {
self.write_manifest_ns
Expand Down Expand Up @@ -3170,6 +3193,8 @@ impl DirectoryNamespace {
prefilter: Option<bool>,
bypass_vector_index: Option<bool>,
nprobes: Option<i32>,
minimum_nprobes: Option<i32>,
maximum_nprobes: Option<i32>,
ef: Option<i32>,
refine_factor: Option<i32>,
distance_type: Option<&str>,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions rust/lance/src/dataset/mem_wal/memtable/scanner/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading