Skip to main content

velesdb_mobile/
collection_sparse.rs

1//! Sparse vector operations for `VelesCollection` (UniFFI-exported).
2//!
3//! Extracted from `collection.rs` to reduce NLOC below the 500 threshold.
4
5use velesdb_core::{Filter, FusionStrategy as CoreFusionStrategy, QueryOperationKind};
6
7use crate::collection::{and_scope, deny_if_scoped};
8use crate::types::{FusionStrategy, SearchResult, VelesError, VelesPoint, VelesSparseVector};
9use crate::VelesCollection;
10
11#[uniffi::export]
12impl VelesCollection {
13    /// Performs sparse-only search using an inverted index.
14    ///
15    /// # Arguments
16    ///
17    /// * `sparse_vector` - Query sparse vector (parallel arrays of indices/values)
18    /// * `limit` - Maximum number of results
19    /// * `index_name` - Name of the sparse index (empty string for default)
20    ///
21    /// # Returns
22    ///
23    /// Vector of search results sorted by sparse similarity.
24    pub fn sparse_search(
25        &self,
26        sparse_vector: VelesSparseVector,
27        limit: u32,
28        index_name: Option<String>,
29    ) -> Result<Vec<SearchResult>, VelesError> {
30        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
31        let idx_name = index_name.unwrap_or_default();
32
33        // Sparse search has no `GatedRead` leaf and no metadata-filtered twin,
34        // so it consults the read gate directly and fails closed if the
35        // observer asks for a scope filter it cannot apply (audit F-5.4, #1392).
36        let scope =
37            self.db
38                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
39        deny_if_scoped(scope, "sparse_search")?;
40
41        let results = self
42            .inner
43            .sparse_search(
44                &core_sv,
45                usize::try_from(limit).unwrap_or(usize::MAX),
46                &idx_name,
47            )
48            .map_err(|e| VelesError::database(format!("Sparse search failed: {e}")))?;
49
50        Ok(results
51            .into_iter()
52            .map(|r| SearchResult {
53                id: r.point.id,
54                score: r.score,
55                payload: None,
56            })
57            .collect())
58    }
59
60    /// Performs hybrid dense+sparse search with RRF fusion.
61    ///
62    /// Combines vector similarity search with sparse (keyword) search
63    /// using Reciprocal Rank Fusion.
64    ///
65    /// # Arguments
66    ///
67    /// * `vector` - Dense query vector
68    /// * `sparse_vector` - Sparse query vector (parallel arrays)
69    /// * `limit` - Maximum number of results
70    /// * `index_name` - Name of the sparse index (empty string or `None` for default)
71    ///
72    /// # Returns
73    ///
74    /// Vector of fused search results.
75    pub fn hybrid_sparse_search(
76        &self,
77        vector: Vec<f32>,
78        sparse_vector: VelesSparseVector,
79        limit: u32,
80        index_name: Option<String>,
81    ) -> Result<Vec<SearchResult>, VelesError> {
82        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
83        let strategy = velesdb_core::fusion::FusionStrategy::RRF { k: 60 };
84        let idx_name = index_name.unwrap_or_default();
85
86        // Fused dense+sparse leaf takes no metadata filter, so consult the gate
87        // and fail closed on an unappliable observer scope (audit F-5.4, #1392).
88        let scope =
89            self.db
90                .authorize_read(&self.name, QueryOperationKind::HybridSearch, None, None)?;
91        deny_if_scoped(scope, "hybrid_sparse_search")?;
92
93        let results = self
94            .inner
95            .hybrid_sparse_search(
96                &vector,
97                &core_sv,
98                usize::try_from(limit).unwrap_or(usize::MAX),
99                &idx_name,
100                &strategy,
101            )
102            .map_err(|e| VelesError::database(format!("Hybrid sparse search failed: {e}")))?;
103
104        Ok(results
105            .into_iter()
106            .map(|r| SearchResult {
107                id: r.point.id,
108                score: r.score,
109                payload: None,
110            })
111            .collect())
112    }
113
114    /// Performs multi-query search with result fusion.
115    pub fn multi_query_search(
116        &self,
117        vectors: Vec<Vec<f32>>,
118        limit: u32,
119        strategy: FusionStrategy,
120    ) -> Result<Vec<SearchResult>, VelesError> {
121        if vectors.is_empty() {
122            return Err(VelesError::database(
123                "multi_query_search requires at least one vector".to_string(),
124            ));
125        }
126
127        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
128        let core_strategy: CoreFusionStrategy = strategy.into();
129
130        // Consult the read gate, then AND any observer scope filter into the
131        // fusion leaf (which accepts a metadata filter) so narrowing is
132        // enforced (Deny propagates as an error) (audit F-5.4, #1392).
133        let scope =
134            self.db
135                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
136        let effective: Option<Filter> = and_scope(None, scope);
137
138        let results = self
139            .inner
140            .multi_query_search(
141                &query_refs,
142                usize::try_from(limit).unwrap_or(usize::MAX),
143                core_strategy,
144                effective.as_ref(),
145            )
146            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;
147
148        Ok(results
149            .into_iter()
150            .map(|r| SearchResult {
151                id: r.point.id,
152                score: r.score,
153                payload: None,
154            })
155            .collect())
156    }
157
158    /// Performs multi-query search returning IDs and scores only.
159    ///
160    /// Id-only twin of [`Self::multi_query_search`]: reuses the same fusion
161    /// path but strips payloads, avoiding payload materialization.
162    pub fn multi_query_search_ids(
163        &self,
164        vectors: Vec<Vec<f32>>,
165        limit: u32,
166        strategy: FusionStrategy,
167    ) -> Result<Vec<SearchResult>, VelesError> {
168        if vectors.is_empty() {
169            return Err(VelesError::database(
170                "multi_query_search requires at least one vector".to_string(),
171            ));
172        }
173
174        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
175        let core_strategy: CoreFusionStrategy = strategy.into();
176
177        // Ids/scores-only results carry no payload, so an observer scope filter
178        // cannot be enforced on them: consult the gate and fail closed rather
179        // than return unscoped ids (audit F-5.4, #1392).
180        let scope =
181            self.db
182                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
183        deny_if_scoped(scope, "multi_query_search_ids")?;
184
185        let results = self
186            .inner
187            .multi_query_search_ids(
188                &query_refs,
189                usize::try_from(limit).unwrap_or(usize::MAX),
190                core_strategy,
191            )
192            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;
193
194        Ok(results
195            .into_iter()
196            .map(|(id, score)| SearchResult {
197                id,
198                score,
199                payload: None,
200            })
201            .collect())
202    }
203
204    /// Performs multi-query search with metadata filtering.
205    pub fn multi_query_search_with_filter(
206        &self,
207        vectors: Vec<Vec<f32>>,
208        limit: u32,
209        strategy: FusionStrategy,
210        filter_json: String,
211    ) -> Result<Vec<SearchResult>, VelesError> {
212        if vectors.is_empty() {
213            return Err(VelesError::database(
214                "multi_query_search requires at least one vector".to_string(),
215            ));
216        }
217
218        let filter: Filter = serde_json::from_str(&filter_json)
219            .map_err(|e| VelesError::database(format!("Invalid filter JSON: {e}")))?;
220
221        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
222        let core_strategy: CoreFusionStrategy = strategy.into();
223
224        // Consult the read gate and AND any observer scope filter into the
225        // caller filter (narrow-only) before running (audit F-5.4, #1392).
226        let scope =
227            self.db
228                .authorize_read(&self.name, QueryOperationKind::VectorSearch, None, None)?;
229        let effective = and_scope(Some(filter), scope);
230
231        let results = self
232            .inner
233            .multi_query_search(
234                &query_refs,
235                usize::try_from(limit).unwrap_or(usize::MAX),
236                core_strategy,
237                effective.as_ref(),
238            )
239            .map_err(|e| VelesError::database(format!("Multi-query search failed: {e}")))?;
240
241        Ok(results
242            .into_iter()
243            .map(|r| SearchResult {
244                id: r.point.id,
245                score: r.score,
246                payload: None,
247            })
248            .collect())
249    }
250
251    /// Inserts or updates a point with an associated sparse vector.
252    ///
253    /// # Arguments
254    ///
255    /// * `point` - The point to upsert (dense vector + payload)
256    /// * `sparse_vector` - Sparse vector to associate with this point
257    pub fn upsert_with_sparse(
258        &self,
259        point: VelesPoint,
260        sparse_vector: VelesSparseVector,
261    ) -> Result<(), VelesError> {
262        let payload = point
263            .payload
264            .map(|s| serde_json::from_str(&s))
265            .transpose()
266            .map_err(|e| VelesError::database(format!("Invalid JSON payload: {e}")))?;
267
268        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
269        let mut sparse_map = std::collections::BTreeMap::new();
270        sparse_map.insert(String::new(), core_sv);
271
272        let core_point =
273            velesdb_core::Point::with_sparse(point.id, point.vector, payload, Some(sparse_map));
274        self.inner.upsert(vec![core_point])?;
275        Ok(())
276    }
277}
278
279impl VelesCollection {
280    /// Converts a `VelesSparseVector` (UniFFI-safe parallel arrays) to the
281    /// core `SparseVector` type.
282    pub(crate) fn to_core_sparse_vector(
283        sv: &VelesSparseVector,
284    ) -> velesdb_core::sparse_index::SparseVector {
285        let pairs: Vec<(u32, f32)> = sv
286            .indices
287            .iter()
288            .copied()
289            .zip(sv.values.iter().copied())
290            .collect();
291        velesdb_core::sparse_index::SparseVector::new(pairs)
292    }
293}