Skip to main content

qdrant_edge/edge/config/
shard.rs

1//! Edge shard configuration: user-facing params and conversion to/from SegmentConfig.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use crate::common::fs::{atomic_save_json, read_json};
7use crate::segment::common::operation_error::{OperationError, OperationResult};
8use crate::segment::types::{
9    Distance, HnswConfig, PayloadStorageType, QuantizationConfig, SegmentConfig, VectorName,
10    VectorNameBuf,
11};
12use serde::{Deserialize, Serialize};
13use crate::shard::operations::optimization::OptimizerThresholds;
14use crate::wal::WalOptions;
15
16use super::optimizers::EdgeOptimizersConfig;
17use super::vectors::{EdgeSparseVectorParams, EdgeVectorParams};
18
19/// File name for the persisted edge shard config.
20pub(crate) const EDGE_CONFIG_FILE: &str = "edge_config.json";
21
22/// Full configuration for an edge shard.
23///
24/// `vectors` and `sparse_vectors` define the stored data: when loading an existing shard they are
25/// validated for compatibility against the segments if provided (non-empty), or taken from the
26/// persisted config / the segments themselves if not.
27///
28/// Everything else is tunable and `None` means "not specified": when loading an existing shard
29/// each parameter resolves through provided → persisted → derived from segments → default (see
30/// [`EdgeConfig::fill_unspecified_from`]), so leaving a parameter unspecified keeps the shard as
31/// it is, while a `Some` value explicitly overwrites it and existing segments converge to it
32/// through the optimizers.
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub struct EdgeConfig {
36    /// If true, payload is stored on disk (mmap); otherwise in RAM. Same as `CollectionParams::on_disk_payload`.
37    /// `None` defaults to on-disk, see [`EdgeConfig::on_disk_payload`].
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub on_disk_payload: Option<bool>,
40    /// Dense vector params per vector name.
41    #[serde(default)]
42    pub vectors: HashMap<VectorNameBuf, EdgeVectorParams>,
43    /// Sparse vector params per vector name.
44    #[serde(default)]
45    pub sparse_vectors: HashMap<VectorNameBuf, EdgeSparseVectorParams>,
46    /// Global HNSW config; per-vector override is in `vectors[].hnsw_config`.
47    /// `None` defaults to [`HnswConfig::default`], see [`EdgeConfig::hnsw_config`].
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub hnsw_config: Option<HnswConfig>,
50    /// Global quantization config for all vectors
51    /// Per-vector override in in `vectors[].quantization_config`
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub quantization_config: Option<QuantizationConfig>,
54    /// `None` defaults to [`EdgeOptimizersConfig::default`], see [`EdgeConfig::optimizers`].
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub optimizers: Option<EdgeOptimizersConfig>,
57    /// WAL options for the shard. `None` keeps the WAL crate's defaults
58    /// (32 MiB segment capacity). Override for embedded/mobile deployments
59    /// where the default segment size is too large.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub wal_options: Option<WalOptions>,
62    /// Number of threads in the shard's search thread pool. The pool executes per-segment reads
63    /// (search, scroll, count, facet, ...) in parallel and loads segments in parallel. `None` (the
64    /// default) derives the count from the number of CPUs, matching the core search runtime — see
65    /// [`EdgeConfig::search_thread_count`].
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub max_search_threads: Option<usize>,
68    /// Pin every thread of this shard's search pool to the given CPU core: bounds the shard's
69    /// search compute to one core while keeping the pool's IO overlap. Best-effort. `None` (the
70    /// default) leaves thread placement to the OS scheduler.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub search_pool_core: Option<usize>,
73}
74
75impl EdgeConfig {
76    /// Start building an [`EdgeConfig`] with a fluent API.
77    pub fn builder() -> crate::edge::builders::EdgeConfigBuilder {
78        crate::edge::builders::EdgeConfigBuilder::new()
79    }
80
81    /// Effective payload storage location: on-disk unless explicitly set to `false`.
82    pub fn on_disk_payload(&self) -> bool {
83        self.on_disk_payload.unwrap_or(true)
84    }
85
86    /// Effective global HNSW config: [`HnswConfig::default`] unless explicitly set.
87    pub fn hnsw_config(&self) -> HnswConfig {
88        self.hnsw_config.unwrap_or_default()
89    }
90
91    /// Effective optimizers config: [`EdgeOptimizersConfig::default`] unless explicitly set.
92    pub fn optimizers(&self) -> EdgeOptimizersConfig {
93        self.optimizers.clone().unwrap_or_default()
94    }
95
96    /// Fill parameters left unspecified from `base`, keeping explicitly provided values.
97    ///
98    /// Chained over the fallback layers of [`EdgeShard::load`](crate::EdgeShard::load):
99    /// provided → persisted → derived from segments → default.
100    ///
101    /// For tunables, unspecified means `None`. For `vectors` and `sparse_vectors` it means an
102    /// empty map: a non-empty map is taken as-is (never merged element-wise) — those define the
103    /// stored data, so the load path validates them against existing segments instead of
104    /// converging via the optimizers like the tunables do.
105    pub fn fill_unspecified_from(self, base: &EdgeConfig) -> Self {
106        let Self {
107            on_disk_payload,
108            vectors,
109            sparse_vectors,
110            hnsw_config,
111            quantization_config,
112            optimizers,
113            wal_options,
114            max_search_threads,
115            search_pool_core,
116        } = self;
117        Self {
118            on_disk_payload: on_disk_payload.or(base.on_disk_payload),
119            vectors: if vectors.is_empty() {
120                base.vectors.clone()
121            } else {
122                vectors
123            },
124            sparse_vectors: if sparse_vectors.is_empty() {
125                base.sparse_vectors.clone()
126            } else {
127                sparse_vectors
128            },
129            hnsw_config: hnsw_config.or(base.hnsw_config),
130            quantization_config: quantization_config.or_else(|| base.quantization_config.clone()),
131            optimizers: optimizers.or_else(|| base.optimizers.clone()),
132            wal_options: wal_options.or_else(|| base.wal_options.clone()),
133            max_search_threads: max_search_threads.or(base.max_search_threads),
134            search_pool_core: search_pool_core.or(base.search_pool_core),
135        }
136    }
137
138    /// Accumulate the config derived from one more segment into `acc`.
139    ///
140    /// Building block for the "derived from segments" layer of the config fallback chain: fold
141    /// this over *all* segments, so that a segment carrying no information about a parameter
142    /// (e.g. a plain appendable segment says nothing about HNSW) never masks one that does (an
143    /// indexed segment carries the actual build parameters). Fold in a deterministic segment
144    /// order: when segments disagree on a parameter, the first one providing it wins.
145    pub(crate) fn fold_from_segment_config(acc: Option<Self>, segment: &SegmentConfig) -> Self {
146        let derived = Self::from_segment_config(segment);
147        match acc {
148            Some(acc) => acc.fill_unspecified_from(&derived),
149            None => derived,
150        }
151    }
152
153    /// Build from existing segment config. Fills all parameters that can be inferred.
154    pub fn from_segment_config(segment: &SegmentConfig) -> Self {
155        let SegmentConfig {
156            vector_data,
157            sparse_vector_data,
158            payload_storage_type,
159        } = segment;
160
161        let vectors = vector_data
162            .iter()
163            .map(|(name, v)| (name.clone(), EdgeVectorParams::from_vector_data_config(v)))
164            .collect();
165
166        let sparse_vectors = sparse_vector_data
167            .iter()
168            .map(|(name, s)| {
169                (
170                    name.clone(),
171                    EdgeSparseVectorParams::from_sparse_vector_data_config(s),
172                )
173            })
174            .collect();
175
176        let on_disk_payload = payload_storage_type.is_on_disk();
177
178        // Infer global hnsw_config from per-vector HNSW configs when all agree
179        let hnsw_configs: Vec<HnswConfig> = vector_data
180            .values()
181            .filter_map(|v| match &v.index {
182                crate::segment::types::Indexes::Plain {} => None,
183                crate::segment::types::Indexes::Hnsw(h) => Some(*h),
184            })
185            .collect();
186        let hnsw_config = hnsw_configs.first().and_then(|first| {
187            if hnsw_configs.iter().all(|h| h == first) {
188                Some(*first)
189            } else {
190                None
191            }
192        });
193
194        Self {
195            on_disk_payload: Some(on_disk_payload),
196            vectors,
197            sparse_vectors,
198            hnsw_config,
199            quantization_config: None,
200            optimizers: None,
201            wal_options: None,
202            max_search_threads: None,
203            search_pool_core: None,
204        }
205    }
206
207    /// Resolve the configured [`max_search_threads`](Self::max_search_threads) into a concrete
208    /// thread count. `None` derives the count from the number of CPUs, matching the core search
209    /// runtime (`common::defaults::search_thread_count`).
210    pub fn search_thread_count(&self) -> usize {
211        crate::common::defaults::search_thread_count(self.max_search_threads.unwrap_or(0))
212    }
213
214    /// Check compatibility with a segment config (e.g. loaded segment).
215    pub fn check_compatible_with_segment_config(
216        &self,
217        other: &SegmentConfig,
218    ) -> Result<(), String> {
219        self.plain_segment_config().check_compatible(other)
220    }
221
222    /// Segment config for creating appendable segments only.
223    /// Does not contain any HNSW configuration (plain index only).
224    pub fn plain_segment_config(&self) -> SegmentConfig {
225        let payload_storage_type = PayloadStorageType::from_on_disk_payload(self.on_disk_payload());
226        let vector_data = self
227            .vectors
228            .iter()
229            .map(|(name, p)| {
230                (
231                    name.clone(),
232                    p.to_plain_vector_data_config(self.quantization_config.as_ref()),
233                )
234            })
235            .collect();
236
237        let sparse_vector_data = self
238            .sparse_vectors
239            .iter()
240            .map(|(name, p)| (name.clone(), p.to_plain_sparse_vector_data_config()))
241            .collect();
242
243        SegmentConfig {
244            vector_data,
245            sparse_vector_data,
246            payload_storage_type,
247        }
248    }
249
250    /// All vector names (dense and sparse) currently present in this config.
251    ///
252    /// Must cover both kinds: a segment's `vector_data` holds dense and sparse vectors together,
253    /// so the optimizer merge consults this set for both.
254    pub fn vector_names(&self) -> HashSet<VectorNameBuf> {
255        self.vectors
256            .keys()
257            .chain(self.sparse_vectors.keys())
258            .cloned()
259            .collect()
260    }
261
262    /// Build segment optimizer config from this config (for blocking optimizers).
263    /// Use this instead of converting to SegmentConfig first.
264    pub fn segment_optimizer_config(&self) -> crate::shard::optimizers::config::SegmentOptimizerConfig {
265        use crate::shard::optimizers::config::SegmentOptimizerConfig;
266
267        let SegmentConfig {
268            vector_data: plain_dense_vector_config,
269            sparse_vector_data: plain_sparse_vector_config,
270            payload_storage_type,
271        } = self.plain_segment_config();
272
273        let hnsw_config = self.hnsw_config();
274        let dense_vector = self
275            .vectors
276            .iter()
277            .map(|(name, p)| {
278                (
279                    name.clone(),
280                    p.to_dense_vector_optimizer_config(
281                        &hnsw_config,
282                        self.quantization_config.as_ref(),
283                    ),
284                )
285            })
286            .collect();
287
288        let sparse_vector = self
289            .sparse_vectors
290            .iter()
291            .map(|(name, p)| (name.clone(), p.to_sparse_vector_optimizer_config()))
292            .collect();
293
294        SegmentOptimizerConfig {
295            payload_storage_type,
296            plain_dense_vector_config,
297            plain_sparse_vector_config,
298            dense_vector,
299            sparse_vector,
300            live_vector_names: None,
301        }
302    }
303
304    /// Return vector data config for a named vector (for read-only use, e.g. query).
305    /// Uses plain index; for optimizer/segment creation use segment_optimizer_config or plain_segment_config.
306    pub fn vector_data_config(
307        &self,
308        name: &VectorNameBuf,
309    ) -> Option<crate::segment::types::VectorDataConfig> {
310        self.vectors
311            .get(name)
312            .map(|p| p.to_plain_vector_data_config(self.quantization_config.as_ref()))
313    }
314
315    /// Distance of a named vector, mirroring `CollectionParams::get_distance`:
316    /// sparse vectors always score with `Dot`.
317    pub fn get_distance(&self, vector_name: &VectorName) -> OperationResult<Distance> {
318        if let Some(params) = self.vectors.get(vector_name) {
319            Ok(params.distance)
320        } else if self.sparse_vectors.contains_key(vector_name) {
321            Ok(Distance::Dot)
322        } else {
323            Err(OperationError::vector_name_not_exists(vector_name))
324        }
325    }
326
327    pub fn optimizer_thresholds(&self, num_indexing_threads: usize) -> OptimizerThresholds {
328        let optimizers = self.optimizers();
329        OptimizerThresholds {
330            memmap_threshold_kb: usize::MAX,
331            indexing_threshold_kb: optimizers.get_indexing_threshold_kb(),
332            max_segment_size_kb: optimizers.get_max_segment_size_kb(num_indexing_threads),
333            deferred_internal_id: None,
334        }
335    }
336
337    pub fn save(&self, path: &Path) -> OperationResult<()> {
338        let config_path = path.join(EDGE_CONFIG_FILE);
339        atomic_save_json(&config_path, self).map_err(|e| {
340            OperationError::service_error(format!(
341                "failed to write {}: {}",
342                config_path.display(),
343                e
344            ))
345        })
346    }
347
348    pub fn load(path: &Path) -> Option<OperationResult<Self>> {
349        let config_path = path.join(EDGE_CONFIG_FILE);
350        match fs_err::exists(&config_path) {
351            Ok(false) => return None,
352            Err(e) => return Some(Err(OperationError::from(e))),
353            Ok(true) => {}
354        }
355        Some(read_json(&config_path).map_err(OperationError::from))
356    }
357
358    pub fn set_hnsw_config(&mut self, hnsw_config: HnswConfig) {
359        self.hnsw_config = Some(hnsw_config);
360    }
361
362    pub fn set_vector_hnsw_config(
363        &mut self,
364        vector_name: &str,
365        hnsw_config: HnswConfig,
366    ) -> OperationResult<()> {
367        let name = VectorNameBuf::from(vector_name);
368        let params = self
369            .vectors
370            .get_mut(&name)
371            .ok_or_else(|| OperationError::vector_name_not_exists(vector_name))?;
372        params.hnsw_config = Some(hnsw_config);
373        Ok(())
374    }
375
376    pub fn set_optimizers_config(&mut self, optimizers: EdgeOptimizersConfig) {
377        self.optimizers = Some(optimizers);
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use crate::segment::types::{Distance, Indexes, VectorDataConfig, VectorStorageType};
384
385    use super::*;
386
387    fn segment_config(index: Indexes) -> SegmentConfig {
388        SegmentConfig {
389            vector_data: HashMap::from([(
390                "vec".to_string(),
391                VectorDataConfig {
392                    size: 4,
393                    distance: Distance::Dot,
394                    storage_type: VectorStorageType::ChunkedMmap,
395                    index,
396                    quantization_config: None,
397                    multivector_config: None,
398                    datatype: None,
399                },
400            )]),
401            sparse_vector_data: HashMap::new(),
402            payload_storage_type: PayloadStorageType::from_on_disk_payload(true),
403        }
404    }
405
406    /// A plain (appendable) segment carries no HNSW parameters; folding must not let it mask an
407    /// indexed segment's actual build parameters, regardless of segment order.
408    #[test]
409    fn fold_derives_hnsw_from_indexed_segment_regardless_of_order() {
410        let hnsw = HnswConfig {
411            m: 32,
412            ..HnswConfig::default()
413        };
414        let plain = segment_config(Indexes::Plain {});
415        let indexed = segment_config(Indexes::Hnsw(hnsw));
416
417        for segments in [[&plain, &indexed], [&indexed, &plain]] {
418            let derived = segments
419                .into_iter()
420                .fold(None, |acc, segment| {
421                    Some(EdgeConfig::fold_from_segment_config(acc, segment))
422                })
423                .unwrap();
424            assert_eq!(derived.hnsw_config, Some(hnsw));
425            assert!(derived.vectors.contains_key("vec"));
426        }
427    }
428}