use std::collections::BTreeSet;
use std::ops::Range;
use std::sync::Arc;
use arrow_array::{Float32Array, RecordBatch, UInt64Array};
use arrow_schema::{DataType, Field, Schema, SchemaRef};
use datafusion::physical_plan::{SendableRecordBatchStream, stream::RecordBatchStreamAdapter};
use futures::{StreamExt, TryStreamExt, stream};
use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
use lance_core::{Error, Result};
use lance_index::metrics::NoOpMetricsCollector;
use lance_index::prefilter::PreFilter;
use lance_index::vector::{
VectorIndex,
pairwise::{PAIRWISE_MEMORY_LIMIT, PairwisePartition, PairwiseVectorBatch},
};
use lance_select::RowAddrMask;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use uuid::Uuid;
use crate::Dataset;
use crate::index::{
DatasetIndexExt, DatasetIndexInternalExt, prefilter::DatasetPreFilter,
segment_has_vector_details,
};
const MAX_VECTOR_BATCH_SIZE: usize = 8192;
const ANCHOR_BLOCK_ROWS: usize = 32;
const MIN_OFFLOAD_WORK_BYTES: usize = 1024 * 1024;
const MIN_LIVE_SPILLED_BATCHES: usize = 3;
#[derive(Clone, Copy, Debug)]
pub struct DuplicatePairsOptions {
memory_limit: usize,
max_concurrency: usize,
}
impl Default for DuplicatePairsOptions {
fn default() -> Self {
Self {
memory_limit: PAIRWISE_MEMORY_LIMIT,
max_concurrency: get_num_compute_intensive_cpus(),
}
}
}
impl DuplicatePairsOptions {
pub fn with_memory_limit(mut self, memory_limit: usize) -> Self {
self.memory_limit = memory_limit;
self
}
pub fn with_max_concurrency(mut self, max_concurrency: usize) -> Self {
self.max_concurrency = max_concurrency;
self
}
}
fn pair_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("row_id_a", DataType::UInt64, false),
Field::new("row_id_b", DataType::UInt64, false),
Field::new("distance", DataType::Float32, false),
]))
}
pub async fn find_duplicate_pairs(
dataset: Arc<Dataset>,
column: &str,
distance_threshold: f32,
) -> Result<SendableRecordBatchStream> {
find_duplicate_pairs_with_options(
dataset,
column,
distance_threshold,
DuplicatePairsOptions::default(),
)
.await
}
pub async fn find_duplicate_pairs_with_options(
dataset: Arc<Dataset>,
column: &str,
distance_threshold: f32,
options: DuplicatePairsOptions,
) -> Result<SendableRecordBatchStream> {
plan(dataset, column, None, distance_threshold, options).await
}
pub async fn find_duplicate_pairs_in_partition(
dataset: Arc<Dataset>,
column: &str,
segment_id: Uuid,
partition_id: usize,
distance_threshold: f32,
) -> Result<SendableRecordBatchStream> {
find_duplicate_pairs_in_partition_with_options(
dataset,
column,
segment_id,
partition_id,
distance_threshold,
DuplicatePairsOptions::default(),
)
.await
}
pub async fn find_duplicate_pairs_in_partition_with_options(
dataset: Arc<Dataset>,
column: &str,
segment_id: Uuid,
partition_id: usize,
distance_threshold: f32,
options: DuplicatePairsOptions,
) -> Result<SendableRecordBatchStream> {
plan(
dataset,
column,
Some((segment_id, partition_id)),
distance_threshold,
options,
)
.await
}
async fn plan(
dataset: Arc<Dataset>,
column: &str,
selection: Option<(Uuid, usize)>,
threshold: f32,
options: DuplicatePairsOptions,
) -> Result<SendableRecordBatchStream> {
if options.max_concurrency == 0 {
return Err(Error::invalid_input(
"max_concurrency must be positive, got 0",
));
}
if !threshold.is_finite() {
return Err(Error::invalid_input(format!(
"distance_threshold must be finite, got {threshold}"
)));
}
let field = dataset.schema().field_id(column)?;
let indices = dataset.load_indices().await?;
let names = indices
.iter()
.filter(|m| m.keyed_fields() == [field] && segment_has_vector_details(m))
.map(|m| m.name.as_str())
.collect::<BTreeSet<_>>();
if names.len() != 1 {
return Err(Error::invalid_input(format!(
"column '{column}' requires exactly one vector index, found {}",
names.len()
)));
}
let name = names
.into_iter()
.next()
.ok_or_else(|| Error::internal("missing resolved vector index"))?;
if selection.is_none() && !dataset.unindexed_fragments(name).await?.is_empty() {
return Err(Error::invalid_input(format!(
"vector index on column '{column}' does not cover all fragments; optimize the index first"
)));
}
let segments = indices
.iter()
.filter(|meta| meta.name == name && meta.keyed_fields() == [field])
.collect::<Vec<_>>();
if let Some((id, _)) = selection
&& !segments.iter().any(|m| m.uuid == id)
{
return Err(Error::invalid_input(format!(
"segment_id={id} is not an active segment of column '{column}'"
)));
}
let mut partitions = Vec::new();
for meta in segments
.into_iter()
.filter(|meta| selection.is_none_or(|(id, _)| id == meta.uuid))
{
let index = dataset
.open_vector_index(column, &meta.uuid, &NoOpMetricsCollector)
.await?;
if !index.supports_pairwise_vectors() {
return Err(Error::not_supported(format!(
"segment {} requires a current-format vector index; rebuild the index",
meta.uuid
)));
}
for fragment in dataset.fragments().iter() {
if meta
.fragment_bitmap
.as_ref()
.is_none_or(|ids| ids.contains(fragment.id as u32))
&& fragment.overlays.iter().any(|overlay| {
overlay.committed_version > meta.dataset_version
&& overlay.data_file.fields.contains(&field)
})
{
return Err(Error::invalid_input(format!(
"column '{column}' has stale index values in segment {} after an overlay update; rebuild the index",
meta.uuid
)));
}
}
let mask = if dataset.manifest().uses_stable_row_ids() {
DatasetPreFilter::do_create_deletion_mask_row_id(
dataset.clone(),
meta.fragment_bitmap.clone(),
)
.await?
} else {
let filter = DatasetPreFilter::new(dataset.clone(), std::slice::from_ref(meta), None);
filter.wait_for_ready().await?;
filter.mask()
};
let count = index.ivf_model().num_partitions();
if let Some((_, p)) = selection {
if p >= count {
return Err(Error::invalid_input(format!(
"partition_id={p} out of range 0..{count} for segment {}",
meta.uuid
)));
}
partitions.push(Partition { index, id: p, mask });
} else {
partitions.extend((0..count).map(|id| Partition {
index: index.clone(),
id,
mask: mask.clone(),
}));
}
}
let session = dataset.session();
let stream = stream::iter(partitions)
.then(move |partition| partition_stream(partition, session.clone(), threshold, options))
.map_err(datafusion::error::DataFusionError::from)
.try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(
pair_schema(),
stream,
)))
}
struct Partition {
index: Arc<dyn VectorIndex>,
id: usize,
mask: Arc<RowAddrMask>,
}
async fn partition_stream(
partition: Partition,
session: Arc<crate::session::Session>,
threshold: f32,
options: DuplicatePairsOptions,
) -> Result<SendableRecordBatchStream> {
let count = partition.index.partition_size(partition.id);
let schema = pair_schema();
if count == 0 {
return Ok(Box::pin(RecordBatchStreamAdapter::new(
schema,
stream::empty(),
)));
}
let prepared = partition
.index
.prepare_pairwise_partition(
partition.id,
MAX_VECTOR_BATCH_SIZE,
options.memory_limit,
session.spill_store(),
)
.await?;
let live_batches = prepared.is_spilled().then(|| {
let batch_bytes = prepared
.vector_batch_size()
.saturating_mul(prepared.row_bytes())
.max(1);
Arc::new(Semaphore::new(
(options.memory_limit / batch_bytes).max(MIN_LIVE_SPILLED_BATCHES),
))
});
let state = TileJobs {
num_batches: prepared.num_batches(),
live_batches,
prepared: Arc::new(prepared),
filter: partition.mask,
threshold,
schema: schema.clone(),
anchor_batch: 0,
candidate_batch: 0,
anchor: None,
candidates: None,
next_row: 0,
};
let jobs = stream::try_unfold(state, |mut state| async move {
state
.next_job()
.await
.map(|job| job.map(|job| (job, state)))
});
let batches = jobs
.map_ok(|job| job.score())
.try_buffered(options.max_concurrency)
.try_filter(|batch| std::future::ready(batch.num_rows() != 0))
.map_err(datafusion::error::DataFusionError::from);
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, batches)))
}
struct TileJobs {
prepared: Arc<PairwisePartition>,
num_batches: usize,
live_batches: Option<Arc<Semaphore>>,
filter: Arc<RowAddrMask>,
threshold: f32,
schema: SchemaRef,
anchor_batch: usize,
candidate_batch: usize,
anchor: Option<Arc<LoadedBatch>>,
candidates: Option<Arc<LoadedBatch>>,
next_row: usize,
}
struct LoadedBatch {
batch: PairwiseVectorBatch,
_permit: Option<OwnedSemaphorePermit>,
}
fn has_selected(selected: &[bool]) -> bool {
selected.iter().any(|&selected| selected)
}
impl TileJobs {
async fn load(&self, batch_id: usize) -> Result<Arc<LoadedBatch>> {
let permit = match &self.live_batches {
Some(live) => Some(
live.clone()
.acquire_owned()
.await
.map_err(|_| Error::internal("pairwise live batch semaphore closed"))?,
),
None => None,
};
let mut batch = self.prepared.read_vectors(batch_id).await?;
batch.filter_rows(|id| self.filter.selected(id));
Ok(Arc::new(LoadedBatch {
batch,
_permit: permit,
}))
}
async fn next_job(&mut self) -> Result<Option<ScoreJob>> {
loop {
tokio::task::consume_budget().await;
if self.anchor_batch >= self.num_batches {
return Ok(None);
}
let anchor = match &self.anchor {
Some(anchor) => anchor.clone(),
None => {
let anchor = self.load(self.anchor_batch).await?;
if !has_selected(anchor.batch.selected()) {
self.anchor_batch += 1;
continue;
}
self.anchor = Some(anchor.clone());
self.candidate_batch = self.anchor_batch;
self.next_row = 0;
anchor
}
};
let is_diagonal = self.candidate_batch == self.anchor_batch;
let candidates = match &self.candidates {
Some(candidates) => candidates.clone(),
None => {
let candidates = if is_diagonal {
anchor.clone()
} else {
self.load(self.candidate_batch).await?
};
self.candidates = Some(candidates.clone());
self.next_row = 0;
candidates
}
};
let anchor_end = anchor.batch.num_rows() - usize::from(is_diagonal);
if self.next_row >= anchor_end || !has_selected(candidates.batch.selected()) {
self.candidates = None;
self.candidate_batch += 1;
if self.candidate_batch >= self.num_batches {
self.anchor = None;
self.anchor_batch += 1;
}
continue;
}
let rows = self.next_row..(self.next_row + ANCHOR_BLOCK_ROWS).min(anchor_end);
self.next_row = rows.end;
if !has_selected(&anchor.batch.selected()[rows.clone()]) {
continue;
}
return Ok(Some(ScoreJob {
prepared: self.prepared.clone(),
anchor,
rows,
candidates,
threshold: self.threshold,
schema: self.schema.clone(),
}));
}
}
}
struct ScoreJob {
prepared: Arc<PairwisePartition>,
anchor: Arc<LoadedBatch>,
rows: Range<usize>,
candidates: Arc<LoadedBatch>,
threshold: f32,
schema: SchemaRef,
}
impl ScoreJob {
async fn score(self) -> Result<RecordBatch> {
let work_bytes = self
.rows
.len()
.saturating_mul(self.candidates.batch.num_rows())
.saturating_mul(self.prepared.row_bytes());
let score = move || -> Result<RecordBatch> {
let hits = self.prepared.score_block(
&self.anchor.batch,
self.rows,
&self.candidates.batch,
self.threshold,
)?;
Ok(RecordBatch::try_new(
self.schema,
vec![
Arc::new(UInt64Array::from(hits.row_id_a)),
Arc::new(UInt64Array::from(hits.row_id_b)),
Arc::new(Float32Array::from(hits.distances)),
],
)?)
};
if work_bytes < MIN_OFFLOAD_WORK_BYTES {
score()
} else {
spawn_cpu(score).await
}
}
}