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}