Skip to content
Merged
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,46 @@ Based on the [liblance RFC](https://github.com/lance-format/lance/discussions/60
| [x] | Dataset metadata | `lance_dataset_version()`, `lance_dataset_count_rows()`, `lance_dataset_latest_version()` |
| [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression`; `lance_scanner_additional_sql_filter()` adds SQL predicates with AND before scanning starts |

## Distance-bounded vector search

After configuring a single-vector nearest-neighbor query, use
`lance_scanner_set_distance_range` or C++ `Scanner::distance_range` to restrict
results to `lower_bound <= _distance < upper_bound`:

```cpp
const float query[] = {1.0f, 0.0f};
auto scanner = dataset.scan();
scanner.nearest("embedding", query, 2, 100)
.metric(LANCE_METRIC_L2)
.distance_range(std::nullopt, 0.5f);
```

This returns **at most 100** neighbors with distance below `0.5`. It is a
range-constrained Top-K search, not an unbounded enumeration of all matches.
For an annulus, use `.distance_range(0.2f, 0.5f)`; for a lower bound only, use
`.distance_range(0.2f)`; `.distance_range()` clears both bounds. In C, pass
pointers to bounds and `NULL` for an unbounded side:

```c
float upper_bound = 0.5f;
int32_t status = lance_scanner_set_distance_range(scanner, NULL, &upper_bound);
/* Check status and lance_last_error_* before starting the scan. */
```

Bounds are copied and must be finite. When both are set, the lower bound must
be strictly smaller than the upper bound. Negative bounds are allowed (for
example, Dot distances can be negative). Distances use the selected metric's
units: L2 reports **squared Euclidean distance**, so a geometric radius `r`
corresponds to an upper bound of `r * r`, with the boundary excluded. Index-based
search retains Lance's approximate candidate selection; the range does not
guarantee exhaustive recall. Use `.use_index(false)` for an exact scan, still
subject to `k`.

Set bounds after `nearest` and before starting the scan. Replacing the nearest
query clears the bounds; an invalid range leaves the previous range unchanged.
Multi-vector queries are not supported by this setter because their scores
aggregate distances across subvectors.

## Multi-vector search

Use `lance_scanner_nearest_multivector` or the C++ `Scanner::nearest_multivector`
Expand Down
25 changes: 25 additions & 0 deletions include/lance/lance.h
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,31 @@ int32_t lance_scanner_nearest(
uint32_t k
);

/**
* Restrict a single-vector nearest query to lower_bound <= _distance < upper_bound.
*
* Call after lance_scanner_nearest and before starting the scan. Both bounds
* are copied before returning; NULL means unbounded on that side. Passing NULL
* for both clears the range. A successful replacement nearest query also clears
* the range. Multi-vector queries and scans without nearest are rejected.
*
* Bounds must be finite; negative distances are allowed. When both are present,
* lower_bound must be strictly smaller than upper_bound. Invalid calls leave the
* previous range unchanged. Distances use the configured metric's units (L2 is
* squared Euclidean distance). Results still have the nearest query's k cap;
* index-based search remains approximate, not an exhaustive range enumeration.
*
* @param scanner Scanner with a single-vector nearest query.
* @param lower_bound Inclusive lower bound, or NULL.
* @param upper_bound Exclusive upper bound, or NULL.
* @return 0 on success, -1 on error (check lance_last_error_*).
*/
int32_t lance_scanner_set_distance_range(
LanceScanner* scanner,
const float* lower_bound,
const float* upper_bound
);

/**
* Set one multi-vector query on a List<FixedSizeList<float16|float32|float64>> column.
* Inner vectors must be non-nullable and contain no null elements; the outer list may be nullable.
Expand Down
14 changes: 14 additions & 0 deletions include/lance/lance.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,20 @@ class Scanner {
return *this;
}

/// Restrict single-vector nearest results to [lower_bound, upper_bound).
/// Call after nearest() and before scanning. Omitted bounds are unbounded;
/// distance_range() clears both. Bounds must be finite and lower < upper
/// when both are present. Uses metric distance units (squared L2 for L2).
/// The nearest query's k cap and ANN candidate selection still apply.
Scanner& distance_range(std::optional<float> lower_bound = std::nullopt,
std::optional<float> upper_bound = std::nullopt) {
if (lance_scanner_set_distance_range(handle_.get(),
lower_bound ? &*lower_bound : nullptr,
upper_bound ? &*upper_bound : nullptr) != 0)
check_error();
return *this;
}

/// One multi-vector query, copied from dimension * num_vectors row-major elements.
Scanner& nearest_multivector(const std::string& column, const void* query_data,
size_t dimension, size_t num_vectors,
Expand Down
69 changes: 69 additions & 0 deletions src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ struct NearestQuery {
column: String,
query: arrow_array::ArrayRef,
k: u32,
lower_bound: Option<f32>,
upper_bound: Option<f32>,
}

/// The effective adaptive partition-search range shared by all three nprobes
Expand Down Expand Up @@ -444,6 +446,7 @@ impl LanceScanner {
}
if let Some(n) = &self.nearest {
scanner.nearest(&n.column, n.query.as_ref(), n.k as usize)?;
scanner.distance_range(n.lower_bound, n.upper_bound);
if let Some(minimum_nprobes) = self.nprobes.minimum {
scanner.minimum_nprobes(minimum_nprobes as usize);
}
Expand Down Expand Up @@ -2502,6 +2505,68 @@ macro_rules! scanner_set_u32 {
scanner_set_u32!(lance_scanner_set_refine_factor, refine_factor);
scanner_set_u32!(lance_scanner_set_ef, ef);

/// Set inclusive lower and exclusive upper distance bounds on a single-vector query.
///
/// NULL bounds are unbounded. Values are copied and must be finite, with lower < upper
/// when both are present. Call after nearest and before scanning; k still caps results.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn lance_scanner_set_distance_range(
scanner: *mut LanceScanner,
lower_bound: *const f32,
upper_bound: *const f32,
) -> i32 {
scanner_poison_check!(scanner, -1);
scanner_ffi_try!(scanner, unsafe {
scanner_set_distance_range_inner(scanner, lower_bound, upper_bound)
})
}

unsafe fn scanner_set_distance_range_inner(
scanner: *mut LanceScanner,
lower_bound: *const f32,
upper_bound: *const f32,
) -> Result<i32> {
let invalid = |message: String| lance_core::Error::invalid_input_source(message.into());
if scanner.is_null() {
return Err(invalid("scanner is NULL".into()));
}
let scanner = unsafe { &mut *scanner };
scanner.ensure_scan_not_started("distance_range")?;
let nearest = scanner
.nearest
.as_mut()
.ok_or_else(|| invalid("distance_range requires nearest() to be configured".into()))?;
// Multi-vector scores aggregate subvectors; filtering each subvector's
// candidates would not enforce the requested range on the final score.
if matches!(
nearest.query.data_type(),
arrow_schema::DataType::FixedSizeList(_, _)
) {
return Err(invalid(
"distance_range does not support multi-vector queries".into(),
));
}
let lower_bound = unsafe { lower_bound.as_ref() }.copied();
let upper_bound = unsafe { upper_bound.as_ref() }.copied();
for (name, bound) in [("lower_bound", lower_bound), ("upper_bound", upper_bound)] {
if let Some(value) = bound
&& !value.is_finite()
{
return Err(invalid(format!("{name} must be finite, got {value}")));
}
}
if let (Some(lower), Some(upper)) = (lower_bound, upper_bound)
&& lower >= upper
{
return Err(invalid(format!(
"lower_bound ({lower}) must be less than upper_bound ({upper})"
)));
}
nearest.lower_bound = lower_bound;
nearest.upper_bound = upper_bound;
Ok(0)
}

/// Set both vector-index partition-search bounds to the same value.
///
/// This replaces any values previously configured through
Expand Down Expand Up @@ -2851,6 +2916,8 @@ unsafe fn scanner_nearest_inner(
column: column_str.to_string(),
query,
k,
lower_bound: None,
upper_bound: None,
});
Ok(0)
}
Expand Down Expand Up @@ -3005,6 +3072,8 @@ unsafe fn nearest_multivector_inner(
column: column.to_string(),
query: Arc::new(query),
k,
lower_bound: None,
upper_bound: None,
});
Ok(0)
}
Expand Down
Loading
Loading