1use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use parking_lot::Mutex;
10
11#[derive(Debug)]
13struct DatasetMetrics {
14 vector_search_count: AtomicU64,
16 fts_count: AtomicU64,
18 hybrid_count: AtomicU64,
20 scan_count: AtomicU64,
22 total_latency_us: AtomicU64,
24 slow_query_count: AtomicU64,
26}
27
28impl DatasetMetrics {
29 fn new() -> Self {
30 Self {
31 vector_search_count: AtomicU64::new(0),
32 fts_count: AtomicU64::new(0),
33 hybrid_count: AtomicU64::new(0),
34 scan_count: AtomicU64::new(0),
35 total_latency_us: AtomicU64::new(0),
36 slow_query_count: AtomicU64::new(0),
37 }
38 }
39
40 fn total_queries(&self) -> u64 {
41 self.vector_search_count.load(Ordering::Relaxed)
42 + self.fts_count.load(Ordering::Relaxed)
43 + self.hybrid_count.load(Ordering::Relaxed)
44 + self.scan_count.load(Ordering::Relaxed)
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum QueryKind {
51 VectorSearch,
52 FullTextSearch,
53 HybridSearch,
54 Scan,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum IndexRecommendation {
60 KeepCurrent { reason: String },
62 SwitchTo { index_type: String, reason: String },
64 AddSecondary { index_type: String, reason: String },
66}
67
68impl IndexRecommendation {
69 pub fn reason(&self) -> &str {
71 match self {
72 Self::KeepCurrent { reason } => reason,
73 Self::SwitchTo { reason, .. } => reason,
74 Self::AddSecondary { reason, .. } => reason,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct DatasetQueryStats {
82 pub vector_search_count: u64,
83 pub fts_count: u64,
84 pub hybrid_count: u64,
85 pub scan_count: u64,
86 pub total_queries: u64,
87 pub avg_latency_us: u64,
88 pub slow_query_count: u64,
89}
90
91#[derive(Debug)]
95pub struct IndexAdvisor {
96 metrics: Mutex<HashMap<String, DatasetMetrics>>,
97 slow_threshold_us: u64,
99 pub auto_apply: bool,
101}
102
103const DEFAULT_SLOW_THRESHOLD_US: u64 = 100_000;
105
106impl Default for IndexAdvisor {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl IndexAdvisor {
113 pub fn new() -> Self {
115 Self {
116 metrics: Mutex::new(HashMap::new()),
117 slow_threshold_us: DEFAULT_SLOW_THRESHOLD_US,
118 auto_apply: false,
119 }
120 }
121
122 pub fn with_slow_threshold(threshold_us: u64) -> Self {
124 Self {
125 metrics: Mutex::new(HashMap::new()),
126 slow_threshold_us: threshold_us,
127 auto_apply: false,
128 }
129 }
130
131 pub fn record_query(&self, dataset: &str, kind: QueryKind, latency: std::time::Duration) {
133 let latency_us = latency.as_micros() as u64;
134 let mut map = self.metrics.lock();
135 let m = map
136 .entry(dataset.to_string())
137 .or_insert_with(DatasetMetrics::new);
138 match kind {
139 QueryKind::VectorSearch => {
140 m.vector_search_count.fetch_add(1, Ordering::Relaxed);
141 }
142 QueryKind::FullTextSearch => {
143 m.fts_count.fetch_add(1, Ordering::Relaxed);
144 }
145 QueryKind::HybridSearch => {
146 m.hybrid_count.fetch_add(1, Ordering::Relaxed);
147 }
148 QueryKind::Scan => {
149 m.scan_count.fetch_add(1, Ordering::Relaxed);
150 }
151 }
152 m.total_latency_us.fetch_add(latency_us, Ordering::Relaxed);
153 if latency_us > self.slow_threshold_us {
154 m.slow_query_count.fetch_add(1, Ordering::Relaxed);
155 }
156 }
157
158 pub fn advise(&self, dataset: &str) -> IndexRecommendation {
160 let map = self.metrics.lock();
161 let Some(m) = map.get(dataset) else {
162 return IndexRecommendation::KeepCurrent {
163 reason: "no query data observed for this dataset".into(),
164 };
165 };
166
167 let total = m.total_queries();
168 if total < 10 {
169 return IndexRecommendation::KeepCurrent {
170 reason: format!("insufficient data: only {total} queries observed (need ≥10)"),
171 };
172 }
173
174 let vec_count = m.vector_search_count.load(Ordering::Relaxed);
175 let fts_count = m.fts_count.load(Ordering::Relaxed);
176 let hybrid_count = m.hybrid_count.load(Ordering::Relaxed);
177 let scan_count = m.scan_count.load(Ordering::Relaxed);
178 let slow_count = m.slow_query_count.load(Ordering::Relaxed);
179
180 let vec_ratio = (vec_count + hybrid_count) as f64 / total as f64;
181 let scan_ratio = scan_count as f64 / total as f64;
182 let fts_ratio = (fts_count + hybrid_count) as f64 / total as f64;
183 let slow_ratio = slow_count as f64 / total as f64;
184
185 if vec_ratio > 0.8 && slow_ratio > 0.2 {
188 return IndexRecommendation::SwitchTo {
189 index_type: "IVF_HNSW".into(),
190 reason: format!(
191 "vector-dominant workload ({:.0}% vector/hybrid) with {:.0}% slow queries — IVF-HNSW improves ANN latency",
192 vec_ratio * 100.0,
193 slow_ratio * 100.0
194 ),
195 };
196 }
197
198 if scan_ratio > 0.8 {
201 return IndexRecommendation::SwitchTo {
202 index_type: "IVF_PQ".into(),
203 reason: format!(
204 "scan-dominant workload ({:.0}% scans) — IVF-PQ provides efficient sequential access",
205 scan_ratio * 100.0
206 ),
207 };
208 }
209
210 if fts_ratio > 0.3 {
212 return IndexRecommendation::AddSecondary {
213 index_type: "FTS".into(),
214 reason: format!(
215 "significant FTS workload ({:.0}% text/hybrid queries) — add FTS index to avoid brute-force text search",
216 fts_ratio * 100.0
217 ),
218 };
219 }
220
221 IndexRecommendation::KeepCurrent {
223 reason: format!(
224 "balanced workload (vec: {:.0}%, scan: {:.0}%, fts: {:.0}%) — current indices adequate",
225 vec_ratio * 100.0,
226 scan_ratio * 100.0,
227 fts_ratio * 100.0
228 ),
229 }
230 }
231
232 pub fn stats(&self, dataset: &str) -> Option<DatasetQueryStats> {
234 let map = self.metrics.lock();
235 let m = map.get(dataset)?;
236 let total = m.total_queries();
237 let total_latency = m.total_latency_us.load(Ordering::Relaxed);
238 Some(DatasetQueryStats {
239 vector_search_count: m.vector_search_count.load(Ordering::Relaxed),
240 fts_count: m.fts_count.load(Ordering::Relaxed),
241 hybrid_count: m.hybrid_count.load(Ordering::Relaxed),
242 scan_count: m.scan_count.load(Ordering::Relaxed),
243 total_queries: total,
244 avg_latency_us: total_latency.checked_div(total).unwrap_or(0),
245 slow_query_count: m.slow_query_count.load(Ordering::Relaxed),
246 })
247 }
248
249 pub fn tracked_datasets(&self) -> Vec<String> {
251 self.metrics.lock().keys().cloned().collect()
252 }
253
254 pub fn reset(&self, dataset: &str) {
256 self.metrics.lock().remove(dataset);
257 }
258
259 pub fn reset_all(&self) {
261 self.metrics.lock().clear();
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::time::Duration;
269
270 #[test]
271 fn no_data_keeps_current() {
272 let advisor = IndexAdvisor::new();
273 let rec = advisor.advise("episodic");
274 assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
275 }
276
277 #[test]
278 fn insufficient_data_keeps_current() {
279 let advisor = IndexAdvisor::new();
280 for _ in 0..5 {
281 advisor.record_query(
282 "episodic",
283 QueryKind::VectorSearch,
284 Duration::from_millis(10),
285 );
286 }
287 let rec = advisor.advise("episodic");
288 assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
289 assert!(rec.reason().contains("insufficient"));
290 }
291
292 #[test]
293 fn vector_dominant_with_slow_queries_recommends_ivf_hnsw() {
294 let advisor = IndexAdvisor::new();
295 for i in 0..90 {
297 let latency = if i < 30 {
298 Duration::from_millis(200) } else {
300 Duration::from_millis(10) };
302 advisor.record_query("episodic", QueryKind::VectorSearch, latency);
303 }
304 for _ in 0..10 {
306 advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
307 }
308 let rec = advisor.advise("episodic");
309 match rec {
310 IndexRecommendation::SwitchTo { index_type, reason } => {
311 assert_eq!(index_type, "IVF_HNSW");
312 assert!(reason.contains("vector-dominant"));
313 }
314 other => panic!("expected SwitchTo, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn scan_dominant_recommends_ivf_pq() {
320 let advisor = IndexAdvisor::new();
321 for _ in 0..90 {
322 advisor.record_query("semantic", QueryKind::Scan, Duration::from_millis(5));
323 }
324 for _ in 0..10 {
325 advisor.record_query(
326 "semantic",
327 QueryKind::VectorSearch,
328 Duration::from_millis(10),
329 );
330 }
331 let rec = advisor.advise("semantic");
332 match rec {
333 IndexRecommendation::SwitchTo { index_type, reason } => {
334 assert_eq!(index_type, "IVF_PQ");
335 assert!(reason.contains("scan-dominant"));
336 }
337 other => panic!("expected SwitchTo, got {other:?}"),
338 }
339 }
340
341 #[test]
342 fn mixed_workload_keeps_current() {
343 let advisor = IndexAdvisor::new();
344 for _ in 0..40 {
345 advisor.record_query(
346 "episodic",
347 QueryKind::VectorSearch,
348 Duration::from_millis(10),
349 );
350 }
351 for _ in 0..30 {
352 advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
353 }
354 for _ in 0..30 {
355 advisor.record_query(
356 "episodic",
357 QueryKind::FullTextSearch,
358 Duration::from_millis(8),
359 );
360 }
361 let rec = advisor.advise("episodic");
362 assert!(matches!(rec, IndexRecommendation::KeepCurrent { .. }));
363 assert!(rec.reason().contains("balanced"));
364 }
365
366 #[test]
367 fn metrics_correct_after_100_queries() {
368 let advisor = IndexAdvisor::new();
369 for _ in 0..60 {
370 advisor.record_query(
371 "episodic",
372 QueryKind::VectorSearch,
373 Duration::from_millis(10),
374 );
375 }
376 for _ in 0..30 {
377 advisor.record_query(
378 "episodic",
379 QueryKind::HybridSearch,
380 Duration::from_millis(15),
381 );
382 }
383 for _ in 0..10 {
384 advisor.record_query("episodic", QueryKind::Scan, Duration::from_millis(5));
385 }
386 let stats = advisor.stats("episodic").unwrap();
387 assert_eq!(stats.vector_search_count, 60);
388 assert_eq!(stats.hybrid_count, 30);
389 assert_eq!(stats.scan_count, 10);
390 assert_eq!(stats.total_queries, 100);
391 }
392
393 #[test]
394 fn recommendation_has_reason() {
395 let advisor = IndexAdvisor::new();
396 for _ in 0..100 {
397 advisor.record_query(
398 "episodic",
399 QueryKind::VectorSearch,
400 Duration::from_millis(200),
401 );
402 }
403 let rec = advisor.advise("episodic");
404 let reason = rec.reason();
405 assert!(!reason.is_empty());
406 }
407
408 #[test]
409 fn fts_heavy_recommends_secondary() {
410 let advisor = IndexAdvisor::new();
411 for _ in 0..50 {
412 advisor.record_query(
413 "episodic",
414 QueryKind::VectorSearch,
415 Duration::from_millis(10),
416 );
417 }
418 for _ in 0..50 {
419 advisor.record_query(
420 "episodic",
421 QueryKind::FullTextSearch,
422 Duration::from_millis(10),
423 );
424 }
425 let rec = advisor.advise("episodic");
426 match rec {
427 IndexRecommendation::AddSecondary { index_type, reason } => {
428 assert_eq!(index_type, "FTS");
429 assert!(reason.contains("FTS"));
430 }
431 other => panic!("expected AddSecondary, got {other:?}"),
432 }
433 }
434
435 #[test]
436 fn auto_apply_default_false() {
437 let advisor = IndexAdvisor::new();
438 assert!(!advisor.auto_apply);
439 }
440}