1#![allow(non_local_definitions)]
3
4#[cfg(feature = "python")]
5use numpy::{PyArray1, PyReadonlyArray1, PyReadonlyArray2};
6#[cfg(feature = "python")]
7use pyo3::prelude::*;
8
9use crate::mstg::{MstgConfig, MstgIndex, ScalarPrecision, SearchParams};
10use crate::rotation::RotatorType;
11use crate::Metric;
12
13#[cfg(feature = "python")]
14#[pyclass(name = "MstgIndex")]
15pub struct PyMstgIndex {
16 index: Option<MstgIndex>,
17 config: MstgConfig,
18 dimension: usize,
19}
20
21#[cfg(feature = "python")]
22#[pymethods]
23impl PyMstgIndex {
24 #[new]
26 #[allow(clippy::too_many_arguments)]
27 #[pyo3(signature = (
28 dimension,
29 metric="euclidean",
30 max_posting_size=16,
31 branching_factor=10,
32 balance_weight=1.0,
33 closure_epsilon=0.15,
34 max_replicas=8,
35 rabitq_bits=7,
36 faster_config=true,
37 hnsw_m=32,
38 hnsw_ef_construction=400,
39 centroid_precision="bf16",
40 default_ef_search=150,
41 pruning_epsilon=0.6
42 ))]
43 fn new(
44 dimension: usize,
45 metric: &str,
46 max_posting_size: usize,
47 branching_factor: usize,
48 balance_weight: f32,
49 closure_epsilon: f32,
50 max_replicas: usize,
51 rabitq_bits: usize,
52 faster_config: bool,
53 hnsw_m: usize,
54 hnsw_ef_construction: usize,
55 centroid_precision: &str,
56 default_ef_search: usize,
57 pruning_epsilon: f32,
58 ) -> PyResult<Self> {
59 let metric = match metric {
60 "euclidean" | "l2" => Metric::L2,
61 "angular" | "ip" | "inner_product" => Metric::InnerProduct,
62 _ => {
63 return Err(pyo3::exceptions::PyValueError::new_err(format!(
64 "Invalid metric: {}. Use 'euclidean' or 'angular'",
65 metric
66 )))
67 }
68 };
69
70 let centroid_precision = match centroid_precision {
71 "fp32" => ScalarPrecision::FP32,
72 "bf16" => ScalarPrecision::BF16,
73 "fp16" => ScalarPrecision::FP16,
74 "int8" => ScalarPrecision::INT8,
75 _ => {
76 return Err(pyo3::exceptions::PyValueError::new_err(format!(
77 "Invalid precision: {}. Use 'fp32', 'bf16', 'fp16', or 'int8'",
78 centroid_precision
79 )))
80 }
81 };
82
83 let config = MstgConfig {
84 max_posting_size,
85 branching_factor,
86 balance_weight,
87 closure_epsilon,
88 max_replicas,
89 rabitq_bits,
90 faster_config,
91 metric,
92 hnsw_m,
93 hnsw_ef_construction,
94 centroid_precision,
95 default_ef_search,
96 pruning_epsilon,
97 };
98
99 Ok(Self {
100 index: None,
101 config,
102 dimension,
103 })
104 }
105
106 fn fit(&mut self, data: PyReadonlyArray2<f32>) -> PyResult<()> {
108 let data = data.as_array();
109 let shape = data.shape();
110
111 if shape.len() != 2 {
112 return Err(pyo3::exceptions::PyValueError::new_err(
113 "Data must be 2D array (N x D)",
114 ));
115 }
116
117 if shape[1] != self.dimension {
118 return Err(pyo3::exceptions::PyValueError::new_err(format!(
119 "Data dimension {} does not match expected {}",
120 shape[1], self.dimension
121 )));
122 }
123
124 let n = shape[0];
126 let mut vectors = Vec::with_capacity(n);
127
128 for i in 0..n {
129 let row = data.row(i);
130 let vec: Vec<f32> = row.iter().copied().collect();
131 vectors.push(vec);
132 }
133
134 match MstgIndex::build(&vectors, self.config.clone()) {
136 Ok(index) => {
137 self.index = Some(index);
138 Ok(())
139 }
140 Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
141 "Failed to build index: {}",
142 e
143 ))),
144 }
145 }
146
147 fn set_query_arguments(&mut self, ef_search: Option<usize>, pruning_epsilon: Option<f32>) {
149 if let Some(ef) = ef_search {
150 self.config.default_ef_search = ef;
151 }
152 if let Some(eps) = pruning_epsilon {
153 self.config.pruning_epsilon = eps;
154 }
155 }
156
157 fn query(&self, py: Python, query: PyReadonlyArray1<f32>, k: usize) -> PyResult<PyObject> {
160 let index = self.index.as_ref().ok_or_else(|| {
161 pyo3::exceptions::PyRuntimeError::new_err("Index not built yet. Call fit() first.")
162 })?;
163
164 let query = query.as_slice()?;
165
166 if query.len() != self.dimension {
167 return Err(pyo3::exceptions::PyValueError::new_err(format!(
168 "Query dimension {} does not match expected {}",
169 query.len(),
170 self.dimension
171 )));
172 }
173
174 let params = SearchParams::new(
175 self.config.default_ef_search,
176 self.config.pruning_epsilon,
177 k,
178 );
179
180 let results = index.search(query, ¶ms);
181
182 let n = results.len();
184 let mut data = Vec::with_capacity(n * 2);
185 for result in &results {
186 data.push(result.vector_id as f32);
187 data.push(result.distance);
188 }
189
190 let array_1d = PyArray1::<f32>::from_vec(py, data);
192 let result_array = array_1d.reshape([n, 2]).unwrap();
193
194 Ok(result_array.to_owned().into_py(py))
195 }
196
197 fn batch_query(
202 &self,
203 py: Python,
204 queries: PyReadonlyArray2<f32>,
205 k: usize,
206 ) -> PyResult<Vec<PyObject>> {
207 let index = self.index.as_ref().ok_or_else(|| {
208 pyo3::exceptions::PyRuntimeError::new_err("Index not built yet. Call fit() first.")
209 })?;
210
211 let queries = queries.as_array();
212 let shape = queries.shape();
213
214 if shape.len() != 2 {
215 return Err(pyo3::exceptions::PyValueError::new_err(
216 "Queries must be 2D array (N x D)",
217 ));
218 }
219
220 if shape[1] != self.dimension {
221 return Err(pyo3::exceptions::PyValueError::new_err(format!(
222 "Query dimension {} does not match expected {}",
223 shape[1], self.dimension
224 )));
225 }
226
227 let n_queries = shape[0];
228 let params = SearchParams::new(
229 self.config.default_ef_search,
230 self.config.pruning_epsilon,
231 k,
232 );
233
234 let query_vecs: Vec<Vec<f32>> = (0..n_queries)
236 .map(|i| {
237 let row = queries.row(i);
238 row.iter().copied().collect()
239 })
240 .collect();
241
242 let all_results = index.batch_search(&query_vecs, ¶ms);
244
245 all_results
247 .into_iter()
248 .map(|results| {
249 let n = results.len();
250 let mut data = Vec::with_capacity(n * 2);
251 for result in &results {
252 data.push(result.vector_id as f32);
253 data.push(result.distance);
254 }
255
256 let array_1d = PyArray1::<f32>::from_vec(py, data);
257 let result_array = array_1d.reshape([n, 2]).unwrap();
258 Ok(result_array.to_owned().into_py(py))
259 })
260 .collect()
261 }
262
263 fn get_memory_usage(&self) -> PyResult<usize> {
265 let index = self
266 .index
267 .as_ref()
268 .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Index not built yet."))?;
269
270 let mem_mb = MstgIndex::estimate_memory_mb(&index.centroid_index, &index.posting_lists);
272 Ok((mem_mb * 1024.0 * 1024.0) as usize)
273 }
274
275 fn __len__(&self) -> PyResult<usize> {
277 let index = self
278 .index
279 .as_ref()
280 .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Index not built yet."))?;
281
282 let total: usize = index.posting_lists.iter().map(|p| p.len()).sum();
283 Ok(total)
284 }
285
286 fn __repr__(&self) -> String {
287 format!(
288 "MstgIndex(dimension={}, metric={:?}, built={})",
289 self.dimension,
290 self.config.metric,
291 self.index.is_some()
292 )
293 }
294
295 fn save(&self, path: &str) -> PyResult<()> {
297 let index = self
298 .index
299 .as_ref()
300 .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Index not built yet."))?;
301
302 index.save_to_path(path).map_err(|e| {
303 pyo3::exceptions::PyRuntimeError::new_err(format!("Save failed: {}", e))
304 })?;
305
306 Ok(())
307 }
308
309 #[staticmethod]
311 fn load(path: &str) -> PyResult<Self> {
312 let index = MstgIndex::load_from_path(path).map_err(|e| {
313 pyo3::exceptions::PyRuntimeError::new_err(format!("Load failed: {}", e))
314 })?;
315
316 let dimension = index.centroid_index.dimension().unwrap_or(0);
318
319 let config = index.config.clone();
320
321 Ok(Self {
322 index: Some(index),
323 config,
324 dimension,
325 })
326 }
327
328 #[staticmethod]
334 fn load_mmap(path: &str) -> PyResult<Self> {
335 let index = MstgIndex::load_from_path_mmap(path).map_err(|e| {
336 pyo3::exceptions::PyRuntimeError::new_err(format!("Load mmap failed: {}", e))
337 })?;
338
339 let dimension = index.centroid_index.dimension().unwrap_or(0);
340
341 let config = index.config.clone();
342
343 Ok(Self {
344 index: Some(index),
345 config,
346 dimension,
347 })
348 }
349}
350
351#[cfg(feature = "python")]
356#[pyclass(name = "IvfRabitqIndex")]
357pub struct PyIvfRabitqIndex {
358 index: Option<crate::ivf::IvfRabitqIndex>,
359 dimension: usize,
360 metric: Metric,
361}
362
363#[cfg(feature = "python")]
364#[pymethods]
365impl PyIvfRabitqIndex {
366 #[new]
368 #[pyo3(signature = (dimension, metric="euclidean"))]
369 fn new(dimension: usize, metric: &str) -> PyResult<Self> {
370 let metric = match metric {
371 "euclidean" | "l2" => Metric::L2,
372 "angular" | "ip" | "inner_product" => Metric::InnerProduct,
373 _ => {
374 return Err(pyo3::exceptions::PyValueError::new_err(format!(
375 "Invalid metric: {}. Use 'euclidean' or 'angular'",
376 metric
377 )))
378 }
379 };
380
381 Ok(Self {
382 index: None,
383 dimension,
384 metric,
385 })
386 }
387
388 #[pyo3(signature = (data, nlist, total_bits=7, rotator_type="random", seed=42, faster_config=true))]
390 fn fit(
391 &mut self,
392 data: PyReadonlyArray2<f32>,
393 nlist: usize,
394 total_bits: usize,
395 rotator_type: &str,
396 seed: u64,
397 faster_config: bool,
398 ) -> PyResult<()> {
399 let data = data.as_array();
400 let shape = data.shape();
401
402 if shape.len() != 2 {
403 return Err(pyo3::exceptions::PyValueError::new_err(
404 "Data must be 2D array (N x D)",
405 ));
406 }
407
408 if shape[1] != self.dimension {
409 return Err(pyo3::exceptions::PyValueError::new_err(format!(
410 "Data dimension {} does not match expected {}",
411 shape[1], self.dimension
412 )));
413 }
414
415 let rotator_type = match rotator_type {
416 "fht" | "random" => RotatorType::FhtKacRotator,
417 "matrix" | "identity" => RotatorType::MatrixRotator,
418 _ => {
419 return Err(pyo3::exceptions::PyValueError::new_err(format!(
420 "Invalid rotator_type: {}. Use 'fht', 'random', 'matrix', or 'identity'",
421 rotator_type
422 )))
423 }
424 };
425
426 let n = shape[0];
428 let mut vectors = Vec::with_capacity(n);
429
430 for i in 0..n {
431 let row = data.row(i);
432 let vec: Vec<f32> = row.iter().copied().collect();
433 vectors.push(vec);
434 }
435
436 match crate::ivf::IvfRabitqIndex::train(
438 &vectors,
439 nlist,
440 total_bits,
441 self.metric,
442 rotator_type,
443 seed,
444 faster_config,
445 ) {
446 Ok(index) => {
447 self.index = Some(index);
448 Ok(())
449 }
450 Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
451 "Failed to build index: {}",
452 e
453 ))),
454 }
455 }
456
457 #[allow(clippy::too_many_arguments)]
459 #[pyo3(signature = (data, centroids, assignments, total_bits=7, rotator_type="random", seed=42, faster_config=true))]
460 fn fit_with_clusters(
461 &mut self,
462 data: PyReadonlyArray2<f32>,
463 centroids: PyReadonlyArray2<f32>,
464 assignments: PyReadonlyArray1<i32>,
465 total_bits: usize,
466 rotator_type: &str,
467 seed: u64,
468 faster_config: bool,
469 ) -> PyResult<()> {
470 let data = data.as_array();
471 let centroids = centroids.as_array();
472 let assignments = assignments.as_slice()?;
473
474 let data_shape = data.shape();
475 let centroids_shape = centroids.shape();
476
477 if data_shape.len() != 2 || centroids_shape.len() != 2 {
478 return Err(pyo3::exceptions::PyValueError::new_err(
479 "Data and centroids must be 2D arrays",
480 ));
481 }
482
483 if data_shape[1] != self.dimension || centroids_shape[1] != self.dimension {
484 return Err(pyo3::exceptions::PyValueError::new_err(format!(
485 "Data/centroids dimension must match expected {}",
486 self.dimension
487 )));
488 }
489
490 if data_shape[0] != assignments.len() {
491 return Err(pyo3::exceptions::PyValueError::new_err(
492 "Data and assignments must have same length",
493 ));
494 }
495
496 let rotator_type = match rotator_type {
497 "fht" | "random" => RotatorType::FhtKacRotator,
498 "matrix" | "identity" => RotatorType::MatrixRotator,
499 _ => {
500 return Err(pyo3::exceptions::PyValueError::new_err(format!(
501 "Invalid rotator_type: {}. Use 'fht', 'random', 'matrix', or 'identity'",
502 rotator_type
503 )))
504 }
505 };
506
507 let n_data = data_shape[0];
509 let mut data_vecs = Vec::with_capacity(n_data);
510 for i in 0..n_data {
511 let row = data.row(i);
512 let vec: Vec<f32> = row.iter().copied().collect();
513 data_vecs.push(vec);
514 }
515
516 let n_centroids = centroids_shape[0];
518 let mut centroid_vecs = Vec::with_capacity(n_centroids);
519 for i in 0..n_centroids {
520 let row = centroids.row(i);
521 let vec: Vec<f32> = row.iter().copied().collect();
522 centroid_vecs.push(vec);
523 }
524
525 let assignments_usize: Vec<usize> = assignments.iter().map(|&x| x as usize).collect();
527
528 match crate::ivf::IvfRabitqIndex::train_with_clusters(
530 &data_vecs,
531 ¢roid_vecs,
532 &assignments_usize,
533 total_bits,
534 self.metric,
535 rotator_type,
536 seed,
537 faster_config,
538 ) {
539 Ok(index) => {
540 self.index = Some(index);
541 Ok(())
542 }
543 Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
544 "Failed to build index with clusters: {}",
545 e
546 ))),
547 }
548 }
549
550 #[pyo3(signature = (query, k, nprobe=1))]
553 fn query(
554 &self,
555 py: Python,
556 query: PyReadonlyArray1<f32>,
557 k: usize,
558 nprobe: usize,
559 ) -> PyResult<PyObject> {
560 let index = self.index.as_ref().ok_or_else(|| {
561 pyo3::exceptions::PyRuntimeError::new_err("Index not built yet. Call fit() first.")
562 })?;
563
564 let query_slice = query.as_slice()?;
565
566 if query_slice.len() != self.dimension {
567 return Err(pyo3::exceptions::PyValueError::new_err(format!(
568 "Query dimension {} does not match expected {}",
569 query_slice.len(),
570 self.dimension
571 )));
572 }
573
574 let params = crate::ivf::SearchParams::new(k, nprobe);
575 let results = match index.search(query_slice, params) {
576 Ok(r) => r,
577 Err(e) => {
578 return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
579 "Search failed: {}",
580 e
581 )))
582 }
583 };
584
585 let n = results.len();
587 let mut data = Vec::with_capacity(n * 2);
588
589 for result in &results {
591 data.push(result.id as f32);
592 data.push(result.score);
593 }
594
595 let array_1d = PyArray1::<f32>::from_vec(py, data);
597 let result_array = array_1d.reshape([n, 2]).unwrap();
598
599 Ok(result_array.to_owned().into_py(py))
600 }
601
602 #[pyo3(signature = (queries, k, nprobe=1))]
610 fn batch_query(
611 &self,
612 py: Python,
613 queries: PyReadonlyArray2<f32>,
614 k: usize,
615 nprobe: usize,
616 ) -> PyResult<Vec<PyObject>> {
617 let index = self.index.as_ref().ok_or_else(|| {
618 pyo3::exceptions::PyRuntimeError::new_err("Index not built yet. Call fit() first.")
619 })?;
620
621 let queries_arr = queries.as_array();
622 let shape = queries_arr.shape();
623
624 if shape.len() != 2 {
625 return Err(pyo3::exceptions::PyValueError::new_err(
626 "Queries must be 2D array (N x D)",
627 ));
628 }
629
630 if shape[1] != self.dimension {
631 return Err(pyo3::exceptions::PyValueError::new_err(format!(
632 "Query dimension {} does not match expected {}",
633 shape[1], self.dimension
634 )));
635 }
636
637 let n_queries = shape[0];
638 let params = crate::ivf::SearchParams::new(k, nprobe);
639
640 let query_vecs: Vec<Vec<f32>> = (0..n_queries)
643 .map(|i| queries_arr.row(i).iter().copied().collect())
644 .collect();
645
646 let query_refs: Vec<&[f32]> = query_vecs.iter().map(|v| v.as_slice()).collect();
647
648 let all_results = index.batch_search(&query_refs, params);
650
651 let mut py_results = Vec::with_capacity(n_queries);
653
654 for (i, result) in all_results.into_iter().enumerate() {
656 let results = match result {
657 Ok(r) => r,
658 Err(e) => {
659 return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
660 "Batch search failed at query {}: {}",
661 i, e
662 )))
663 }
664 };
665
666 let n = results.len();
668 let mut data = Vec::with_capacity(n * 2);
669 for result in &results {
670 data.push(result.id as f32);
671 data.push(result.score);
672 }
673
674 let array_1d = PyArray1::<f32>::from_vec(py, data);
676 let result_array = array_1d.reshape([n, 2]).unwrap();
677
678 py_results.push(result_array.to_owned().into_py(py));
679 }
680
681 Ok(py_results)
682 }
683
684 fn save(&self, path: &str) -> PyResult<()> {
686 let index = self.index.as_ref().ok_or_else(|| {
687 pyo3::exceptions::PyRuntimeError::new_err("Index not built yet. Call fit() first.")
688 })?;
689
690 index.save_to_path(path).map_err(|e| {
691 pyo3::exceptions::PyIOError::new_err(format!("Failed to save index: {}", e))
692 })
693 }
694
695 fn load(&mut self, path: &str) -> PyResult<()> {
697 let index = crate::ivf::IvfRabitqIndex::load_from_path(path).map_err(|e| {
698 pyo3::exceptions::PyIOError::new_err(format!("Failed to load index: {}", e))
699 })?;
700
701 self.index = Some(index);
702 Ok(())
703 }
704
705 fn __len__(&self) -> PyResult<usize> {
707 let index = self
708 .index
709 .as_ref()
710 .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Index not built yet."))?;
711
712 Ok(index.len())
713 }
714
715 fn cluster_count(&self) -> PyResult<usize> {
717 let index = self
718 .index
719 .as_ref()
720 .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Index not built yet."))?;
721
722 Ok(index.cluster_count())
723 }
724
725 fn __repr__(&self) -> String {
726 format!(
727 "IvfRabitqIndex(dimension={}, metric={:?}, built={}, clusters={})",
728 self.dimension,
729 self.metric,
730 self.index.is_some(),
731 self.index
732 .as_ref()
733 .map(|idx| idx.cluster_count())
734 .unwrap_or(0)
735 )
736 }
737}
738
739#[cfg(feature = "python")]
741#[pymodule]
742fn rabitq_rs(_py: Python, m: &PyModule) -> PyResult<()> {
743 m.add_class::<PyMstgIndex>()?;
744 m.add_class::<PyIvfRabitqIndex>()?;
745 Ok(())
746}