Skip to main content

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}