velesdb_core/database/vector_ops.rs
1//! Vector collection creation and retrieval operations.
2
3use crate::collection::VectorCollection;
4use crate::index::hnsw::HnswParams;
5use crate::{CollectionType, DistanceMetric, Result, StorageMode};
6
7use super::Database;
8
9impl Database {
10 /// Resolves the HNSW parameters for a collection about to be created.
11 ///
12 /// Implements the `[hnsw]` half of the configuration precedence chain
13 /// (issue #2087). From strongest to weakest:
14 ///
15 /// 1. **Per-collection creation argument** — the `m` / `ef_construction`
16 /// passed to the constructor, or a whole `HnswParams` handed to
17 /// [`Database::create_vector_collection_with_params`], which does not
18 /// consult the config at all.
19 /// 2. **`VelesConfig`'s `[hnsw]` section** — the deployment-wide default.
20 /// 3. **Built-in engine default** — `HnswParams::auto(dimension)`.
21 ///
22 /// Per-query `WITH (...)` overrides sit above all three but never reach
23 /// here: they tune `ef_search` at query time, whereas everything resolved
24 /// in this function is graph *topology*, fixed when the index is built.
25 ///
26 /// Because the resolved params are persisted in the collection's
27 /// `config.json`, a collection keeps the topology it was created with:
28 /// editing `[hnsw]` later changes new collections only. Applying it to an
29 /// existing one is a full index rebuild, which is `auto_reindex`'s job,
30 /// not a config reload's.
31 ///
32 /// Resolution is layered here, in the only component that owns a
33 /// `VelesConfig`, so a direct `VectorCollection::create` caller is
34 /// unaffected by any file on disk: it receives a `HnswParams` value that
35 /// is already an answer.
36 ///
37 /// That is a claim about *decisions*, not about imports.
38 /// [`HnswParams::from_config`](crate::index::hnsw::HnswParams::from_config)
39 /// names `config::HnswConfig` in its signature, exactly as
40 /// `RuntimeLimits::from_config` names `LimitsConfig` — one pure mapping
41 /// function per table, sitting beside the type it produces. What neither
42 /// module does is *read* configuration: nothing below this function
43 /// consults a `VelesConfig`, and the precedence chain exists only here.
44 ///
45 /// `storage_mode` is left at whatever `HnswParams::auto` produced: every
46 /// constructor downstream overwrites it with the collection's own storage
47 /// mode argument.
48 ///
49 /// Returns `None` when **no** level chose anything — neither argument, and
50 /// an untouched `[hnsw]` section. That is not the same as returning
51 /// `HnswParams::auto(dimension)`: a collection persists this value, and
52 /// `hnsw_params: None` on disk means "nobody ever chose", exactly as
53 /// `pq_rescore_oversampling: None` does a field away. Materializing a
54 /// snapshot of today's auto-tuned defaults into that slot would build the
55 /// identical index and destroy the distinction a later migration reads.
56 /// Callers that need a concrete value regardless say so at the call site
57 /// with `unwrap_or_else(|| HnswParams::auto(dimension))`.
58 pub(super) fn resolve_hnsw_params(
59 &self,
60 dimension: usize,
61 m: Option<usize>,
62 ef_construction: Option<usize>,
63 ) -> Option<HnswParams> {
64 if m.is_none()
65 && ef_construction.is_none()
66 && self.config.hnsw.m.is_none()
67 && self.config.hnsw.ef_construction.is_none()
68 {
69 return None;
70 }
71
72 // Level 2 first, then level 1 on top of it, so a per-field argument
73 // overrides only the field it names.
74 let mut params = HnswParams::from_config(dimension, &self.config.hnsw);
75 if let Some(m) = m {
76 params.max_connections = m;
77 }
78 if let Some(ef) = ef_construction {
79 params.ef_construction = ef;
80 }
81 Some(params)
82 }
83
84 /// Creates a new vector collection.
85 ///
86 /// # Errors
87 ///
88 /// Returns an error if a collection with the same name already exists.
89 pub fn create_vector_collection(
90 &self,
91 name: &str,
92 dimension: usize,
93 metric: DistanceMetric,
94 ) -> Result<()> {
95 self.create_vector_collection_with_options(name, dimension, metric, StorageMode::default())
96 }
97
98 /// Creates a new vector collection with custom storage options.
99 ///
100 /// # Errors
101 ///
102 /// Returns an error if a collection with the same name already exists
103 /// or if the dimension exceeds the configured `max_dimensions` limit.
104 pub fn create_vector_collection_with_options(
105 &self,
106 name: &str,
107 dimension: usize,
108 metric: DistanceMetric,
109 storage_mode: StorageMode,
110 ) -> Result<()> {
111 self.ensure_collection_name_available(name)?;
112 self.enforce_vector_dimension_limit(dimension)?;
113 let path = self.data_dir.join(name);
114 // #2087: no per-collection HNSW argument here, so the `[hnsw]` section
115 // is the strongest level that applies. An untouched section resolves
116 // to `None` and takes the original constructor, so this path is
117 // byte-for-byte unchanged for anyone who did not configure `[hnsw]` —
118 // including the `hnsw_params: None` it persists.
119 let coll = match self.resolve_hnsw_params(dimension, None, None) {
120 Some(params) => VectorCollection::create_with_hnsw_params(
121 path,
122 dimension,
123 metric,
124 storage_mode,
125 params,
126 )?,
127 None => VectorCollection::create(path, name, dimension, metric, storage_mode)?,
128 };
129 self.register_vector_collection(name, &coll, dimension, metric, storage_mode);
130 Ok(())
131 }
132
133 /// Creates a new vector collection with custom HNSW parameters.
134 ///
135 /// When `m` or `ef_construction` are `Some`, those values win over the
136 /// configured `[hnsw]` section, which in turn wins over the
137 /// dimension-based auto-tuned defaults from [`HnswParams::auto`] — see
138 /// [`Database::resolve_hnsw_params`] for the full chain. The two
139 /// arguments are resolved independently, so pinning one still takes the
140 /// other from config.
141 ///
142 /// Shortcut for [`Database::create_vector_collection_with_params`] that
143 /// only overrides `max_connections` and `ef_construction`.
144 ///
145 /// # Errors
146 ///
147 /// Returns an error if a collection with the same name already exists.
148 pub fn create_vector_collection_with_hnsw(
149 &self,
150 name: &str,
151 dimension: usize,
152 metric: DistanceMetric,
153 storage_mode: StorageMode,
154 m: Option<usize>,
155 ef_construction: Option<usize>,
156 ) -> Result<()> {
157 self.ensure_collection_name_available(name)?;
158 self.enforce_vector_dimension_limit(dimension)?;
159 let path = self.data_dir.join(name);
160 // #2087: each argument is resolved on its own — a caller that pins
161 // only `m` still picks up `ef_construction` from `[hnsw]`.
162 //
163 // Materialized unconditionally, unlike the path above: this
164 // constructor already persisted `Some(HnswParams::auto(dimension))`
165 // before the wiring, so keeping `None` here would be the behaviour
166 // change rather than avoiding one.
167 let params = self
168 .resolve_hnsw_params(dimension, m, ef_construction)
169 .unwrap_or_else(|| HnswParams::auto(dimension));
170 let coll = VectorCollection::create_with_params(
171 path,
172 dimension,
173 metric,
174 storage_mode,
175 params,
176 None,
177 )?;
178 self.register_vector_collection(name, &coll, dimension, metric, storage_mode);
179 Ok(())
180 }
181
182 /// Creates a new vector collection with a fully specified
183 /// [`HnswParams`] and an explicit `pq_rescore_oversampling` override.
184 ///
185 /// This is the most expressive vector constructor exposed by
186 /// `Database`: callers pass every HNSW parameter — `max_connections`,
187 /// `ef_construction`, `max_elements`, `alpha`, storage mode — via a
188 /// single value, and override the PQ rescore factor explicitly rather
189 /// than implicitly falling back to the engine default of `Some(4)`.
190 /// Passing `pq_rescore_oversampling = None` keeps the persisted config
191 /// in "no explicit override" mode so later migrations can recompute
192 /// the factor from dataset shape.
193 ///
194 /// The storage mode argument wins over `hnsw_params.storage_mode` if
195 /// they disagree — the field on `HnswParams` is a legacy denormalised
196 /// copy that the engine keeps in sync with the collection-level value.
197 ///
198 /// The configured `[hnsw]` section is **not** consulted: `hnsw_params` is
199 /// already a complete answer, and silently merging a deployment default
200 /// into a fully specified value would make the result depend on a file the
201 /// caller did not mention. Callers wanting the config as a base should
202 /// build from [`HnswParams::from_config`] and adjust from there.
203 ///
204 /// # Errors
205 ///
206 /// Returns an error if a collection with the same name already exists
207 /// or if the underlying directory cannot be created.
208 pub fn create_vector_collection_with_params(
209 &self,
210 name: &str,
211 dimension: usize,
212 metric: DistanceMetric,
213 storage_mode: StorageMode,
214 hnsw_params: HnswParams,
215 pq_rescore_oversampling: Option<u32>,
216 ) -> Result<()> {
217 self.ensure_collection_name_available(name)?;
218 self.enforce_vector_dimension_limit(dimension)?;
219 let path = self.data_dir.join(name);
220 let coll = VectorCollection::create_with_params(
221 path,
222 dimension,
223 metric,
224 storage_mode,
225 hnsw_params,
226 pq_rescore_oversampling,
227 )?;
228 self.register_vector_collection(name, &coll, dimension, metric, storage_mode);
229 Ok(())
230 }
231
232 /// Registers a vector collection in the typed registry,
233 /// notifies the observer, and bumps the schema version.
234 fn register_vector_collection(
235 &self,
236 name: &str,
237 coll: &VectorCollection,
238 dimension: usize,
239 metric: DistanceMetric,
240 storage_mode: StorageMode,
241 ) {
242 // Parity item E: thread the live LimitsConfig caps into the collection
243 // before it is shared, so direct Collection::upsert / search paths
244 // (used by every SDK/REST handler) enforce the configured limits.
245 self.push_runtime_limits(&coll.inner);
246
247 self.vector_colls
248 .write()
249 .insert(name.to_string(), coll.clone());
250
251 if let Some(ref obs) = self.observer {
252 let kind = CollectionType::Vector {
253 dimension,
254 metric,
255 storage_mode,
256 };
257 obs.on_collection_created(name, &kind);
258 }
259
260 self.schema_version
261 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
262 }
263
264 /// Returns a `VectorCollection` by name.
265 ///
266 /// Checks the typed registry first. If not found there, falls back to
267 /// opening the collection directory from disk (e.g. for collections created
268 /// via the legacy `create_collection` API that were not registered in the
269 /// typed registry). The opened instance is cached back into the registry
270 /// so subsequent calls avoid the disk round-trip.
271 ///
272 /// Returns `None` if the collection does not exist on disk.
273 #[must_use]
274 pub fn get_vector_collection(&self, name: &str) -> Option<VectorCollection> {
275 // Bound before the `if let`: the guard would otherwise stay alive for
276 // the whole expression, and the disk fallback below takes `vector_colls`
277 // for WRITE — one refactor away from a self-deadlock on a non-reentrant
278 // `parking_lot` lock.
279 let cached = self.vector_colls.read().get(name).cloned();
280 if let Some(c) = cached {
281 return Some(c);
282 }
283 self.open_vector_collection_from_disk(name)
284 }
285
286 /// Disk fallback for `get_vector_collection`.
287 fn open_vector_collection_from_disk(&self, name: &str) -> Option<VectorCollection> {
288 let cfg = self.read_collection_config(name)?;
289 if cfg.graph_schema.is_some() || cfg.metadata_only {
290 return None;
291 }
292 let coll = VectorCollection::open(self.data_dir.join(name)).ok()?;
293 // Parity item E: re-push runtime limits on disk-open (not persisted).
294 self.push_runtime_limits(&coll.inner);
295 self.vector_colls
296 .write()
297 .insert(name.to_string(), coll.clone());
298 Some(coll)
299 }
300}