1use serde::{Deserialize, Serialize};
8
9use crate::dtype::Dtype;
10use crate::error::{Error, Result};
11use crate::metric::Metric;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
18pub enum IndexBackend {
19 #[default]
22 Hnsw,
23 Ivf,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
30pub enum IoProfile {
31 Hdd,
34 Ssd,
36 #[default]
39 Auto,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
44pub struct StorageParams {
45 pub dtype: Dtype,
47 pub block_size: usize,
50 pub io_profile: IoProfile,
52}
53
54impl Default for StorageParams {
55 fn default() -> Self {
56 Self {
57 dtype: Dtype::F32,
58 block_size: 64 * 1024,
59 io_profile: IoProfile::Auto,
60 }
61 }
62}
63
64impl StorageParams {
65 pub fn validate(&self) -> Result<()> {
67 if self.block_size < 512 {
68 return Err(Error::invalid_config(format!(
69 "block_size must be >= 512 bytes, got {}",
70 self.block_size
71 )));
72 }
73 if !self.block_size.is_power_of_two() {
74 return Err(Error::invalid_config(format!(
75 "block_size must be a power of two, got {}",
76 self.block_size
77 )));
78 }
79 Ok(())
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PqParams {
86 pub num_subquantizers: usize,
89 pub bits_per_code: u8,
91}
92
93impl Default for PqParams {
94 fn default() -> Self {
95 Self {
96 num_subquantizers: 16,
97 bits_per_code: 8,
98 }
99 }
100}
101
102impl PqParams {
103 #[inline]
105 pub const fn centroids_per_subspace(&self) -> usize {
106 1usize << self.bits_per_code
107 }
108
109 pub fn validate(&self) -> Result<()> {
112 if self.num_subquantizers == 0 {
113 return Err(Error::invalid_config("num_subquantizers must be >= 1"));
114 }
115 if self.bits_per_code == 0 || self.bits_per_code > 8 {
116 return Err(Error::invalid_config(format!(
117 "bits_per_code must be in 1..=8, got {}",
118 self.bits_per_code
119 )));
120 }
121 Ok(())
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
133pub struct HnswParams {
134 pub m: usize,
136 pub m_max: usize,
138 pub ef_construction: usize,
140 pub hub_fraction: f32,
142}
143
144impl Default for HnswParams {
145 fn default() -> Self {
146 Self {
147 m: 8,
148 m_max: 32,
149 ef_construction: 200,
150 hub_fraction: 0.02,
151 }
152 }
153}
154
155impl HnswParams {
156 pub fn validate(&self) -> Result<()> {
158 if self.m == 0 {
159 return Err(Error::invalid_config("hnsw.m must be >= 1"));
160 }
161 if self.m_max < self.m {
162 return Err(Error::invalid_config(format!(
163 "hnsw.m_max ({}) must be >= hnsw.m ({})",
164 self.m_max, self.m
165 )));
166 }
167 if self.ef_construction == 0 {
168 return Err(Error::invalid_config("hnsw.ef_construction must be >= 1"));
169 }
170 if !(0.0..=1.0).contains(&self.hub_fraction) {
171 return Err(Error::invalid_config(format!(
172 "hnsw.hub_fraction must be in [0, 1], got {}",
173 self.hub_fraction
174 )));
175 }
176 Ok(())
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
182pub struct IvfParams {
183 pub num_lists: usize,
185 pub num_probes: usize,
187 pub soft_assign: usize,
190 pub max_kmeans_iters: usize,
192}
193
194impl Default for IvfParams {
195 fn default() -> Self {
196 Self {
197 num_lists: 256,
198 num_probes: 16,
199 soft_assign: 2,
200 max_kmeans_iters: 25,
201 }
202 }
203}
204
205impl IvfParams {
206 pub fn validate(&self) -> Result<()> {
208 if self.num_lists == 0 {
209 return Err(Error::invalid_config("ivf.num_lists must be >= 1"));
210 }
211 if self.num_probes == 0 || self.num_probes > self.num_lists {
212 return Err(Error::invalid_config(format!(
213 "ivf.num_probes must be in 1..={}, got {}",
214 self.num_lists, self.num_probes
215 )));
216 }
217 if self.soft_assign == 0 || self.soft_assign > self.num_lists {
218 return Err(Error::invalid_config(format!(
219 "ivf.soft_assign must be in 1..={}, got {}",
220 self.num_lists, self.soft_assign
221 )));
222 }
223 if self.max_kmeans_iters == 0 {
224 return Err(Error::invalid_config("ivf.max_kmeans_iters must be >= 1"));
225 }
226 Ok(())
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct BuildConfig {
233 pub dimensions: usize,
236 pub metric: Metric,
238 pub backend: IndexBackend,
240 pub storage: StorageParams,
242 pub pq: PqParams,
244 pub hnsw: HnswParams,
246 pub ivf: IvfParams,
248 pub num_shards: usize,
251}
252
253impl Default for BuildConfig {
254 fn default() -> Self {
255 Self {
256 dimensions: 0,
257 metric: Metric::L2,
258 backend: IndexBackend::Hnsw,
259 storage: StorageParams::default(),
260 pq: PqParams::default(),
261 hnsw: HnswParams::default(),
262 ivf: IvfParams::default(),
263 num_shards: 1,
264 }
265 }
266}
267
268impl BuildConfig {
269 pub fn new(dimensions: usize, metric: Metric, backend: IndexBackend) -> Self {
272 Self {
273 dimensions,
274 metric,
275 backend,
276 ..Self::default()
277 }
278 }
279
280 pub fn validate(&self) -> Result<()> {
282 if self.dimensions == 0 {
283 return Err(Error::invalid_config("dimensions must be set (> 0)"));
284 }
285 self.storage.validate()?;
286 self.pq.validate()?;
287 self.hnsw.validate()?;
288 self.ivf.validate()?;
289 if !self.dimensions.is_multiple_of(self.pq.num_subquantizers) {
290 return Err(Error::invalid_config(format!(
291 "dimensions ({}) must be divisible by pq.num_subquantizers ({})",
292 self.dimensions, self.pq.num_subquantizers
293 )));
294 }
295 if self.num_shards == 0 {
296 return Err(Error::invalid_config("num_shards must be >= 1"));
297 }
298 Ok(())
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304pub struct SearchConfig {
305 pub k: usize,
307 pub ef_search: usize,
309 pub rerank_ratio: f32,
312 pub fetch_batch_size: usize,
315}
316
317impl Default for SearchConfig {
318 fn default() -> Self {
319 Self {
320 k: 10,
321 ef_search: 64,
322 rerank_ratio: 0.2,
323 fetch_batch_size: 64,
324 }
325 }
326}
327
328impl SearchConfig {
329 pub fn validate(&self) -> Result<()> {
331 if self.k == 0 {
332 return Err(Error::invalid_config("k must be >= 1"));
333 }
334 if self.ef_search < self.k {
335 return Err(Error::invalid_config(format!(
336 "ef_search ({}) must be >= k ({})",
337 self.ef_search, self.k
338 )));
339 }
340 if !(self.rerank_ratio > 0.0 && self.rerank_ratio <= 1.0) {
341 return Err(Error::invalid_config(format!(
342 "rerank_ratio must be in (0, 1], got {}",
343 self.rerank_ratio
344 )));
345 }
346 if self.fetch_batch_size == 0 {
347 return Err(Error::invalid_config("fetch_batch_size must be >= 1"));
348 }
349 Ok(())
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 #![allow(clippy::field_reassign_with_default)]
359
360 use super::*;
361
362 #[test]
363 fn default_build_config_requires_dimensions() {
364 let cfg = BuildConfig::default();
365 assert!(cfg.validate().is_err(), "dimensions=0 must be rejected");
366 }
367
368 #[test]
369 fn valid_build_config_passes() {
370 let cfg = BuildConfig::new(768, Metric::Cosine, IndexBackend::Hnsw);
371 assert!(cfg.validate().is_ok(), "{:?}", cfg.validate());
372 }
373
374 #[test]
375 fn pq_divisibility_enforced() {
376 let mut cfg = BuildConfig::new(770, Metric::L2, IndexBackend::Hnsw);
377 cfg.pq.num_subquantizers = 16; assert!(cfg.validate().is_err());
379 }
380
381 #[test]
382 fn pq_centroid_count() {
383 assert_eq!(PqParams::default().centroids_per_subspace(), 256);
384 }
385
386 #[test]
387 fn storage_block_size_must_be_pow2() {
388 let mut sp = StorageParams::default();
389 sp.block_size = 1000;
390 assert!(sp.validate().is_err());
391 sp.block_size = 4096;
392 assert!(sp.validate().is_ok());
393 }
394
395 #[test]
396 fn hnsw_degree_ordering() {
397 let mut p = HnswParams::default();
398 p.m = 40;
399 p.m_max = 32;
400 assert!(p.validate().is_err());
401 }
402
403 #[test]
404 fn ivf_probe_bounds() {
405 let mut p = IvfParams::default();
406 p.num_probes = p.num_lists + 1;
407 assert!(p.validate().is_err());
408 }
409
410 #[test]
411 fn search_config_bounds() {
412 let mut s = SearchConfig::default();
413 assert!(s.validate().is_ok());
414 s.ef_search = 1;
415 s.k = 10;
416 assert!(s.validate().is_err());
417 s = SearchConfig::default();
418 s.rerank_ratio = 0.0;
419 assert!(s.validate().is_err());
420 }
421
422 #[test]
423 fn config_roundtrips_through_json() {
424 let cfg = BuildConfig::new(128, Metric::InnerProduct, IndexBackend::Ivf);
426 let json = serde_json::to_string(&cfg).expect("serialize");
427 let back: BuildConfig = serde_json::from_str(&json).expect("deserialize");
428 assert_eq!(cfg, back);
429 }
430}