Skip to main content

velesdb_core/
point.rs

1//! Point data structure representing a vector with metadata.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value as JsonValue;
7
8use crate::sparse_index::{SparseVector, DEFAULT_SPARSE_INDEX_NAME};
9
10/// A point in the vector database.
11///
12/// A point consists of:
13/// - A unique identifier
14/// - A dense vector (embedding)
15/// - Optional payload (metadata)
16/// - Optional named sparse vectors (e.g., SPLADE, BM25 term weights)
17#[derive(Debug, Clone, Serialize)]
18pub struct Point {
19    /// Unique identifier for the point.
20    pub id: u64,
21
22    /// The dense vector embedding.
23    pub vector: Vec<f32>,
24
25    /// Optional JSON payload containing metadata.
26    #[serde(default)]
27    pub payload: Option<JsonValue>,
28
29    /// Optional named sparse vectors for hybrid dense+sparse search.
30    ///
31    /// Keys are sparse vector names (e.g., `""` for default, `"title"`, `"body"`).
32    /// Enables multi-model support (BGE-M3, SPLADE title+body).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub sparse_vectors: Option<BTreeMap<String, SparseVector>>,
35}
36
37/// Custom deserializer that accepts both:
38/// - `"sparse_vectors": {"name": {...}}` (new named map format)
39/// - `"sparse_vector": {...}` (old single format, wraps in map under `""` key)
40impl<'de> Deserialize<'de> for Point {
41    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
42    where
43        D: serde::Deserializer<'de>,
44    {
45        #[derive(Deserialize)]
46        struct PointHelper {
47            id: u64,
48            vector: Vec<f32>,
49            #[serde(default)]
50            payload: Option<JsonValue>,
51            #[serde(default)]
52            sparse_vectors: Option<BTreeMap<String, SparseVector>>,
53            /// Old single-vector field for backward compat.
54            #[serde(default)]
55            sparse_vector: Option<SparseVector>,
56        }
57
58        let helper = PointHelper::deserialize(deserializer)?;
59
60        // Prefer new `sparse_vectors` field; fall back to old `sparse_vector`.
61        let sparse_vectors = if helper.sparse_vectors.is_some() {
62            helper.sparse_vectors
63        } else {
64            helper.sparse_vector.map(|sv| {
65                let mut map = BTreeMap::new();
66                // Use the canonical constant to avoid magic empty-string literals.
67                map.insert(DEFAULT_SPARSE_INDEX_NAME.to_string(), sv);
68                map
69            })
70        };
71
72        Ok(Point {
73            id: helper.id,
74            vector: helper.vector,
75            payload: helper.payload,
76            sparse_vectors,
77        })
78    }
79}
80
81impl Point {
82    /// Creates a new point with the given ID, vector, and optional payload.
83    ///
84    /// # Arguments
85    ///
86    /// * `id` - Unique identifier
87    /// * `vector` - Vector embedding
88    /// * `payload` - Optional metadata
89    #[must_use]
90    pub fn new(id: u64, vector: Vec<f32>, payload: Option<JsonValue>) -> Self {
91        Self {
92            id,
93            vector,
94            payload,
95            sparse_vectors: None,
96        }
97    }
98
99    /// Creates a new point without payload.
100    #[must_use]
101    pub fn without_payload(id: u64, vector: Vec<f32>) -> Self {
102        Self::new(id, vector, None)
103    }
104
105    /// Creates a metadata-only point (no vector, only payload).
106    ///
107    /// Used for metadata-only collections that don't store vectors.
108    ///
109    /// # Arguments
110    ///
111    /// * `id` - Unique identifier
112    /// * `payload` - Metadata (JSON value)
113    #[must_use]
114    pub fn metadata_only(id: u64, payload: JsonValue) -> Self {
115        Self {
116            id,
117            vector: Vec::new(), // Empty vector
118            payload: Some(payload),
119            sparse_vectors: None,
120        }
121    }
122
123    /// Creates a point with both dense and named sparse vectors.
124    ///
125    /// # Arguments
126    ///
127    /// * `id` - Unique identifier
128    /// * `vector` - Dense vector embedding
129    /// * `payload` - Optional metadata
130    /// * `sparse_vectors` - Optional named sparse vectors
131    #[must_use]
132    pub fn with_sparse(
133        id: u64,
134        vector: Vec<f32>,
135        payload: Option<JsonValue>,
136        sparse_vectors: Option<BTreeMap<String, SparseVector>>,
137    ) -> Self {
138        Self {
139            id,
140            vector,
141            payload,
142            sparse_vectors,
143        }
144    }
145
146    /// Creates a sparse-only point (no dense vector).
147    ///
148    /// # Arguments
149    ///
150    /// * `id` - Unique identifier
151    /// * `sparse_vector` - The sparse vector (stored under the default `""` name)
152    /// * `payload` - Optional metadata
153    #[must_use]
154    pub fn sparse_only(id: u64, sparse_vector: SparseVector, payload: Option<JsonValue>) -> Self {
155        let mut map = BTreeMap::new();
156        // Use the canonical constant to avoid magic empty-string literals.
157        map.insert(DEFAULT_SPARSE_INDEX_NAME.to_string(), sparse_vector);
158        Self {
159            id,
160            vector: Vec::new(),
161            payload,
162            sparse_vectors: Some(map),
163        }
164    }
165
166    /// Returns `true` if this point has any sparse vectors.
167    #[must_use]
168    pub fn has_sparse_vectors(&self) -> bool {
169        self.sparse_vectors.as_ref().is_some_and(|m| !m.is_empty())
170    }
171
172    /// Returns the dimension of the vector.
173    #[must_use]
174    pub fn dimension(&self) -> usize {
175        self.vector.len()
176    }
177
178    /// Returns `true` if this point carries **no dense vector**.
179    ///
180    /// This is a *per-point* property — it means `self.vector` is empty, i.e.
181    /// the point was built via [`Point::metadata_only`] or [`Point::sparse_only`]
182    /// (a sparse-only point is still "metadata-only" in the dense sense: it has
183    /// no dense embedding, only sparse vectors and/or payload). It is **not**
184    /// the same concept as the collection-kind `CollectionType::is_metadata_only`
185    /// (a discriminant on how a whole collection is stored, in the
186    /// persistence-gated `collection` module).
187    ///
188    /// Note there is no "dense vector of length 0": an empty `vector` *is* the
189    /// canonical representation of "no dense vector", so this predicate is exact
190    /// and unambiguous. Collections still validate their declared dimension to
191    /// [`MIN_DIMENSION`](crate::validation::MIN_DIMENSION)` = 1` (see
192    /// `validation::validate_dimension`); a metadata-only point simply omits the
193    /// dense vector rather than storing a zero-length one under that dimension.
194    #[must_use]
195    pub fn is_metadata_only(&self) -> bool {
196        self.vector.is_empty()
197    }
198}
199
200/// Per-component score breakdown for hybrid search results.
201///
202/// Stores individual scores from each search pipeline component (vector,
203/// BM25, graph, sparse) so arithmetic ORDER BY expressions like
204/// `0.7 * vector_score + 0.3 * bm25_score` can resolve each variable
205/// independently instead of mapping everything to the fused score.
206///
207/// Uses `SmallVec<4>` to avoid heap allocation for typical queries
208/// (at most 2-3 components: vector, text, sparse).
209pub type ComponentScores = smallvec::SmallVec<[(&'static str, f32); 4]>;
210
211/// A search result containing a point and its similarity score.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct SearchResult {
214    /// The matching point.
215    pub point: Point,
216
217    /// Similarity score (interpretation depends on the distance metric).
218    pub score: f32,
219
220    /// Optional per-component score breakdown for arithmetic ORDER BY.
221    ///
222    /// When present, variables like `vector_score` and `bm25_score` resolve
223    /// to their individual component values. When `None`, all score variables
224    /// fall back to `score` (the fused/primary score).
225    #[serde(default, skip_serializing_if = "Option::is_none", skip_deserializing)]
226    pub component_scores: Option<ComponentScores>,
227}
228
229impl SearchResult {
230    /// Creates a new search result with no component score breakdown.
231    #[must_use]
232    pub fn new(point: Point, score: f32) -> Self {
233        Self {
234            point,
235            score,
236            component_scores: None,
237        }
238    }
239
240    /// Creates a search result with per-component score breakdown.
241    #[must_use]
242    pub fn with_component_scores(
243        point: Point,
244        score: f32,
245        component_scores: ComponentScores,
246    ) -> Self {
247        Self {
248            point,
249            score,
250            component_scores: if component_scores.is_empty() {
251                None
252            } else {
253                Some(component_scores)
254            },
255        }
256    }
257}