vector-index 0.1.0

Generic HNSW vector index with pluggable distance metrics.
Documentation
//! Concurrent access wrapper over [`HnswIndex`].
//!
//! Wraps the index in `Arc<RwLock<_>>` (using `parking_lot` for performance)
//! to support the architecture's "high-velocity concurrent reads +
//! append-writes" pattern. Cloning a [`ConcurrentHnsw`] is cheap — both
//! clones share the same underlying index.
//!
//! # Write contention
//!
//! `parking_lot::RwLock` is writer-preferring, so a long-running search
//! cannot starve an insert. For workloads with very high write frequency,
//! consider batching inserts under a single write lock.
//!
//! # Sharded variant
//!
//! For workloads that genuinely need parallel inserts, a sharded index
//! (multiple sub-indices keyed by hash(id)) is on the roadmap. The
//! single-lock version covers the OmniPulse usage pattern (1 inserter,
//! many searchers) cleanly.

use crate::error::IndexResult;
use crate::hnsw::{HnswConfig, HnswIndex, Neighbor};
use crate::metric::Metric;
use crate::PointId;
use parking_lot::RwLock;
use std::sync::Arc;

/// Thread-safe handle to a shared HNSW index.
pub struct ConcurrentHnsw<P, M>
where
    M: Metric<Point = P>,
{
    inner: Arc<RwLock<HnswIndex<P, M>>>,
}

impl<P, M> Clone for ConcurrentHnsw<P, M>
where
    M: Metric<Point = P>,
{
    fn clone(&self) -> Self {
        Self {
            inner: Arc::clone(&self.inner),
        }
    }
}

impl<P, M> ConcurrentHnsw<P, M>
where
    M: Metric<Point = P>,
{
    /// Construct a new shared index.
    pub fn new(config: HnswConfig, metric: M) -> IndexResult<Self> {
        Ok(Self {
            inner: Arc::new(RwLock::new(HnswIndex::new(config, metric)?)),
        })
    }

    /// Insert a point. Acquires the write lock.
    pub fn insert(&self, id: PointId, point: P) -> IndexResult<()> {
        self.inner.write().insert(id, point)
    }

    /// Search for k nearest neighbors. Acquires the read lock.
    pub fn search(&self, query: &P, k: usize) -> Vec<Neighbor> {
        self.inner.read().search(query, k)
    }

    /// Number of points currently in the index.
    pub fn len(&self) -> usize {
        self.inner.read().len()
    }

    /// Whether the index is empty.
    pub fn is_empty(&self) -> bool {
        self.inner.read().is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::metric::L2;
    use std::thread;

    #[test]
    fn concurrent_reads_during_inserts() {
        let idx: ConcurrentHnsw<Vec<f32>, L2> =
            ConcurrentHnsw::new(HnswConfig::default(), L2).unwrap();

        // Seed with a few points.
        for i in 0..50 {
            idx.insert(i, vec![i as f32, 0.0]).unwrap();
        }

        // Spawn readers and a writer; assert nothing panics and lengths
        // are monotonically non-decreasing from the reader's perspective.
        let writer = {
            let idx = idx.clone();
            thread::spawn(move || {
                for i in 50..150 {
                    idx.insert(i, vec![i as f32, 0.0]).unwrap();
                }
            })
        };

        let readers: Vec<_> = (0..4)
            .map(|_| {
                let idx = idx.clone();
                thread::spawn(move || {
                    for _ in 0..200 {
                        let res = idx.search(&vec![25.0, 0.0], 5);
                        assert!(!res.is_empty());
                    }
                })
            })
            .collect();

        writer.join().unwrap();
        for r in readers {
            r.join().unwrap();
        }

        assert_eq!(idx.len(), 150);
    }
}