use crate::cluster::DuplicateCluster;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::minhash::MinHashSignature;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use tracing::{instrument, warn};
pub struct LshIndex {
num_bands: usize,
rows_per_band: usize,
threshold: f64,
buckets: Vec<HashMap<u64, Vec<usize>>>,
signatures: std::collections::BTreeMap<usize, MinHashSignature>,
doc_count: usize,
clusters: Vec<DuplicateCluster>,
doc_to_cluster: HashMap<usize, usize>,
next_cluster_id: usize,
max_doc_id: usize,
}
impl LshIndex {
#[instrument(skip(config), level = "debug")]
pub fn new(config: &Config) -> Result<Self> {
if config.signature_size % config.num_bands != 0 {
warn!(
signature_size = config.signature_size,
num_bands = config.num_bands,
"signature_size not divisible by num_bands"
);
return Err(Error::InvalidConfig {
reason: format!(
"signature_size ({}) not divisible by num_bands ({})",
config.signature_size, config.num_bands
),
fix: "ensure signature_size = num_bands * rows_per_band".to_string(),
});
}
let rows_per_band = config.signature_size / config.num_bands;
let buckets: Vec<HashMap<u64, Vec<usize>>> =
(0..config.num_bands).map(|_| HashMap::new()).collect();
Ok(Self {
num_bands: config.num_bands,
rows_per_band,
threshold: config.similarity_threshold,
buckets,
signatures: std::collections::BTreeMap::new(),
doc_count: 0,
clusters: Vec::new(),
doc_to_cluster: HashMap::new(),
next_cluster_id: 0,
max_doc_id: 0,
})
}
pub fn clear(&mut self) {
for band in &mut self.buckets {
band.clear();
}
self.signatures.clear();
self.doc_count = 0;
self.clusters.clear();
self.doc_to_cluster.clear();
self.next_cluster_id = 0;
self.max_doc_id = 0;
}
#[instrument(skip(self, signature), fields(doc_id = signature.doc_id), level = "debug")]
pub fn insert(&mut self, signature: MinHashSignature) -> Result<Vec<usize>> {
let doc_id = signature.doc_id;
const MAX_DOC_ID: usize = 100_000_000;
if doc_id > MAX_DOC_ID {
return Err(Error::InvalidConfig {
reason: format!("doc_id {doc_id} exceeds maximum {MAX_DOC_ID}"),
fix: "use sequential doc_ids starting from 0".to_string(),
});
}
let expected_len = self.num_bands * self.rows_per_band;
if signature.len() != expected_len {
return Err(Error::InvalidConfig {
reason: format!(
"signature length {} does not match index configuration ({} bands x {} rows = {expected_len})",
signature.len(),
self.num_bands,
self.rows_per_band
),
fix: "generate signatures with the signature_size the index was configured for"
.to_string(),
});
}
let had_signature = self.signatures.contains_key(&doc_id);
if had_signature {
if let Some(old_sig) = self.signatures.remove(&doc_id) {
for band_idx in 0..self.num_bands {
let start = band_idx * self.rows_per_band;
let old_hash = old_sig.band_hash(start, self.rows_per_band);
if let Some(vec) = self.buckets[band_idx].get_mut(&old_hash) {
vec.retain(|&id| id != doc_id);
if vec.is_empty() {
self.buckets[band_idx].remove(&old_hash);
}
}
}
}
}
self.signatures.insert(doc_id, signature.clone());
if !had_signature {
self.doc_count += 1;
}
self.max_doc_id = self.max_doc_id.max(doc_id);
if !self.clusters.is_empty() || !self.doc_to_cluster.is_empty() {
self.clusters.clear();
self.doc_to_cluster.clear();
self.next_cluster_id = 0;
}
let mut candidates = std::collections::HashSet::new();
for band_idx in 0..self.num_bands {
let start = band_idx * self.rows_per_band;
let band_hash = signature.band_hash(start, self.rows_per_band);
let bucket = &mut self.buckets[band_idx];
match bucket.entry(band_hash) {
Entry::Occupied(mut entry) => {
for &existing_id in entry.get() {
if existing_id != doc_id {
candidates.insert(existing_id);
}
}
const MAX_BUCKET_SIZE: usize = 10_000;
if entry.get().len() < MAX_BUCKET_SIZE {
entry.get_mut().push(doc_id);
}
}
Entry::Vacant(entry) => {
entry.insert(vec![doc_id]);
}
}
}
Ok(candidates.into_iter().collect())
}
pub fn query(&self, signature: &MinHashSignature) -> Vec<usize> {
let mut candidates = std::collections::HashSet::new();
for band_idx in 0..self.num_bands {
let start = band_idx * self.rows_per_band;
let band_hash = signature.band_hash(start, self.rows_per_band);
if let Some(bucket) = self.buckets[band_idx].get(&band_hash) {
for &doc_id in bucket {
if doc_id != signature.doc_id {
candidates.insert(doc_id);
}
}
}
}
candidates.into_iter().collect()
}
#[must_use]
pub fn verify_similarity(&self, doc_a: usize, doc_b: usize) -> Option<f64> {
let sig_a = self.signatures.get(&doc_a)?;
let sig_b = self.signatures.get(&doc_b)?;
Some(sig_a.similarity(sig_b))
}
#[instrument(skip(self), level = "debug")]
pub fn find_clusters(&mut self) -> &[DuplicateCluster] {
if !self.clusters.is_empty() {
return &self.clusters;
}
let mut parent: HashMap<usize, usize> =
self.signatures.keys().map(|&d| (d, d)).collect();
for (doc_id, signature) in &self.signatures {
let doc_id = *doc_id;
let candidates = self.query(signature);
for &candidate_id in &candidates {
if candidate_id <= doc_id {
continue; }
if uf_find(&mut parent, doc_id) == uf_find(&mut parent, candidate_id) {
continue;
}
if let Some(sim) = self.verify_similarity(doc_id, candidate_id) {
if sim >= self.threshold {
uf_union(&mut parent, doc_id, candidate_id);
}
}
}
}
let all_docs: Vec<usize> = self.signatures.keys().copied().collect();
let mut components: HashMap<usize, Vec<usize>> = HashMap::new();
for doc_id in all_docs {
let root = uf_find(&mut parent, doc_id);
components.entry(root).or_default().push(doc_id);
}
let mut groups: Vec<Vec<usize>> = components.into_values().collect();
for g in &mut groups {
g.sort_unstable();
}
groups.sort_unstable_by_key(|g| g[0]);
for cluster_docs in groups {
if cluster_docs.len() > 1 {
let mut cluster = DuplicateCluster::new(self.next_cluster_id, cluster_docs[0]);
self.doc_to_cluster.insert(cluster_docs[0], self.next_cluster_id);
for &doc in &cluster_docs[1..] {
cluster.add(doc);
self.doc_to_cluster.insert(doc, self.next_cluster_id);
}
self.clusters.push(cluster);
self.next_cluster_id += 1;
}
}
&self.clusters
}
#[must_use]
pub fn get_cluster_for_doc(&self, doc_id: usize) -> Option<&DuplicateCluster> {
let cluster_id = self.doc_to_cluster.get(&doc_id)?;
self.clusters.get(*cluster_id)
}
#[must_use]
pub fn is_duplicate(&self, doc_id: usize) -> bool {
self.doc_to_cluster.contains_key(&doc_id)
}
pub fn get_unique_indices(&self) -> Vec<usize> {
let mut unique: Vec<usize> = Vec::new();
let mut in_cluster = std::collections::HashSet::new();
for cluster in &self.clusters {
unique.push(cluster.representative);
for &idx in &cluster.indices {
in_cluster.insert(idx);
}
}
for &doc_id in self.signatures.keys() {
if !in_cluster.contains(&doc_id) {
unique.push(doc_id);
}
}
unique.sort_unstable();
unique
}
#[must_use]
pub const fn doc_count(&self) -> usize {
self.doc_count
}
#[must_use]
pub fn cluster_count(&self) -> usize {
self.clusters.len()
}
#[must_use]
pub fn duplicate_count(&self) -> usize {
self.clusters.iter().map(|c| c.len().saturating_sub(1)).sum()
}
pub fn stats(&self) -> LshStats {
let total_buckets: usize = self.buckets.iter().map(std::collections::HashMap::len).sum();
let total_entries: usize = self.buckets.iter().map(|b| b.values().map(std::vec::Vec::len).sum::<usize>()).sum();
LshStats {
num_bands: self.num_bands,
rows_per_band: self.rows_per_band,
threshold: self.threshold,
doc_count: self.doc_count,
total_buckets,
total_entries,
avg_bucket_size: if total_buckets > 0 {
total_entries as f64 / total_buckets as f64
} else {
0.0
},
cluster_count: self.clusters.len(),
duplicate_count: self.duplicate_count(),
}
}
#[must_use]
pub fn memory_usage(&self) -> usize {
let signature_bytes = self.signatures.len() * (std::mem::size_of::<usize>() + std::mem::size_of::<MinHashSignature>() + 32); let bucket_bytes: usize = self.buckets.iter()
.map(|b| {
b.capacity() * (std::mem::size_of::<u64>() + std::mem::size_of::<Vec<usize>>()) +
b.values().map(|v| v.capacity() * std::mem::size_of::<usize>()).sum::<usize>()
})
.sum();
let cluster_bytes = self.clusters.len() * std::mem::size_of::<DuplicateCluster>();
signature_bytes + bucket_bytes + cluster_bytes
}
}
fn uf_find(parent: &mut HashMap<usize, usize>, x: usize) -> usize {
let mut root = x;
while let Some(&p) = parent.get(&root) {
if p == root {
break;
}
root = p;
}
let mut cur = x;
while let Some(&p) = parent.get(&cur) {
if p == root {
break;
}
parent.insert(cur, root);
cur = p;
}
root
}
fn uf_union(parent: &mut HashMap<usize, usize>, a: usize, b: usize) {
let ra = uf_find(parent, a);
let rb = uf_find(parent, b);
if ra != rb {
let (keep, drop) = if ra < rb { (ra, rb) } else { (rb, ra) };
parent.insert(drop, keep);
}
}
pub mod stats;
#[cfg(test)]
mod tests;
pub use stats::LshStats;