use std::collections::HashMap;
use rayon::prelude::*;
use crate::retrieval::{SearchResult, TernaryInvertedIndex};
use crate::similarity::{compute_similarity, SimilarityMetric};
use embeddenator_vsa::SparseVec;
#[derive(Debug, Clone)]
pub struct SearchConfig {
pub metric: SimilarityMetric,
pub candidate_k: usize,
pub beam_width: usize,
pub parallel: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
metric: SimilarityMetric::Cosine,
candidate_k: 200,
beam_width: 10,
parallel: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RankedResult {
pub id: usize,
pub score: f64,
pub approx_score: i32,
pub rank: usize,
}
pub fn two_stage_search(
query: &SparseVec,
index: &TernaryInvertedIndex,
vectors: &HashMap<usize, SparseVec>,
config: &SearchConfig,
k: usize,
) -> Vec<RankedResult> {
if k == 0 {
return Vec::new();
}
let candidate_k = config.candidate_k.max(k);
let candidates = index.query_top_k(query, candidate_k);
let mut reranked: Vec<RankedResult> = if config.parallel {
let candidates_with_vecs: Vec<_> = candidates
.iter()
.filter_map(|cand| vectors.get(&cand.id).map(|vec| (cand, vec)))
.collect();
candidates_with_vecs
.par_iter()
.map(|(cand, vec)| {
let score = compute_similarity(query, vec, config.metric);
RankedResult {
id: cand.id,
score,
approx_score: cand.score,
rank: 0, }
})
.collect()
} else {
candidates
.iter()
.filter_map(|cand| {
vectors.get(&cand.id).map(|vec| {
let score = compute_similarity(query, vec, config.metric);
RankedResult {
id: cand.id,
score,
approx_score: cand.score,
rank: 0, }
})
})
.collect()
};
reranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
reranked.truncate(k);
for (idx, result) in reranked.iter_mut().enumerate() {
result.rank = idx + 1;
}
reranked
}
pub fn exact_search(
query: &SparseVec,
vectors: &HashMap<usize, SparseVec>,
metric: SimilarityMetric,
k: usize,
) -> Vec<RankedResult> {
exact_search_impl(query, vectors, metric, k, false)
}
pub fn exact_search_parallel(
query: &SparseVec,
vectors: &HashMap<usize, SparseVec>,
metric: SimilarityMetric,
k: usize,
parallel: bool,
) -> Vec<RankedResult> {
exact_search_impl(query, vectors, metric, k, parallel)
}
fn exact_search_impl(
query: &SparseVec,
vectors: &HashMap<usize, SparseVec>,
metric: SimilarityMetric,
k: usize,
parallel: bool,
) -> Vec<RankedResult> {
if k == 0 || vectors.is_empty() {
return Vec::new();
}
let mut results: Vec<RankedResult> = if parallel {
let vec_entries: Vec<_> = vectors.iter().collect();
vec_entries
.par_iter()
.map(|(id, vec)| {
let score = compute_similarity(query, vec, metric);
RankedResult {
id: **id,
score,
approx_score: (score * 1000.0) as i32,
rank: 0,
}
})
.collect()
} else {
vectors
.iter()
.map(|(id, vec)| {
let score = compute_similarity(query, vec, metric);
RankedResult {
id: *id,
score,
approx_score: (score * 1000.0) as i32,
rank: 0,
}
})
.collect()
};
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
results.truncate(k);
for (idx, result) in results.iter_mut().enumerate() {
result.rank = idx + 1;
}
results
}
pub fn approximate_search(
query: &SparseVec,
index: &TernaryInvertedIndex,
k: usize,
) -> Vec<SearchResult> {
index.query_top_k(query, k)
}
pub fn batch_search(
queries: &[SparseVec],
index: &TernaryInvertedIndex,
vectors: &HashMap<usize, SparseVec>,
config: &SearchConfig,
k: usize,
) -> Vec<Vec<RankedResult>> {
if config.parallel {
queries
.par_iter()
.map(|query| two_stage_search(query, index, vectors, config, k))
.collect()
} else {
queries
.iter()
.map(|query| two_stage_search(query, index, vectors, config, k))
.collect()
}
}
pub fn compute_recall_at_k(
approx_results: &[SearchResult],
exact_results: &[RankedResult],
k: usize,
) -> f64 {
if k == 0 || exact_results.is_empty() {
return 0.0;
}
let exact_ids: std::collections::HashSet<usize> =
exact_results.iter().take(k).map(|r| r.id).collect();
let matches = approx_results
.iter()
.take(k)
.filter(|r| exact_ids.contains(&r.id))
.count();
matches as f64 / k.min(exact_results.len()) as f64
}
#[cfg(test)]
mod tests {
use super::*;
use embeddenator_vsa::ReversibleVSAConfig;
#[test]
fn test_two_stage_search() {
let config = ReversibleVSAConfig::default();
let mut index = TernaryInvertedIndex::new();
let mut vectors = HashMap::new();
let vec1 = SparseVec::encode_data(b"hello world", &config, None);
let vec2 = SparseVec::encode_data(b"goodbye world", &config, None);
index.add(1, &vec1);
index.add(2, &vec2);
index.finalize();
vectors.insert(1, vec1);
vectors.insert(2, vec2);
let query = SparseVec::encode_data(b"hello", &config, None);
let search_config = SearchConfig::default();
let results = two_stage_search(&query, &index, &vectors, &search_config, 2);
assert!(!results.is_empty());
assert_eq!(results[0].rank, 1);
}
#[test]
fn test_exact_search() {
let config = ReversibleVSAConfig::default();
let mut vectors = HashMap::new();
vectors.insert(1, SparseVec::encode_data(b"apple", &config, None));
vectors.insert(2, SparseVec::encode_data(b"banana", &config, None));
vectors.insert(3, SparseVec::encode_data(b"cherry", &config, None));
let query = SparseVec::encode_data(b"apple", &config, None);
let results = exact_search(&query, &vectors, SimilarityMetric::Cosine, 3);
assert_eq!(results.len(), 3);
assert_eq!(results[0].id, 1); }
#[test]
fn test_batch_search() {
let config = ReversibleVSAConfig::default();
let mut index = TernaryInvertedIndex::new();
let mut vectors = HashMap::new();
let vec1 = SparseVec::encode_data(b"doc1", &config, None);
let vec2 = SparseVec::encode_data(b"doc2", &config, None);
index.add(1, &vec1);
index.add(2, &vec2);
index.finalize();
vectors.insert(1, vec1);
vectors.insert(2, vec2);
let queries = vec![
SparseVec::encode_data(b"query1", &config, None),
SparseVec::encode_data(b"query2", &config, None),
];
let search_config = SearchConfig::default();
let results = batch_search(&queries, &index, &vectors, &search_config, 2);
assert_eq!(results.len(), 2);
}
#[test]
fn test_recall_computation() {
let approx = vec![
SearchResult { id: 1, score: 100 },
SearchResult { id: 2, score: 90 },
SearchResult { id: 5, score: 80 },
];
let exact = vec![
RankedResult {
id: 1,
score: 0.95,
approx_score: 100,
rank: 1,
},
RankedResult {
id: 3,
score: 0.90,
approx_score: 95,
rank: 2,
},
RankedResult {
id: 2,
score: 0.85,
approx_score: 90,
rank: 3,
},
];
let recall = compute_recall_at_k(&approx, &exact, 3);
assert!((recall - 0.666).abs() < 0.01); }
#[test]
fn test_parallel_two_stage_search_matches_sequential() {
let config = ReversibleVSAConfig::default();
let mut index = TernaryInvertedIndex::new();
let mut vectors = HashMap::new();
for i in 0..50 {
let data = format!("document number {} with some content", i);
let vec = SparseVec::encode_data(data.as_bytes(), &config, None);
index.add(i, &vec);
vectors.insert(i, vec);
}
index.finalize();
let query = SparseVec::encode_data(b"document number 25", &config, None);
let seq_config = SearchConfig {
parallel: false,
..SearchConfig::default()
};
let par_config = SearchConfig {
parallel: true,
..SearchConfig::default()
};
let seq_results = two_stage_search(&query, &index, &vectors, &seq_config, 10);
let par_results = two_stage_search(&query, &index, &vectors, &par_config, 10);
assert_eq!(seq_results.len(), par_results.len());
for (seq, par) in seq_results.iter().zip(par_results.iter()) {
assert_eq!(seq.id, par.id);
assert!((seq.score - par.score).abs() < 1e-10);
assert_eq!(seq.rank, par.rank);
}
}
#[test]
fn test_parallel_exact_search_matches_sequential() {
let config = ReversibleVSAConfig::default();
let mut vectors = HashMap::new();
for i in 0..100 {
let data = format!("item {} for testing parallel exact search", i);
vectors.insert(i, SparseVec::encode_data(data.as_bytes(), &config, None));
}
let query = SparseVec::encode_data(b"item 50 for testing", &config, None);
let seq_results =
exact_search_parallel(&query, &vectors, SimilarityMetric::Cosine, 20, false);
let par_results =
exact_search_parallel(&query, &vectors, SimilarityMetric::Cosine, 20, true);
assert_eq!(seq_results.len(), par_results.len());
for (seq, par) in seq_results.iter().zip(par_results.iter()) {
assert_eq!(seq.id, par.id);
assert!((seq.score - par.score).abs() < 1e-10);
}
}
#[test]
fn test_parallel_batch_search_matches_sequential() {
let config = ReversibleVSAConfig::default();
let mut index = TernaryInvertedIndex::new();
let mut vectors = HashMap::new();
for i in 0..30 {
let data = format!("batch doc {}", i);
let vec = SparseVec::encode_data(data.as_bytes(), &config, None);
index.add(i, &vec);
vectors.insert(i, vec);
}
index.finalize();
let queries: Vec<SparseVec> = (0..10)
.map(|i| {
let data = format!("query {}", i);
SparseVec::encode_data(data.as_bytes(), &config, None)
})
.collect();
let seq_config = SearchConfig {
parallel: false,
..SearchConfig::default()
};
let par_config = SearchConfig {
parallel: true,
..SearchConfig::default()
};
let seq_results = batch_search(&queries, &index, &vectors, &seq_config, 5);
let par_results = batch_search(&queries, &index, &vectors, &par_config, 5);
assert_eq!(seq_results.len(), par_results.len());
for (seq_batch, par_batch) in seq_results.iter().zip(par_results.iter()) {
assert_eq!(seq_batch.len(), par_batch.len());
for (seq, par) in seq_batch.iter().zip(par_batch.iter()) {
assert_eq!(seq.id, par.id);
assert!((seq.score - par.score).abs() < 1e-10);
}
}
}
}