velesdb-mobile 5.2.0

VelesDB mobile bindings for iOS and Android via UniFFI
Documentation
//! Sparse vector operations for `VelesCollection` (UniFFI-exported).
//!
//! Extracted from `collection.rs` to reduce NLOC below the 500 threshold.

use velesdb_core::{Filter, FusionStrategy as CoreFusionStrategy, QueryOperationKind};

use crate::collection::{and_scope, deny_if_scoped};
use crate::types::{FusionStrategy, SearchResult, VelesError, VelesPoint, VelesSparseVector};
use crate::VelesCollection;

#[uniffi::export]
impl VelesCollection {
    /// Performs sparse-only search using an inverted index.
    ///
    /// # Arguments
    ///
    /// * `sparse_vector` - Query sparse vector (parallel arrays of indices/values)
    /// * `limit` - Maximum number of results
    /// * `index_name` - Name of the sparse index (empty string for default)
    ///
    /// # Returns
    ///
    /// Vector of search results sorted by sparse similarity.
    pub fn sparse_search(
        &self,
        sparse_vector: VelesSparseVector,
        limit: u32,
        index_name: Option<String>,
    ) -> Result<Vec<SearchResult>, VelesError> {
        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
        let idx_name = index_name.unwrap_or_default();

        // Sparse search has no `GatedRead` leaf and no metadata-filtered twin,
        // so it consults the read gate directly and fails closed if the
        // observer asks for a scope filter it cannot apply (audit F-5.4, #1392).
        let scope =
            self.db
                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
        deny_if_scoped(scope, "sparse_search")?;

        let results = self
            .inner
            .sparse_search(
                &core_sv,
                usize::try_from(limit).unwrap_or(usize::MAX),
                &idx_name,
            )
            .map_err(|e| VelesError::database(format!("Sparse search failed: {e}")))?;

        Ok(results
            .into_iter()
            .map(|r| SearchResult {
                id: r.point.id,
                score: r.score,
                payload: None,
            })
            .collect())
    }

    /// Performs hybrid dense+sparse search with RRF fusion.
    ///
    /// Combines vector similarity search with sparse (keyword) search
    /// using Reciprocal Rank Fusion.
    ///
    /// # Arguments
    ///
    /// * `vector` - Dense query vector
    /// * `sparse_vector` - Sparse query vector (parallel arrays)
    /// * `limit` - Maximum number of results
    /// * `index_name` - Name of the sparse index (empty string or `None` for default)
    ///
    /// # Returns
    ///
    /// Vector of fused search results.
    pub fn hybrid_sparse_search(
        &self,
        vector: Vec<f32>,
        sparse_vector: VelesSparseVector,
        limit: u32,
        index_name: Option<String>,
    ) -> Result<Vec<SearchResult>, VelesError> {
        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
        let strategy = velesdb_core::fusion::FusionStrategy::RRF { k: 60 };
        let idx_name = index_name.unwrap_or_default();

        // Fused dense+sparse leaf takes no metadata filter, so consult the gate
        // and fail closed on an unappliable observer scope (audit F-5.4, #1392).
        let scope =
            self.db
                .authorize_read(&self.name, QueryOperationKind::HybridSearch, None, None)?;
        deny_if_scoped(scope, "hybrid_sparse_search")?;

        let results = self
            .inner
            .hybrid_sparse_search(
                &vector,
                &core_sv,
                usize::try_from(limit).unwrap_or(usize::MAX),
                &idx_name,
                &strategy,
            )
            .map_err(|e| VelesError::database(format!("Hybrid sparse search failed: {e}")))?;

        Ok(results
            .into_iter()
            .map(|r| SearchResult {
                id: r.point.id,
                score: r.score,
                payload: None,
            })
            .collect())
    }

    /// Performs multi-query search with result fusion.
    pub fn multi_query_search(
        &self,
        vectors: Vec<Vec<f32>>,
        limit: u32,
        strategy: FusionStrategy,
    ) -> Result<Vec<SearchResult>, VelesError> {
        if vectors.is_empty() {
            return Err(VelesError::database(
                "multi_query_search requires at least one vector".to_string(),
            ));
        }

        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
        let core_strategy: CoreFusionStrategy = strategy.into();

        // Consult the read gate, then AND any observer scope filter into the
        // fusion leaf (which accepts a metadata filter) so narrowing is
        // enforced (Deny propagates as an error) (audit F-5.4, #1392).
        let scope =
            self.db
                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
        let effective: Option<Filter> = and_scope(None, scope);

        let results = self
            .inner
            .multi_query_search(
                &query_refs,
                usize::try_from(limit).unwrap_or(usize::MAX),
                core_strategy,
                effective.as_ref(),
            )
            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;

        Ok(results
            .into_iter()
            .map(|r| SearchResult {
                id: r.point.id,
                score: r.score,
                payload: None,
            })
            .collect())
    }

    /// Performs multi-query search returning IDs and scores only.
    ///
    /// Id-only twin of [`Self::multi_query_search`]: reuses the same fusion
    /// path but strips payloads, avoiding payload materialization.
    pub fn multi_query_search_ids(
        &self,
        vectors: Vec<Vec<f32>>,
        limit: u32,
        strategy: FusionStrategy,
    ) -> Result<Vec<SearchResult>, VelesError> {
        if vectors.is_empty() {
            return Err(VelesError::database(
                "multi_query_search requires at least one vector".to_string(),
            ));
        }

        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
        let core_strategy: CoreFusionStrategy = strategy.into();

        // Ids/scores-only results carry no payload, so an observer scope filter
        // cannot be enforced on them: consult the gate and fail closed rather
        // than return unscoped ids (audit F-5.4, #1392).
        let scope =
            self.db
                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
        deny_if_scoped(scope, "multi_query_search_ids")?;

        let results = self
            .inner
            .multi_query_search_ids(
                &query_refs,
                usize::try_from(limit).unwrap_or(usize::MAX),
                core_strategy,
            )
            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;

        Ok(results
            .into_iter()
            .map(|(id, score)| SearchResult {
                id,
                score,
                payload: None,
            })
            .collect())
    }

    /// Performs multi-query search with metadata filtering.
    pub fn multi_query_search_with_filter(
        &self,
        vectors: Vec<Vec<f32>>,
        limit: u32,
        strategy: FusionStrategy,
        filter_json: String,
    ) -> Result<Vec<SearchResult>, VelesError> {
        if vectors.is_empty() {
            return Err(VelesError::database(
                "multi_query_search requires at least one vector".to_string(),
            ));
        }

        let filter: Filter = serde_json::from_str(&filter_json)
            .map_err(|e| VelesError::database(format!("Invalid filter JSON: {e}")))?;

        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
        let core_strategy: CoreFusionStrategy = strategy.into();

        // Consult the read gate and AND any observer scope filter into the
        // caller filter (narrow-only) before running (audit F-5.4, #1392).
        let scope =
            self.db
                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
        let effective = and_scope(Some(filter), scope);

        let results = self
            .inner
            .multi_query_search(
                &query_refs,
                usize::try_from(limit).unwrap_or(usize::MAX),
                core_strategy,
                effective.as_ref(),
            )
            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;

        Ok(results
            .into_iter()
            .map(|r| SearchResult {
                id: r.point.id,
                score: r.score,
                payload: None,
            })
            .collect())
    }

    /// Inserts or updates a point with an associated sparse vector.
    ///
    /// # Arguments
    ///
    /// * `point` - The point to upsert (dense vector + payload)
    /// * `sparse_vector` - Sparse vector to associate with this point
    pub fn upsert_with_sparse(
        &self,
        point: VelesPoint,
        sparse_vector: VelesSparseVector,
    ) -> Result<(), VelesError> {
        let payload = point
            .payload
            .map(|s| serde_json::from_str(&s))
            .transpose()
            .map_err(|e| VelesError::database(format!("Invalid JSON payload: {e}")))?;

        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
        let mut sparse_map = std::collections::BTreeMap::new();
        sparse_map.insert(String::new(), core_sv);

        let core_point =
            velesdb_core::Point::with_sparse(point.id, point.vector, payload, Some(sparse_map));
        self.inner.upsert(vec![core_point])?;
        Ok(())
    }
}

impl VelesCollection {
    /// Converts a `VelesSparseVector` (UniFFI-safe parallel arrays) to the
    /// core `SparseVector` type.
    pub(crate) fn to_core_sparse_vector(
        sv: &VelesSparseVector,
    ) -> velesdb_core::sparse_index::SparseVector {
        let pairs: Vec<(u32, f32)> = sv
            .indices
            .iter()
            .copied()
            .zip(sv.values.iter().copied())
            .collect();
        velesdb_core::sparse_index::SparseVector::new(pairs)
    }
}