use std::cmp::Ordering;
use std::collections::BinaryHeap;
use crate::embedding::dot;
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scored {
pub id: usize,
pub score: f32,
}
pub trait VectorIndex {
fn dimension(&self) -> usize;
fn len(&self) -> usize;
fn is_empty(&self) -> bool {
self.len() == 0
}
fn add(&mut self, vector: &[f32]) -> usize;
fn search(&self, query: &[f32], limit: usize, min_score: f32) -> Vec<Scored>;
}
#[derive(Debug, Clone)]
pub struct BruteForceIndex {
dim: usize,
data: Vec<f32>,
}
impl BruteForceIndex {
pub fn new(dim: usize) -> Result<Self> {
Self::with_capacity(dim, 0)
}
pub fn with_capacity(dim: usize, n: usize) -> Result<Self> {
if dim == 0 {
return Err(Error::Config(
"vector index dimension must be nonzero".into(),
));
}
Ok(Self {
dim,
data: Vec::with_capacity(dim * n),
})
}
pub fn memory_bytes(&self) -> usize {
self.data.len() * std::mem::size_of::<f32>()
}
}
#[derive(PartialEq)]
struct Entry(Scored);
impl Eq for Entry {}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> Ordering {
other
.0
.score
.total_cmp(&self.0.score)
.then_with(|| self.0.id.cmp(&other.0.id))
}
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl VectorIndex for BruteForceIndex {
fn dimension(&self) -> usize {
self.dim
}
fn len(&self) -> usize {
self.data.len() / self.dim
}
fn add(&mut self, vector: &[f32]) -> usize {
assert_eq!(vector.len(), self.dim, "vector dimension mismatch");
self.data.extend_from_slice(vector);
self.len() - 1
}
fn search(&self, query: &[f32], limit: usize, min_score: f32) -> Vec<Scored> {
assert_eq!(query.len(), self.dim, "query dimension mismatch");
if limit == 0 {
return Vec::new();
}
let mut heap: BinaryHeap<Entry> = BinaryHeap::with_capacity(limit + 1);
for (id, row) in self.data.chunks_exact(self.dim).enumerate() {
let score = dot(query, row);
if score < min_score {
continue;
}
if heap.len() < limit {
heap.push(Entry(Scored { id, score }));
} else if heap.peek().is_some_and(|worst| score > worst.0.score) {
heap.pop();
heap.push(Entry(Scored { id, score }));
}
}
let mut out: Vec<Scored> = heap.into_iter().map(|e| e.0).collect();
out.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.id.cmp(&b.id)));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_reject_zero_width() {
assert!(matches!(BruteForceIndex::new(0), Err(Error::Config(_))));
for capacity in [0, 1, 10] {
assert!(matches!(
BruteForceIndex::with_capacity(0, capacity),
Err(Error::Config(_))
));
}
}
#[test]
fn positive_width_indexes_start_empty_and_accept_vectors() {
for mut index in [
BruteForceIndex::new(1).unwrap(),
BruteForceIndex::with_capacity(1, 10).unwrap(),
] {
assert_eq!(index.dimension(), 1);
assert!(index.is_empty());
assert_eq!(index.add(&[1.0]), 0);
assert_eq!(index.len(), 1);
assert_eq!(index.search(&[1.0], 1, 0.0), [Scored { id: 0, score: 1.0 }]);
}
}
#[test]
fn returns_best_first_with_threshold() {
let mut idx = BruteForceIndex::new(2).unwrap();
idx.add(&[1.0, 0.0]);
idx.add(&[0.0, 1.0]);
idx.add(&[0.6, 0.8]);
let hits = idx.search(&[1.0, 0.0], 2, -1.0);
assert_eq!(hits.iter().map(|h| h.id).collect::<Vec<_>>(), [0, 2]);
let hits = idx.search(&[1.0, 0.0], 10, 0.5);
assert_eq!(hits.len(), 2);
}
#[test]
fn matches_full_sort() {
let dim = 8;
let mut idx = BruteForceIndex::new(dim).unwrap();
let mut seed = 7u64;
let mut rnd = || {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((seed >> 33) as f32 / u32::MAX as f32) - 0.25
};
let vecs: Vec<Vec<f32>> = (0..500)
.map(|_| (0..dim).map(|_| rnd()).collect())
.collect();
for v in &vecs {
idx.add(v);
}
let q: Vec<f32> = (0..dim).map(|_| rnd()).collect();
let mut all: Vec<(usize, f32)> = vecs
.iter()
.enumerate()
.map(|(i, v)| (i, dot(&q, v)))
.collect();
all.sort_by(|a, b| b.1.total_cmp(&a.1));
let top: Vec<usize> = idx.search(&q, 10, f32::MIN).iter().map(|s| s.id).collect();
assert_eq!(top, all[..10].iter().map(|x| x.0).collect::<Vec<_>>());
}
}