1#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum IndexTuningGoal {
10 MaxRecall,
12 MaxSpeed,
14 Balanced,
16}
17
18#[derive(Clone, Debug, PartialEq)]
20pub struct HnswParams {
21 pub m: usize,
23 pub ef_construction: usize,
25 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 pub fn is_valid(&self) -> bool {
42 self.m >= 2 && self.ef_construction >= self.m && self.ef_search >= 1
43 }
44}
45
46#[derive(Clone, Debug, PartialEq)]
50pub struct LevelDistribution {
51 pub levels: Vec<usize>,
53}
54
55impl LevelDistribution {
56 pub fn total_nodes(&self) -> usize {
58 self.levels.iter().sum()
59 }
60
61 pub fn max_level(&self) -> usize {
63 self.levels.len().saturating_sub(1)
64 }
65
66 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#[derive(Clone, Debug)]
82pub struct OptimizationReport {
83 pub current_params: HnswParams,
85 pub recommended_params: HnswParams,
87 pub goal: IndexTuningGoal,
89 pub expected_recall_change: f64,
91 pub expected_speed_change: f64,
93 pub notes: Vec<String>,
95}
96
97pub struct EmbeddingIndexOptimizer;
99
100impl EmbeddingIndexOptimizer {
101 pub fn new() -> Self {
103 Self
104 }
105
106 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 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 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 #[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 #[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 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 let dist = LevelDistribution {
318 levels: vec![1000, 900, 10],
319 };
320 assert!(!dist.is_well_formed(16));
321 }
322
323 #[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 #[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 #[test]
440 fn test_balanced_clamps_m_within_range() {
441 let opt = EmbeddingIndexOptimizer::new();
442 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 #[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 #[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, ¶ms);
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, ¶ms);
513 assert!(obs.iter().any(|s| s.contains("555")));
514 }
515
516 #[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, ¶ms);
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, ¶ms);
533 let mb_large = opt.estimate_memory_mb(10_000, ¶ms);
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, ¶ms);
542 assert_eq!(mb, 0.0);
543 }
544}