mod builder;
mod index;
#[cfg(test)]
mod tests;
pub use builder::MinHashLshIndexBuilder;
pub use index::{MinHashLshIndex, merge_minhash_indices};
use builder::{RowIdTransform, SignatureSource};
use std::any::Any;
use std::collections::{BinaryHeap, HashMap};
use std::ops::Range;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
use arrow_array::cast::AsArray;
use arrow_array::types::{UInt16Type, UInt32Type, UInt64Type};
use arrow_array::{
Array, ArrayRef, FixedSizeListArray, RecordBatch, UInt16Array, UInt32Array, UInt64Array,
};
use arrow_schema::{DataType, Field, Schema, SchemaRef};
use async_trait::async_trait;
use bytes::Bytes;
use datafusion::execution::SendableRecordBatchStream;
use futures::stream::FuturesOrdered;
use futures::{Stream, StreamExt, TryStreamExt};
use lance_core::cache::{CacheKey, CacheKeySchema, KeyBuilder, LanceCache, WeakLanceCache};
use lance_core::datatypes::SchemaCompareOptions;
use lance_core::deepsize::DeepSizeOf;
use lance_core::utils::row_addr_remap::RowAddrRemap;
use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
use lance_core::{Error, ROW_ID, Result};
use lance_encoding::constants::{
COMPRESSION_META_KEY, STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY,
};
use lance_select::RowAddrMask;
use lance_tokenizer::TokenStream;
use prost::Message;
use rayon::slice::ParallelSliceMut;
use roaring::RoaringBitmap;
use serde::{Deserialize, Serialize};
use twox_hash::XxHash64;
use crate::scalar::expression::ScalarQueryParser;
use crate::scalar::inverted::tokenizer::InvertedIndexParams;
use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
use crate::scalar::registry::{
BasicTrainer, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, TrainingRequest,
VALUE_COLUMN_NAME,
};
use crate::scalar::{
AnyQuery, BuiltinIndexType, CreatedIndex, IndexFile, IndexReader, IndexStore, IndexWriter,
MetricsCollector, OldIndexDataFilter, RowIdRemapper, ScalarIndex, ScalarIndexParams,
SearchResult, UpdateCriteria,
};
use crate::vector::graph::OrderedFloat;
use crate::{Index, IndexType};
use crate::{pb, pbold};
pub const MINHASH_LSH_INDEX_VERSION: u32 = 0;
pub const SIGNATURE_VERSION: u32 = 0;
const SIGNATURE_SEED: u64 = 42;
pub type SignatureValue = u16;
pub const SIGNATURES_FILENAME: &str = "signatures.lance";
pub const BANDS_FILENAME: &str = "bands.lance";
pub const SIGNATURE_COL: &str = "signature";
pub const BAND_KEY_COL: &str = "band_key";
pub const DOC_ID_COL: &str = "doc_id";
const DETAILS_META_KEY: &str = "minhash_lsh_details";
const INDEX_VERSION_META_KEY: &str = "minhash_lsh_index_version";
const PAGE_ROWS_META_KEY: &str = "minhash_lsh_page_rows";
const PAGE_TABLE_BUFFER_META_KEY: &str = "minhash_lsh_page_table_buffer";
const NUM_DOCS_META_KEY: &str = "minhash_lsh_num_docs";
const DEFAULT_PAGE_ROWS: usize = 4096;
const SHINGLE_SEPARATOR: u8 = 0x1F;
const IO_BATCH_BYTES: usize = 8 * 1024 * 1024;
const RESIDENT_CHUNK_BYTES: usize = 128 * 1024 * 1024;
const READ_CONCURRENCY: usize = 4;
const SPARSE_REFINE_READ_PERCENT: u64 = 10;
const MIN_REFINE_READ_ROWS: usize = 64;
const BAND_ROW_BYTES: usize = std::mem::size_of::<u64>() + std::mem::size_of::<u32>();
const MAX_INFLIGHT_SPILLS: usize = 4;
const SIGNED_BATCH_QUEUE: usize = 16;
const SPILL_PARTITIONS: usize = 1 << 16;
const MERGE_GROUPS_IN_FLIGHT: usize = 16;
const MAX_NUM_BANDS: u32 = 256;
const MAX_NUM_HASHES: u32 = 4096;
fn rows_per_batch(row_bytes: usize) -> usize {
(IO_BATCH_BYTES / row_bytes.max(1)).max(1)
}
fn signature_row_bytes(num_hashes: usize) -> usize {
std::mem::size_of::<u64>() + num_hashes * std::mem::size_of::<SignatureValue>()
}
static BANDS_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
Arc::new(Schema::new(vec![
Field::new(BAND_KEY_COL, DataType::UInt64, false).with_metadata(HashMap::from([(
COMPRESSION_META_KEY.to_string(),
"none".to_string(),
)])),
Field::new(DOC_ID_COL, DataType::UInt32, false).with_metadata(HashMap::from([(
COMPRESSION_META_KEY.to_string(),
"none".to_string(),
)])),
]))
});
fn signatures_schema(num_hashes: i32) -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new(ROW_ID, DataType::UInt64, false),
Field::new(
SIGNATURE_COL,
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::UInt16, false)),
num_hashes,
),
false,
)
.with_metadata(HashMap::from([
(
STRUCTURAL_ENCODING_META_KEY.to_string(),
STRUCTURAL_ENCODING_FULLZIP.to_string(),
),
(COMPRESSION_META_KEY.to_string(), "none".to_string()),
])),
]))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MinHashQuery {
pub column: String,
pub text: String,
}
impl MinHashQuery {
pub fn new(text: impl Into<String>, column: impl Into<String>) -> Self {
Self {
column: column.into(),
text: text.into(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct MinHashLshIndexParams {
pub num_hashes: u32,
pub num_bands: u32,
pub shingle_size: u32,
pub tokenizer: InvertedIndexParams,
}
impl Default for MinHashLshIndexParams {
fn default() -> Self {
Self {
num_hashes: 128,
num_bands: 16,
shingle_size: 3,
tokenizer: InvertedIndexParams::default()
.stem(false)
.remove_stop_words(false),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMinHashLshIndexParams {
num_hashes: Option<u32>,
num_bands: Option<u32>,
shingle_size: Option<u32>,
tokenizer: Option<serde_json::Map<String, serde_json::Value>>,
}
impl MinHashLshIndexParams {
pub fn from_json(params: &str) -> Result<Self> {
let params = if params.trim().is_empty() {
"{}"
} else {
params
};
let raw: RawMinHashLshIndexParams = serde_json::from_str(params).map_err(|err| {
Error::invalid_input(format!(
"invalid MinHash LSH index params {params:?}: {err}"
))
})?;
let mut resolved = Self::default();
if let Some(num_hashes) = raw.num_hashes {
resolved.num_hashes = num_hashes;
}
if let Some(num_bands) = raw.num_bands {
resolved.num_bands = num_bands;
}
if let Some(shingle_size) = raw.shingle_size {
resolved.shingle_size = shingle_size;
}
if let Some(tokenizer) = raw.tokenizer {
let has_stem = tokenizer.contains_key("stem");
let has_stop_words = tokenizer.contains_key("remove_stop_words");
let mut parsed: InvertedIndexParams =
serde_json::from_value(serde_json::Value::Object(tokenizer)).map_err(|err| {
Error::invalid_input(format!("invalid MinHash LSH tokenizer params: {err}"))
})?;
if !has_stem {
parsed.stem = false;
}
if !has_stop_words {
parsed.remove_stop_words = false;
}
resolved.tokenizer = parsed;
}
resolved.validate()?;
resolved.tokenizer = InvertedIndexParams::try_from(
&pbold::InvertedIndexDetails::try_from(&resolved.tokenizer)?,
)?;
Ok(resolved)
}
fn validate(&self) -> Result<()> {
if self.num_hashes == 0 || self.num_hashes > MAX_NUM_HASHES {
return Err(Error::invalid_input(format!(
"MinHash LSH index requires 1 <= num_hashes <= {MAX_NUM_HASHES}, got num_hashes={}",
self.num_hashes
)));
}
if self.num_bands == 0 || self.num_bands > MAX_NUM_BANDS {
return Err(Error::invalid_input(format!(
"MinHash LSH index requires 1 <= num_bands <= {MAX_NUM_BANDS}, got num_bands={}",
self.num_bands
)));
}
if !self.num_hashes.is_multiple_of(self.num_bands) {
return Err(Error::invalid_input(format!(
"MinHash LSH index requires num_hashes to be a multiple of num_bands, got num_hashes={} num_bands={}",
self.num_hashes, self.num_bands
)));
}
if self.shingle_size == 0 {
return Err(Error::invalid_input(
"MinHash LSH index requires shingle_size > 0".to_string(),
));
}
self.validate_tokenizer()?;
self.tokenizer.build()?;
Ok(())
}
fn validate_tokenizer(&self) -> Result<()> {
let unsupported = |reason: &str| {
Error::invalid_input(format!(
"MinHash LSH index cannot identify its tokenizer from the index details: {reason}"
))
};
if self.tokenizer.custom_stop_words.is_some() {
return Err(unsupported("custom_stop_words are not recorded"));
}
if self.tokenizer.lance_tokenizer.is_some() {
return Err(unsupported("lance_tokenizer is not recorded"));
}
let base_tokenizer = self.tokenizer.base_tokenizer.as_str();
if base_tokenizer == "jieba"
|| base_tokenizer.starts_with("jieba/")
|| base_tokenizer.starts_with("lindera/")
{
return Err(unsupported(&format!(
"the {base_tokenizer} tokenizer loads a dictionary from LANCE_LANGUAGE_MODEL_HOME, outside Lance"
)));
}
Ok(())
}
fn to_details(&self) -> Result<pb::MinHashLshIndexDetails> {
Ok(pb::MinHashLshIndexDetails {
num_hashes: self.num_hashes,
num_bands: self.num_bands,
shingle_size: self.shingle_size,
tokenizer: Some(pbold::InvertedIndexDetails::try_from(&self.tokenizer)?),
signature_version: SIGNATURE_VERSION,
})
}
fn from_details(details: &pb::MinHashLshIndexDetails) -> Result<Self> {
if details.signature_version != SIGNATURE_VERSION {
return Err(Error::not_supported(format!(
"MinHash LSH index was built with signature_version {} but this version of Lance produces signature_version {}; rebuild the index",
details.signature_version, SIGNATURE_VERSION
)));
}
let tokenizer = details.tokenizer.as_ref().ok_or_else(|| {
Error::invalid_input("MinHash LSH index details carry no tokenizer".to_string())
})?;
let params = Self {
num_hashes: details.num_hashes,
num_bands: details.num_bands,
shingle_size: details.shingle_size,
tokenizer: InvertedIndexParams::try_from(tokenizer)?,
};
params.validate()?;
Ok(params)
}
fn details_any(&self) -> Result<prost_types::Any> {
Ok(prost_types::Any::from_msg(&self.to_details()?)?)
}
pub fn from_details_any(details: &prost_types::Any) -> Result<Self> {
Self::from_details(&details.to_msg::<pb::MinHashLshIndexDetails>()?)
}
fn details_hex(&self) -> Result<String> {
Ok(hex::encode(self.to_details()?.encode_to_vec()))
}
fn from_details_hex(hex: &str) -> Result<Self> {
let bytes = hex::decode(hex).map_err(|err| {
Error::invalid_input(format!("{hex:?} is not a hex-encoded message: {err}"))
})?;
let details = pb::MinHashLshIndexDetails::decode(bytes.as_slice()).map_err(|err| {
Error::invalid_input(format!("invalid MinHash LSH index details: {err}"))
})?;
Self::from_details(&details)
}
}
fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[derive(Clone)]
pub struct SignatureGenerator {
tokenizer: Box<dyn LanceTokenizer>,
shingle_size: usize,
num_bands: usize,
multipliers: Vec<u64>,
increments: Vec<u64>,
compressors: Vec<u64>,
token_text: Vec<u8>,
token_ends: Vec<usize>,
mins: Vec<u64>,
}
impl SignatureGenerator {
pub fn try_new(params: &MinHashLshIndexParams) -> Result<Self> {
params.validate()?;
let num_hashes = params.num_hashes as usize;
let mut state = SIGNATURE_SEED;
let mut multipliers = Vec::with_capacity(num_hashes);
let mut increments = Vec::with_capacity(num_hashes);
for _ in 0..num_hashes {
multipliers.push(splitmix64(&mut state) | 1);
increments.push(splitmix64(&mut state));
}
let compressors = (0..num_hashes)
.map(|_| splitmix64(&mut state) | 1)
.collect();
Ok(Self {
tokenizer: params.tokenizer.build()?,
shingle_size: params.shingle_size as usize,
num_bands: params.num_bands as usize,
multipliers,
increments,
compressors,
token_text: Vec::new(),
token_ends: Vec::new(),
mins: vec![u64::MAX; num_hashes],
})
}
pub fn num_hashes(&self) -> usize {
self.multipliers.len()
}
pub fn num_bands(&self) -> usize {
self.num_bands
}
pub fn signature(&mut self, text: &str, signature: &mut [SignatureValue]) -> bool {
debug_assert_eq!(
signature.len(),
self.multipliers.len(),
"signature buffer must hold num_hashes values"
);
self.token_text.clear();
self.token_ends.clear();
{
let mut stream = self.tokenizer.token_stream_for_doc(text);
while stream.advance() {
if !self.token_ends.is_empty() {
self.token_text.push(SHINGLE_SEPARATOR);
}
self.token_text
.extend_from_slice(stream.token().text.as_bytes());
self.token_ends.push(self.token_text.len());
}
}
let num_tokens = self.token_ends.len();
if num_tokens == 0 {
return false;
}
self.mins.fill(u64::MAX);
let window = self.shingle_size.min(num_tokens);
for start in 0..=(num_tokens - window) {
let from = if start == 0 {
0
} else {
self.token_ends[start - 1] + 1
};
let to = self.token_ends[start + window - 1];
let base = XxHash64::oneshot(SIGNATURE_SEED, &self.token_text[from..to]);
for ((value, multiplier), increment) in self
.mins
.iter_mut()
.zip(&self.multipliers)
.zip(&self.increments)
{
let permuted = multiplier.wrapping_mul(base).wrapping_add(*increment);
*value = (*value).min(permuted);
}
}
for ((value, min), compressor) in
signature.iter_mut().zip(&self.mins).zip(&self.compressors)
{
*value = (compressor.wrapping_mul(*min) >> 48) as SignatureValue;
}
true
}
pub fn band_keys(&self, signature: &[SignatureValue], keys: &mut Vec<u64>) {
let band_width = signature.len() / self.num_bands;
let mut band_bytes = Vec::with_capacity(band_width * std::mem::size_of::<SignatureValue>());
for (band_id, band) in signature.chunks_exact(band_width).enumerate() {
band_bytes.clear();
for value in band {
band_bytes.extend_from_slice(&value.to_le_bytes());
}
let hash = XxHash64::oneshot(SIGNATURE_SEED, &band_bytes);
keys.push(((band_id as u64) << 56) | (hash & 0x00FF_FFFF_FFFF_FFFF));
}
}
}
pub fn estimate_jaccard(a: &[SignatureValue], b: &[SignatureValue]) -> f32 {
debug_assert_eq!(a.len(), b.len(), "signatures must have the same width");
let matches = a.iter().zip(b).filter(|(x, y)| x == y).count();
matches as f32 / a.len() as f32
}
fn checked_doc_id(doc_index: u64) -> Result<u32> {
u32::try_from(doc_index).map_err(|_| {
Error::invalid_input(format!(
"MinHash LSH index segment cannot hold more than {} rows (row index {} does not fit a u32 doc id); \
build the index in pieces by calling create_index_uncommitted on disjoint fragment subsets \
and committing the resulting segments together with commit_existing_index_segments",
u32::MAX as u64 + 1,
doc_index
))
})
}
#[derive(Debug, Clone)]
pub struct QuerySignature {
signature: Vec<SignatureValue>,
band_keys: Vec<u64>,
}
impl QuerySignature {
pub fn compute(generator: &mut SignatureGenerator, text: &str) -> Option<Self> {
let mut signature = vec![SignatureValue::MAX; generator.num_hashes()];
if !generator.signature(text, &mut signature) {
return None;
}
let mut band_keys = Vec::with_capacity(generator.num_bands());
generator.band_keys(&signature, &mut band_keys);
Some(Self {
signature,
band_keys,
})
}
pub fn signature(&self) -> &[SignatureValue] {
&self.signature
}
pub fn shares_band(
&self,
generator: &SignatureGenerator,
signature: &[SignatureValue],
scratch: &mut Vec<u64>,
) -> bool {
scratch.clear();
generator.band_keys(signature, scratch);
scratch
.iter()
.zip(&self.band_keys)
.any(|(row, query)| row == query)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MinHashHit {
pub distance: OrderedFloat,
pub row_id: u64,
}
pub struct TopHits {
limit: usize,
heap: BinaryHeap<MinHashHit>,
}
impl TopHits {
pub fn new(limit: usize) -> Self {
Self {
limit,
heap: BinaryHeap::with_capacity(limit.min(1024)),
}
}
pub fn push(&mut self, hit: MinHashHit) {
if self.limit == 0 {
return;
}
if self.heap.len() < self.limit {
self.heap.push(hit);
} else if self.heap.peek().is_some_and(|worst| hit < *worst) {
self.heap.pop();
self.heap.push(hit);
}
}
pub fn missing(&self) -> usize {
self.limit - self.heap.len()
}
pub fn cutoff(&self) -> Option<f32> {
if self.heap.len() < self.limit {
return None;
}
self.heap.peek().map(|worst| worst.distance.0)
}
pub fn into_sorted(self) -> Vec<MinHashHit> {
self.heap.into_sorted_vec()
}
}
impl Extend<MinHashHit> for TopHits {
fn extend<I: IntoIterator<Item = MinHashHit>>(&mut self, hits: I) {
for hit in hits {
self.push(hit);
}
}
}
#[derive(Debug, Default)]
pub struct MinHashLshIndexPlugin;
#[derive(Debug)]
pub struct MinHashLshTrainingRequest {
pub params: MinHashLshIndexParams,
criteria: TrainingCriteria,
}
impl MinHashLshTrainingRequest {
pub fn new(params: MinHashLshIndexParams) -> Self {
Self {
params,
criteria: TrainingCriteria::new(TrainingOrdering::None).with_row_id(),
}
}
}
impl TrainingRequest for MinHashLshTrainingRequest {
fn as_any(&self) -> &dyn Any {
self
}
fn criteria(&self) -> &TrainingCriteria {
&self.criteria
}
}
#[async_trait]
impl BasicTrainer for MinHashLshIndexPlugin {
fn new_training_request(
&self,
params: &str,
field: &Field,
) -> Result<Box<dyn TrainingRequest>> {
match field.data_type() {
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => {}
other => {
return Err(Error::invalid_input(format!(
"MinHash LSH index can only be created on a string column (Utf8, LargeUtf8, Utf8View), field {} has type {other}",
field.name()
)));
}
}
let params = MinHashLshIndexParams::from_json(params)?;
Ok(Box::new(MinHashLshTrainingRequest::new(params)))
}
async fn train_index(
&self,
data: SendableRecordBatchStream,
index_store: &dyn IndexStore,
request: Box<dyn TrainingRequest>,
_fragment_ids: Option<Vec<u32>>,
_progress: Arc<dyn crate::progress::IndexBuildProgress>,
) -> Result<CreatedIndex> {
let request = (request as Box<dyn Any>)
.downcast::<MinHashLshTrainingRequest>()
.map_err(|_| {
Error::invalid_input(
"must provide training request created by new_training_request".to_string(),
)
})?;
let builder = MinHashLshIndexBuilder::try_new(request.params.clone())?;
let files = builder.train(data, index_store).await?;
Ok(CreatedIndex {
index_details: request.params.details_any()?,
index_version: MINHASH_LSH_INDEX_VERSION,
files,
})
}
}
#[async_trait]
impl ScalarIndexPlugin for MinHashLshIndexPlugin {
fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
Some(self)
}
fn name(&self) -> &str {
"MinHashLsh"
}
fn provides_exact_answer(&self) -> bool {
false
}
fn version(&self) -> u32 {
MINHASH_LSH_INDEX_VERSION
}
fn new_query_parser(
&self,
_index_name: String,
_index_details: &prost_types::Any,
) -> Option<Box<dyn ScalarQueryParser>> {
None
}
async fn load_index(
&self,
index_store: Arc<dyn IndexStore>,
index_details: &prost_types::Any,
frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
cache: &LanceCache,
) -> Result<Arc<dyn ScalarIndex>> {
Ok(
MinHashLshIndex::load(index_store, index_details, frag_reuse_index, cache).await?
as Arc<dyn ScalarIndex>,
)
}
fn validate_new_segments_against_existing(
&self,
existing: &[&prost_types::Any],
incoming: &[&prost_types::Any],
) -> Result<()> {
let mut all = existing
.iter()
.chain(incoming)
.map(|details| MinHashLshIndexParams::from_details_any(details));
let Some(first) = all.next().transpose()? else {
return Ok(());
};
for params in all {
let params = params?;
if params != first {
return Err(Error::invalid_input(format!(
"MinHash LSH index segments must share identical parameters (signatures are only comparable under the same hash functions and tokenizer); found {first:?} and {params:?}"
)));
}
}
Ok(())
}
fn details_as_json(&self, details: &prost_types::Any) -> Result<serde_json::Value> {
let details = details.to_msg::<pb::MinHashLshIndexDetails>()?;
let params = MinHashLshIndexParams::from_details(&details)?;
let mut value = serde_json::to_value(¶ms)?;
if let Some(object) = value.as_object_mut() {
object.insert(
"signature_version".to_string(),
serde_json::Value::from(details.signature_version),
);
}
Ok(value)
}
}