Skip to main content

ipfrs_semantic/
index_optimizer.rs

1//! Embedding Index Optimizer
2//!
3//! Analyzes HNSW index structure and recommends parameter tuning
4//! (ef_construction, M, level distribution) to optimize search
5//! quality vs. speed trade-offs.
6
7/// Goal for HNSW index tuning.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum IndexTuningGoal {
10    /// Optimize for highest recall (larger M, ef_construction).
11    MaxRecall,
12    /// Optimize for fastest search (smaller parameters).
13    MaxSpeed,
14    /// Middle ground between recall and speed.
15    Balanced,
16}
17
18/// HNSW index parameters.
19#[derive(Clone, Debug, PartialEq)]
20pub struct HnswParams {
21    /// Max connections per node.
22    pub m: usize,
23    /// Search width during index build.
24    pub ef_construction: usize,
25    /// Search width during query.
26    pub ef_search: usize,
27}
28
29impl Default for HnswParams {
30    fn default() -> Self {
31        Self {
32            m: 16,
33            ef_construction: 200,
34            ef_search: 50,
35        }
36    }
37}
38
39impl HnswParams {
40    /// Returns `true` when the parameter combination is logically valid.
41    pub fn is_valid(&self) -> bool {
42        self.m >= 2 && self.ef_construction >= self.m && self.ef_search >= 1
43    }
44}
45
46/// Distribution of nodes across HNSW levels.
47///
48/// Index 0 corresponds to the base (densest) layer.
49#[derive(Clone, Debug, PartialEq)]
50pub struct LevelDistribution {
51    /// Node count per level; index 0 = base layer.
52    pub levels: Vec<usize>,
53}
54
55impl LevelDistribution {
56    /// Total number of nodes across all levels.
57    pub fn total_nodes(&self) -> usize {
58        self.levels.iter().sum()
59    }
60
61    /// Highest level index (0-based).
62    pub fn max_level(&self) -> usize {
63        self.levels.len().saturating_sub(1)
64    }
65
66    /// Loose structural sanity check: each upper layer should have no more
67    /// than roughly `2 * level[i] / m` nodes.
68    pub fn is_well_formed(&self, m: usize) -> bool {
69        let divisor = m.max(1);
70        for i in 0..self.levels.len().saturating_sub(1) {
71            let upper_bound = self.levels[i] / divisor * 2;
72            if self.levels[i + 1] > upper_bound {
73                return false;
74            }
75        }
76        true
77    }
78}
79
80/// Report produced by [`EmbeddingIndexOptimizer::recommend_params`].
81#[derive(Clone, Debug)]
82pub struct OptimizationReport {
83    /// Parameters currently in use.
84    pub current_params: HnswParams,
85    /// Recommended parameters after analysis.
86    pub recommended_params: HnswParams,
87    /// Tuning goal driving the recommendation.
88    pub goal: IndexTuningGoal,
89    /// Estimated recall delta (positive = better recall).
90    pub expected_recall_change: f64,
91    /// Estimated latency delta (negative = faster).
92    pub expected_speed_change: f64,
93    /// Human-readable observations and warnings.
94    pub notes: Vec<String>,
95}
96
97/// Analyzes an HNSW index and recommends parameter adjustments.
98pub struct EmbeddingIndexOptimizer;
99
100impl EmbeddingIndexOptimizer {
101    /// Creates a new optimizer instance.
102    pub fn new() -> Self {
103        Self
104    }
105
106    /// Recommends HNSW parameters given the current configuration, the
107    /// desired tuning goal, and the approximate number of indexed nodes.
108    pub fn recommend_params(
109        &self,
110        current: HnswParams,
111        goal: IndexTuningGoal,
112        node_count: usize,
113    ) -> OptimizationReport {
114        let mut notes = Vec::new();
115
116        if node_count > 100_000 {
117            notes.push(format!(
118                "Large index detected ({node_count} nodes): consider monitoring memory usage \
119                 and build time when increasing M or ef_construction."
120            ));
121        }
122
123        let (recommended_params, expected_recall_change, expected_speed_change) = match goal {
124            IndexTuningGoal::MaxRecall => {
125                let new_m = (current.m * 2).min(64);
126                let new_ef_construction = (current.ef_construction * 2).min(800);
127                let new_ef_search = (current.ef_search * 2).min(500);
128                (
129                    HnswParams {
130                        m: new_m,
131                        ef_construction: new_ef_construction,
132                        ef_search: new_ef_search,
133                    },
134                    0.05_f64,
135                    -0.30_f64,
136                )
137            }
138            IndexTuningGoal::MaxSpeed => {
139                let new_m = (current.m / 2).max(4);
140                let new_ef_construction = (current.ef_construction / 2).max(current.m);
141                let new_ef_search = (current.ef_search / 2).max(1);
142                (
143                    HnswParams {
144                        m: new_m,
145                        ef_construction: new_ef_construction,
146                        ef_search: new_ef_search,
147                    },
148                    -0.08_f64,
149                    0.50_f64,
150                )
151            }
152            IndexTuningGoal::Balanced => {
153                let new_m = ((current.m + 16) / 2).clamp(8, 32);
154                (
155                    HnswParams {
156                        m: new_m,
157                        ef_construction: 200,
158                        ef_search: 50,
159                    },
160                    0.0_f64,
161                    0.0_f64,
162                )
163            }
164        };
165
166        OptimizationReport {
167            current_params: current,
168            recommended_params,
169            goal,
170            expected_recall_change,
171            expected_speed_change,
172            notes,
173        }
174    }
175
176    /// Returns a list of textual observations about the level distribution.
177    pub fn analyze_levels(&self, dist: &LevelDistribution, params: &HnswParams) -> Vec<String> {
178        let mut observations = Vec::new();
179
180        observations.push(format!(
181            "Total nodes across all levels: {}",
182            dist.total_nodes()
183        ));
184        observations.push(format!("Maximum level index: {}", dist.max_level()));
185
186        if dist.is_well_formed(params.m) {
187            observations.push(
188                "Level distribution is well-formed (each upper layer is within expected bounds)."
189                    .to_string(),
190            );
191        } else {
192            observations.push(
193                "Level distribution is NOT well-formed: some upper layers exceed expected node \
194                 counts. Consider rebuilding the index."
195                    .to_string(),
196            );
197        }
198
199        observations
200    }
201
202    /// Rough estimate of the memory footprint in megabytes.
203    ///
204    /// Calculation: `node_count × m × 8 bytes` per connection.
205    pub fn estimate_memory_mb(&self, node_count: usize, params: &HnswParams) -> f64 {
206        let bytes = node_count * params.m * 8;
207        bytes as f64 / (1024.0 * 1024.0)
208    }
209}
210
211impl Default for EmbeddingIndexOptimizer {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    // ------------------------------------------------------------------
222    // HnswParams::is_valid
223    // ------------------------------------------------------------------
224
225    #[test]
226    fn test_is_valid_default_params() {
227        let p = HnswParams::default();
228        assert!(p.is_valid());
229    }
230
231    #[test]
232    fn test_is_valid_m_less_than_2() {
233        let p = HnswParams {
234            m: 1,
235            ef_construction: 10,
236            ef_search: 1,
237        };
238        assert!(!p.is_valid());
239    }
240
241    #[test]
242    fn test_is_valid_ef_construction_less_than_m() {
243        let p = HnswParams {
244            m: 16,
245            ef_construction: 8,
246            ef_search: 1,
247        };
248        assert!(!p.is_valid());
249    }
250
251    #[test]
252    fn test_is_valid_ef_search_zero() {
253        let p = HnswParams {
254            m: 16,
255            ef_construction: 200,
256            ef_search: 0,
257        };
258        assert!(!p.is_valid());
259    }
260
261    #[test]
262    fn test_is_valid_minimal_valid() {
263        let p = HnswParams {
264            m: 2,
265            ef_construction: 2,
266            ef_search: 1,
267        };
268        assert!(p.is_valid());
269    }
270
271    // ------------------------------------------------------------------
272    // LevelDistribution
273    // ------------------------------------------------------------------
274
275    #[test]
276    fn test_level_distribution_total_nodes() {
277        let dist = LevelDistribution {
278            levels: vec![1000, 100, 10, 1],
279        };
280        assert_eq!(dist.total_nodes(), 1111);
281    }
282
283    #[test]
284    fn test_level_distribution_max_level() {
285        let dist = LevelDistribution {
286            levels: vec![1000, 100, 10],
287        };
288        assert_eq!(dist.max_level(), 2);
289    }
290
291    #[test]
292    fn test_level_distribution_max_level_single() {
293        let dist = LevelDistribution { levels: vec![500] };
294        assert_eq!(dist.max_level(), 0);
295    }
296
297    #[test]
298    fn test_level_distribution_max_level_empty() {
299        let dist = LevelDistribution { levels: vec![] };
300        assert_eq!(dist.max_level(), 0);
301    }
302
303    #[test]
304    fn test_is_well_formed_true() {
305        // With m=16, each upper layer should be ≤ lower / 16 * 2.
306        // 1000 / 16 * 2 = 125, so 100 is fine.
307        // 100 / 16 * 2 = 12, so 10 is fine.
308        let dist = LevelDistribution {
309            levels: vec![1000, 100, 10],
310        };
311        assert!(dist.is_well_formed(16));
312    }
313
314    #[test]
315    fn test_is_well_formed_false() {
316        // level[1] = 900 > 1000 / 16 * 2 = 125
317        let dist = LevelDistribution {
318            levels: vec![1000, 900, 10],
319        };
320        assert!(!dist.is_well_formed(16));
321    }
322
323    // ------------------------------------------------------------------
324    // MaxRecall goal
325    // ------------------------------------------------------------------
326
327    #[test]
328    fn test_max_recall_doubles_m() {
329        let opt = EmbeddingIndexOptimizer::new();
330        let current = HnswParams {
331            m: 16,
332            ef_construction: 200,
333            ef_search: 50,
334        };
335        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 1000);
336        assert_eq!(report.recommended_params.m, 32);
337    }
338
339    #[test]
340    fn test_max_recall_doubles_ef_construction() {
341        let opt = EmbeddingIndexOptimizer::new();
342        let current = HnswParams {
343            m: 16,
344            ef_construction: 200,
345            ef_search: 50,
346        };
347        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 1000);
348        assert_eq!(report.recommended_params.ef_construction, 400);
349    }
350
351    #[test]
352    fn test_max_recall_doubles_ef_search() {
353        let opt = EmbeddingIndexOptimizer::new();
354        let current = HnswParams {
355            m: 16,
356            ef_construction: 200,
357            ef_search: 50,
358        };
359        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 1000);
360        assert_eq!(report.recommended_params.ef_search, 100);
361    }
362
363    #[test]
364    fn test_max_recall_caps_m_at_64() {
365        let opt = EmbeddingIndexOptimizer::new();
366        let current = HnswParams {
367            m: 48,
368            ef_construction: 400,
369            ef_search: 50,
370        };
371        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 1000);
372        assert_eq!(report.recommended_params.m, 64);
373    }
374
375    #[test]
376    fn test_max_recall_positive_recall_change() {
377        let opt = EmbeddingIndexOptimizer::new();
378        let current = HnswParams::default();
379        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 500);
380        assert!(report.expected_recall_change > 0.0);
381    }
382
383    #[test]
384    fn test_max_recall_negative_speed_change() {
385        let opt = EmbeddingIndexOptimizer::new();
386        let current = HnswParams::default();
387        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 500);
388        assert!(report.expected_speed_change < 0.0);
389    }
390
391    // ------------------------------------------------------------------
392    // MaxSpeed goal
393    // ------------------------------------------------------------------
394
395    #[test]
396    fn test_max_speed_halves_m() {
397        let opt = EmbeddingIndexOptimizer::new();
398        let current = HnswParams {
399            m: 16,
400            ef_construction: 200,
401            ef_search: 50,
402        };
403        let report = opt.recommend_params(current, IndexTuningGoal::MaxSpeed, 1000);
404        assert_eq!(report.recommended_params.m, 8);
405    }
406
407    #[test]
408    fn test_max_speed_halves_ef_search() {
409        let opt = EmbeddingIndexOptimizer::new();
410        let current = HnswParams {
411            m: 16,
412            ef_construction: 200,
413            ef_search: 50,
414        };
415        let report = opt.recommend_params(current, IndexTuningGoal::MaxSpeed, 1000);
416        assert_eq!(report.recommended_params.ef_search, 25);
417    }
418
419    #[test]
420    fn test_max_speed_negative_recall_change() {
421        let opt = EmbeddingIndexOptimizer::new();
422        let current = HnswParams::default();
423        let report = opt.recommend_params(current, IndexTuningGoal::MaxSpeed, 500);
424        assert!(report.expected_recall_change < 0.0);
425    }
426
427    #[test]
428    fn test_max_speed_positive_speed_change() {
429        let opt = EmbeddingIndexOptimizer::new();
430        let current = HnswParams::default();
431        let report = opt.recommend_params(current, IndexTuningGoal::MaxSpeed, 500);
432        assert!(report.expected_speed_change > 0.0);
433    }
434
435    // ------------------------------------------------------------------
436    // Balanced goal
437    // ------------------------------------------------------------------
438
439    #[test]
440    fn test_balanced_clamps_m_within_range() {
441        let opt = EmbeddingIndexOptimizer::new();
442        // current.m = 16 => (16+16)/2 = 16, clamped to [8,32] => 16
443        let current = HnswParams {
444            m: 16,
445            ef_construction: 200,
446            ef_search: 50,
447        };
448        let report = opt.recommend_params(current, IndexTuningGoal::Balanced, 1000);
449        assert!(report.recommended_params.m >= 8 && report.recommended_params.m <= 32);
450    }
451
452    #[test]
453    fn test_balanced_zero_recall_and_speed_change() {
454        let opt = EmbeddingIndexOptimizer::new();
455        let current = HnswParams::default();
456        let report = opt.recommend_params(current, IndexTuningGoal::Balanced, 1000);
457        assert_eq!(report.expected_recall_change, 0.0);
458        assert_eq!(report.expected_speed_change, 0.0);
459    }
460
461    #[test]
462    fn test_balanced_fixed_ef_values() {
463        let opt = EmbeddingIndexOptimizer::new();
464        let current = HnswParams::default();
465        let report = opt.recommend_params(current, IndexTuningGoal::Balanced, 1000);
466        assert_eq!(report.recommended_params.ef_construction, 200);
467        assert_eq!(report.recommended_params.ef_search, 50);
468    }
469
470    // ------------------------------------------------------------------
471    // Large index note
472    // ------------------------------------------------------------------
473
474    #[test]
475    fn test_large_index_adds_note() {
476        let opt = EmbeddingIndexOptimizer::new();
477        let current = HnswParams::default();
478        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 200_000);
479        assert!(!report.notes.is_empty());
480    }
481
482    #[test]
483    fn test_small_index_no_note() {
484        let opt = EmbeddingIndexOptimizer::new();
485        let current = HnswParams::default();
486        let report = opt.recommend_params(current, IndexTuningGoal::MaxRecall, 50_000);
487        assert!(report.notes.is_empty());
488    }
489
490    // ------------------------------------------------------------------
491    // analyze_levels
492    // ------------------------------------------------------------------
493
494    #[test]
495    fn test_analyze_levels_returns_strings() {
496        let opt = EmbeddingIndexOptimizer::new();
497        let dist = LevelDistribution {
498            levels: vec![1000, 100, 10],
499        };
500        let params = HnswParams::default();
501        let obs = opt.analyze_levels(&dist, &params);
502        assert!(!obs.is_empty());
503    }
504
505    #[test]
506    fn test_analyze_levels_contains_total_nodes() {
507        let opt = EmbeddingIndexOptimizer::new();
508        let dist = LevelDistribution {
509            levels: vec![500, 50, 5],
510        };
511        let params = HnswParams::default();
512        let obs = opt.analyze_levels(&dist, &params);
513        assert!(obs.iter().any(|s| s.contains("555")));
514    }
515
516    // ------------------------------------------------------------------
517    // estimate_memory_mb
518    // ------------------------------------------------------------------
519
520    #[test]
521    fn test_estimate_memory_mb_positive() {
522        let opt = EmbeddingIndexOptimizer::new();
523        let params = HnswParams::default();
524        let mb = opt.estimate_memory_mb(10_000, &params);
525        assert!(mb > 0.0);
526    }
527
528    #[test]
529    fn test_estimate_memory_mb_scales_with_node_count() {
530        let opt = EmbeddingIndexOptimizer::new();
531        let params = HnswParams::default();
532        let mb_small = opt.estimate_memory_mb(1_000, &params);
533        let mb_large = opt.estimate_memory_mb(10_000, &params);
534        assert!(mb_large > mb_small);
535    }
536
537    #[test]
538    fn test_estimate_memory_mb_zero_nodes() {
539        let opt = EmbeddingIndexOptimizer::new();
540        let params = HnswParams::default();
541        let mb = opt.estimate_memory_mb(0, &params);
542        assert_eq!(mb, 0.0);
543    }
544}