1use std::fmt;
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::{VectorError, VectorResult};
22use crate::ops::{BinaryDistanceMetric, DistanceMetric};
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum IndexType {
28 IvfFlat(IvfFlatConfig),
30
31 Hnsw(HnswConfig),
33}
34
35impl fmt::Display for IndexType {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Self::IvfFlat(_) => write!(f, "ivfflat"),
39 Self::Hnsw(_) => write!(f, "hnsw"),
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct IvfFlatConfig {
55 pub lists: usize,
60}
61
62impl IvfFlatConfig {
63 pub fn new(lists: usize) -> Self {
65 Self { lists }
66 }
67
68 pub fn for_row_count(rows: usize) -> Self {
70 let lists = if rows <= 1_000_000 {
71 (rows / 1000).max(1)
72 } else {
73 (rows as f64).sqrt() as usize
74 };
75 Self { lists }
76 }
77}
78
79impl Default for IvfFlatConfig {
80 fn default() -> Self {
81 Self { lists: 100 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct HnswConfig {
98 pub m: Option<usize>,
103
104 pub ef_construction: Option<usize>,
109}
110
111impl HnswConfig {
112 pub fn new() -> Self {
114 Self {
115 m: None,
116 ef_construction: None,
117 }
118 }
119
120 pub fn m(mut self, m: usize) -> Self {
122 self.m = Some(m);
123 self
124 }
125
126 pub fn ef_construction(mut self, ef: usize) -> Self {
128 self.ef_construction = Some(ef);
129 self
130 }
131
132 pub fn high_recall() -> Self {
134 Self {
135 m: Some(32),
136 ef_construction: Some(128),
137 }
138 }
139
140 pub fn fast_build() -> Self {
142 Self {
143 m: Some(8),
144 ef_construction: Some(32),
145 }
146 }
147}
148
149impl Default for HnswConfig {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct VectorIndex {
176 pub name: String,
178 pub table: String,
180 pub column: String,
182 pub metric: DistanceMetric,
184 pub index_type: IndexType,
186 pub concurrent: bool,
188 pub if_not_exists: bool,
190}
191
192impl VectorIndex {
193 pub fn hnsw(
195 name: impl Into<String>,
196 table: impl Into<String>,
197 column: impl Into<String>,
198 ) -> VectorIndexBuilder {
199 VectorIndexBuilder {
200 name: name.into(),
201 table: table.into(),
202 column: column.into(),
203 metric: DistanceMetric::L2,
204 index_type: IndexType::Hnsw(HnswConfig::default()),
205 concurrent: false,
206 if_not_exists: false,
207 }
208 }
209
210 pub fn ivfflat(
212 name: impl Into<String>,
213 table: impl Into<String>,
214 column: impl Into<String>,
215 ) -> VectorIndexBuilder {
216 VectorIndexBuilder {
217 name: name.into(),
218 table: table.into(),
219 column: column.into(),
220 metric: DistanceMetric::L2,
221 index_type: IndexType::IvfFlat(IvfFlatConfig::default()),
222 concurrent: false,
223 if_not_exists: false,
224 }
225 }
226
227 pub fn to_create_sql(&self) -> String {
229 let concurrent = if self.concurrent { " CONCURRENTLY" } else { "" };
230 let if_not_exists = if self.if_not_exists {
231 " IF NOT EXISTS"
232 } else {
233 ""
234 };
235
236 let (method, with_clause) = match &self.index_type {
237 IndexType::IvfFlat(config) => {
238 let with = format!(" WITH (lists = {})", config.lists);
239 ("ivfflat", with)
240 }
241 IndexType::Hnsw(config) => {
242 let mut with_parts = Vec::new();
243 if let Some(m) = config.m {
244 with_parts.push(format!("m = {m}"));
245 }
246 if let Some(ef) = config.ef_construction {
247 with_parts.push(format!("ef_construction = {ef}"));
248 }
249 let with = if with_parts.is_empty() {
250 String::new()
251 } else {
252 format!(" WITH ({})", with_parts.join(", "))
253 };
254 ("hnsw", with)
255 }
256 };
257
258 format!(
259 "CREATE INDEX{}{} {} ON {} USING {} ({} {}){}",
260 concurrent,
261 if_not_exists,
262 self.name,
263 self.table,
264 method,
265 self.column,
266 self.metric.ops_class(),
267 with_clause
268 )
269 }
270
271 pub fn to_drop_sql(&self) -> String {
273 let concurrent = if self.concurrent { " CONCURRENTLY" } else { "" };
274 format!("DROP INDEX{} IF EXISTS {}", concurrent, self.name)
275 }
276
277 pub fn to_exists_sql(&self) -> String {
279 let name = self.name.replace('\'', "''");
280 format!("SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{name}')")
281 }
282
283 pub fn to_size_sql(&self) -> String {
285 let name = self.name.replace('\'', "''");
286 format!("SELECT pg_size_pretty(pg_relation_size('{name}'))")
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct VectorIndexBuilder {
293 name: String,
294 table: String,
295 column: String,
296 metric: DistanceMetric,
297 index_type: IndexType,
298 concurrent: bool,
299 if_not_exists: bool,
300}
301
302impl VectorIndexBuilder {
303 pub fn metric(mut self, metric: DistanceMetric) -> Self {
305 self.metric = metric;
306 self
307 }
308
309 pub fn config(mut self, config: HnswConfig) -> Self {
316 if matches!(self.index_type, IndexType::Hnsw(_)) {
317 self.index_type = IndexType::Hnsw(config);
318 }
319 self
320 }
321
322 pub fn ivfflat_config(mut self, config: IvfFlatConfig) -> Self {
324 self.index_type = IndexType::IvfFlat(config);
325 self
326 }
327
328 pub fn concurrent(mut self) -> Self {
330 self.concurrent = true;
331 self
332 }
333
334 pub fn if_not_exists(mut self) -> Self {
336 self.if_not_exists = true;
337 self
338 }
339
340 pub fn build(self) -> VectorResult<VectorIndex> {
348 if self.name.is_empty() {
349 return Err(VectorError::index("index name cannot be empty"));
350 }
351 if !self
352 .name
353 .chars()
354 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
355 {
356 return Err(VectorError::index(
357 "index name contains invalid characters: only ASCII alphanumeric, '_', and '.' are allowed",
358 ));
359 }
360 if self.table.is_empty() {
361 return Err(VectorError::index("table name cannot be empty"));
362 }
363 if self.column.is_empty() {
364 return Err(VectorError::index("column name cannot be empty"));
365 }
366
367 Ok(VectorIndex {
368 name: self.name,
369 table: self.table,
370 column: self.column,
371 metric: self.metric,
372 index_type: self.index_type,
373 concurrent: self.concurrent,
374 if_not_exists: self.if_not_exists,
375 })
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct BinaryVectorIndex {
382 pub name: String,
384 pub table: String,
386 pub column: String,
388 pub metric: BinaryDistanceMetric,
390 pub hnsw_config: HnswConfig,
392 pub concurrent: bool,
394}
395
396impl BinaryVectorIndex {
397 #[allow(clippy::new_ret_no_self)]
399 pub fn new(
400 name: impl Into<String>,
401 table: impl Into<String>,
402 column: impl Into<String>,
403 ) -> BinaryVectorIndexBuilder {
404 BinaryVectorIndexBuilder {
405 name: name.into(),
406 table: table.into(),
407 column: column.into(),
408 metric: BinaryDistanceMetric::Hamming,
409 hnsw_config: HnswConfig::default(),
410 concurrent: false,
411 }
412 }
413
414 pub fn to_create_sql(&self) -> String {
416 let concurrent = if self.concurrent { " CONCURRENTLY" } else { "" };
417
418 let mut with_parts = Vec::new();
419 if let Some(m) = self.hnsw_config.m {
420 with_parts.push(format!("m = {m}"));
421 }
422 if let Some(ef) = self.hnsw_config.ef_construction {
423 with_parts.push(format!("ef_construction = {ef}"));
424 }
425 let with = if with_parts.is_empty() {
426 String::new()
427 } else {
428 format!(" WITH ({})", with_parts.join(", "))
429 };
430
431 format!(
432 "CREATE INDEX{} {} ON {} USING hnsw ({} {}){}",
433 concurrent,
434 self.name,
435 self.table,
436 self.column,
437 self.metric.ops_class(),
438 with
439 )
440 }
441}
442
443#[derive(Debug, Clone)]
445pub struct BinaryVectorIndexBuilder {
446 name: String,
447 table: String,
448 column: String,
449 metric: BinaryDistanceMetric,
450 hnsw_config: HnswConfig,
451 concurrent: bool,
452}
453
454impl BinaryVectorIndexBuilder {
455 pub fn metric(mut self, metric: BinaryDistanceMetric) -> Self {
457 self.metric = metric;
458 self
459 }
460
461 pub fn config(mut self, config: HnswConfig) -> Self {
463 self.hnsw_config = config;
464 self
465 }
466
467 pub fn concurrent(mut self) -> Self {
469 self.concurrent = true;
470 self
471 }
472
473 pub fn build(self) -> VectorResult<BinaryVectorIndex> {
475 if self.name.is_empty() {
476 return Err(VectorError::index("index name cannot be empty"));
477 }
478 Ok(BinaryVectorIndex {
479 name: self.name,
480 table: self.table,
481 column: self.column,
482 metric: self.metric,
483 hnsw_config: self.hnsw_config,
484 concurrent: self.concurrent,
485 })
486 }
487}
488
489pub mod extension {
491 pub fn create_extension_sql() -> &'static str {
493 "CREATE EXTENSION IF NOT EXISTS vector"
494 }
495
496 pub fn create_extension_in_schema_sql(schema: &str) -> String {
498 format!("CREATE EXTENSION IF NOT EXISTS vector SCHEMA {schema}")
499 }
500
501 pub fn drop_extension_sql() -> &'static str {
503 "DROP EXTENSION IF EXISTS vector"
504 }
505
506 pub fn check_extension_sql() -> &'static str {
508 "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')"
509 }
510
511 pub fn version_sql() -> &'static str {
513 "SELECT extversion FROM pg_extension WHERE extname = 'vector'"
514 }
515
516 pub fn add_vector_column_sql(table: &str, column: &str, dimensions: usize) -> String {
518 format!("ALTER TABLE {table} ADD COLUMN {column} vector({dimensions})")
519 }
520
521 pub fn add_halfvec_column_sql(table: &str, column: &str, dimensions: usize) -> String {
523 format!("ALTER TABLE {table} ADD COLUMN {column} halfvec({dimensions})")
524 }
525
526 pub fn add_sparsevec_column_sql(table: &str, column: &str, dimensions: usize) -> String {
528 format!("ALTER TABLE {table} ADD COLUMN {column} sparsevec({dimensions})")
529 }
530
531 pub fn add_bit_column_sql(table: &str, column: &str, dimensions: usize) -> String {
533 format!("ALTER TABLE {table} ADD COLUMN {column} bit({dimensions})")
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_hnsw_index_create_sql() {
543 let index = VectorIndex::hnsw("idx_embedding", "documents", "embedding")
544 .metric(DistanceMetric::Cosine)
545 .config(HnswConfig::new().m(16).ef_construction(64))
546 .build()
547 .unwrap();
548
549 let sql = index.to_create_sql();
550 assert!(sql.contains("CREATE INDEX"));
551 assert!(sql.contains("idx_embedding"));
552 assert!(sql.contains("documents"));
553 assert!(sql.contains("USING hnsw"));
554 assert!(sql.contains("vector_cosine_ops"));
555 assert!(sql.contains("m = 16"));
556 assert!(sql.contains("ef_construction = 64"));
557 }
558
559 #[test]
560 fn test_ivfflat_builder_ignores_hnsw_config() {
561 let index = VectorIndex::ivfflat("idx_embedding", "documents", "embedding")
562 .config(HnswConfig::high_recall())
563 .build()
564 .unwrap();
565
566 assert!(matches!(index.index_type, IndexType::IvfFlat(_)));
567 let sql = index.to_create_sql();
568 assert!(sql.contains("USING ivfflat"));
569 assert!(!sql.contains("USING hnsw"));
570 }
571
572 #[test]
573 fn test_hnsw_index_default_config() {
574 let index = VectorIndex::hnsw("idx_emb", "docs", "emb").build().unwrap();
575
576 let sql = index.to_create_sql();
577 assert!(sql.contains("USING hnsw"));
578 assert!(sql.contains("vector_l2_ops")); assert!(!sql.contains("WITH")); }
581
582 #[test]
583 fn test_ivfflat_index_create_sql() {
584 let index = VectorIndex::ivfflat("idx_embedding", "documents", "embedding")
585 .metric(DistanceMetric::L2)
586 .ivfflat_config(IvfFlatConfig::new(200))
587 .build()
588 .unwrap();
589
590 let sql = index.to_create_sql();
591 assert!(sql.contains("USING ivfflat"));
592 assert!(sql.contains("vector_l2_ops"));
593 assert!(sql.contains("lists = 200"));
594 }
595
596 #[test]
597 fn test_ivfflat_for_row_count() {
598 let config = IvfFlatConfig::for_row_count(500_000);
599 assert_eq!(config.lists, 500);
600
601 let config = IvfFlatConfig::for_row_count(5_000_000);
602 assert_eq!(config.lists, 2236); }
604
605 #[test]
606 fn test_concurrent_index() {
607 let index = VectorIndex::hnsw("idx_emb", "docs", "emb")
608 .concurrent()
609 .if_not_exists()
610 .build()
611 .unwrap();
612
613 let sql = index.to_create_sql();
614 assert!(sql.contains("CONCURRENTLY"));
615 assert!(sql.contains("IF NOT EXISTS"));
616 }
617
618 #[test]
619 fn test_drop_index() {
620 let index = VectorIndex::hnsw("idx_emb", "docs", "emb").build().unwrap();
621
622 let sql = index.to_drop_sql();
623 assert_eq!(sql, "DROP INDEX IF EXISTS idx_emb");
624 }
625
626 #[test]
627 fn test_concurrent_drop_index() {
628 let index = VectorIndex::hnsw("idx_emb", "docs", "emb")
629 .concurrent()
630 .build()
631 .unwrap();
632
633 let sql = index.to_drop_sql();
634 assert!(sql.contains("CONCURRENTLY"));
635 }
636
637 #[test]
638 fn test_index_exists_sql() {
639 let index = VectorIndex::hnsw("idx_emb", "docs", "emb").build().unwrap();
640
641 let sql = index.to_exists_sql();
642 assert!(sql.contains("pg_indexes"));
643 assert!(sql.contains("idx_emb"));
644 }
645
646 #[test]
647 fn test_index_size_sql() {
648 let index = VectorIndex::hnsw("idx_emb", "docs", "emb").build().unwrap();
649
650 let sql = index.to_size_sql();
651 assert!(sql.contains("pg_size_pretty"));
652 assert!(sql.contains("idx_emb"));
653 }
654
655 #[test]
656 fn test_index_name_with_quote_rejected_at_build() {
657 let result = VectorIndex::hnsw("idx'; DROP TABLE users; --", "docs", "emb").build();
658 assert!(result.is_err());
659 }
660
661 #[test]
662 fn test_schema_qualified_index_name_allowed() {
663 let index = VectorIndex::hnsw("myschema.idx_emb", "docs", "emb")
664 .build()
665 .unwrap();
666
667 let sql = index.to_exists_sql();
668 assert!(sql.contains("myschema.idx_emb"));
669 }
670
671 #[test]
672 fn test_exists_and_size_sql_escape_quotes() {
673 let mut index = VectorIndex::hnsw("idx_emb", "docs", "emb").build().unwrap();
676 index.name = "idx'evil".to_string();
677
678 assert!(index.to_exists_sql().contains("idx''evil"));
679 assert!(index.to_size_sql().contains("idx''evil"));
680 }
681
682 #[test]
683 fn test_empty_name_error() {
684 let result = VectorIndex::hnsw("", "docs", "emb").build();
685 assert!(result.is_err());
686 }
687
688 #[test]
689 fn test_hnsw_high_recall() {
690 let config = HnswConfig::high_recall();
691 assert_eq!(config.m, Some(32));
692 assert_eq!(config.ef_construction, Some(128));
693 }
694
695 #[test]
696 fn test_hnsw_fast_build() {
697 let config = HnswConfig::fast_build();
698 assert_eq!(config.m, Some(8));
699 assert_eq!(config.ef_construction, Some(32));
700 }
701
702 #[test]
703 fn test_binary_vector_index() {
704 let index = BinaryVectorIndex::new("idx_bits", "docs", "binary_emb")
705 .metric(BinaryDistanceMetric::Hamming)
706 .build()
707 .unwrap();
708
709 let sql = index.to_create_sql();
710 assert!(sql.contains("USING hnsw"));
711 assert!(sql.contains("bit_hamming_ops"));
712 }
713
714 #[test]
715 fn test_extension_create_sql() {
716 assert_eq!(
717 extension::create_extension_sql(),
718 "CREATE EXTENSION IF NOT EXISTS vector"
719 );
720 }
721
722 #[test]
723 fn test_extension_in_schema() {
724 let sql = extension::create_extension_in_schema_sql("public");
725 assert!(sql.contains("SCHEMA public"));
726 }
727
728 #[test]
729 fn test_add_vector_column() {
730 let sql = extension::add_vector_column_sql("documents", "embedding", 1536);
731 assert_eq!(
732 sql,
733 "ALTER TABLE documents ADD COLUMN embedding vector(1536)"
734 );
735 }
736
737 #[test]
738 fn test_add_sparsevec_column() {
739 let sql = extension::add_sparsevec_column_sql("documents", "sparse_emb", 30000);
740 assert!(sql.contains("sparsevec(30000)"));
741 }
742
743 #[test]
744 fn test_add_bit_column() {
745 let sql = extension::add_bit_column_sql("documents", "binary_emb", 1024);
746 assert!(sql.contains("bit(1024)"));
747 }
748
749 #[test]
750 fn test_check_extension_sql() {
751 let sql = extension::check_extension_sql();
752 assert!(sql.contains("pg_extension"));
753 }
754
755 #[test]
756 fn test_version_sql() {
757 let sql = extension::version_sql();
758 assert!(sql.contains("extversion"));
759 }
760
761 #[test]
762 fn test_index_type_display() {
763 let ivf = IndexType::IvfFlat(IvfFlatConfig::default());
764 assert_eq!(format!("{ivf}"), "ivfflat");
765
766 let hnsw = IndexType::Hnsw(HnswConfig::default());
767 assert_eq!(format!("{hnsw}"), "hnsw");
768 }
769
770 #[test]
771 fn test_all_metrics_with_ivfflat() {
772 for metric in [
773 DistanceMetric::L2,
774 DistanceMetric::InnerProduct,
775 DistanceMetric::Cosine,
776 DistanceMetric::L1,
777 ] {
778 let index = VectorIndex::ivfflat("idx", "t", "c")
779 .metric(metric)
780 .build()
781 .unwrap();
782 let sql = index.to_create_sql();
783 assert!(sql.contains(metric.ops_class()));
784 }
785 }
786
787 #[test]
788 fn test_all_metrics_with_hnsw() {
789 for metric in [
790 DistanceMetric::L2,
791 DistanceMetric::InnerProduct,
792 DistanceMetric::Cosine,
793 DistanceMetric::L1,
794 ] {
795 let index = VectorIndex::hnsw("idx", "t", "c")
796 .metric(metric)
797 .build()
798 .unwrap();
799 let sql = index.to_create_sql();
800 assert!(sql.contains(metric.ops_class()));
801 }
802 }
803}