1use serde::{Deserialize, Serialize};
25
26use prax_query::sql::is_valid_sql_identifier;
27
28use crate::error::{VectorError, VectorResult};
29use crate::ops::{DistanceMetric, SearchParams};
30use crate::types::Embedding;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct VectorSearchQuery {
35 pub table: String,
37 pub column: String,
39 pub query_vector: Embedding,
41 pub metric: DistanceMetric,
43 pub limit: usize,
45 pub select_columns: Vec<String>,
47 pub where_clauses: Vec<String>,
49 pub include_distance: bool,
51 pub distance_alias: String,
53 pub max_distance: Option<f64>,
55 pub min_distance: Option<f64>,
57 pub extra_order_by: Vec<String>,
59 pub offset: Option<usize>,
61 pub search_params: SearchParams,
63}
64
65impl VectorSearchQuery {
66 pub fn to_sql(&self) -> String {
70 self.to_sql_with_param(1)
71 }
72
73 pub fn to_sql_with_param(&self, param_index: usize) -> String {
75 let param = format!("${param_index}");
76 let distance_expr = format!("{} {} {}", self.column, self.metric.operator(), param);
77
78 let select = if self.select_columns.is_empty() {
80 "*".to_string()
81 } else {
82 self.select_columns.join(", ")
83 };
84
85 let distance_select = if self.include_distance {
86 format!(", {} AS {}", distance_expr, self.distance_alias)
87 } else {
88 String::new()
89 };
90
91 let mut where_parts = Vec::new();
93
94 if let Some(max) = self.max_distance {
95 where_parts.push(format!("{distance_expr} < {max}"));
96 }
97 if let Some(min) = self.min_distance {
98 where_parts.push(format!("{distance_expr} >= {min}"));
99 }
100 where_parts.extend(self.where_clauses.clone());
101
102 let where_clause = if where_parts.is_empty() {
103 String::new()
104 } else {
105 format!(" WHERE {}", where_parts.join(" AND "))
106 };
107
108 let order_by_main = if self.include_distance {
110 self.distance_alias.clone()
111 } else {
112 distance_expr
113 };
114
115 let order_by = if self.extra_order_by.is_empty() {
116 order_by_main
117 } else {
118 let mut parts = vec![order_by_main];
119 parts.extend(self.extra_order_by.clone());
120 parts.join(", ")
121 };
122
123 let limit = format!(" LIMIT {}", self.limit);
125 let offset = self
126 .offset
127 .map(|o| format!(" OFFSET {o}"))
128 .unwrap_or_default();
129
130 format!(
131 "SELECT {}{} FROM {}{} ORDER BY {}{}{}",
132 select, distance_select, self.table, where_clause, order_by, limit, offset
133 )
134 }
135
136 pub fn param_set_sql(&self) -> Vec<String> {
140 self.search_params.to_set_sql()
141 }
142}
143
144pub struct VectorSearchBuilder {
160 table: String,
161 column: String,
162 query_vector: Option<Embedding>,
163 metric: DistanceMetric,
164 limit: usize,
165 select_columns: Vec<String>,
166 where_clauses: Vec<String>,
167 include_distance: bool,
168 distance_alias: String,
169 max_distance: Option<f64>,
170 min_distance: Option<f64>,
171 extra_order_by: Vec<String>,
172 offset: Option<usize>,
173 search_params: SearchParams,
174}
175
176impl VectorSearchBuilder {
177 pub fn new(table: impl Into<String>, column: impl Into<String>) -> Self {
179 Self {
180 table: table.into(),
181 column: column.into(),
182 query_vector: None,
183 metric: DistanceMetric::L2,
184 limit: 10,
185 select_columns: Vec::new(),
186 where_clauses: Vec::new(),
187 include_distance: true,
188 distance_alias: "distance".to_string(),
189 max_distance: None,
190 min_distance: None,
191 extra_order_by: Vec::new(),
192 offset: None,
193 search_params: SearchParams::new(),
194 }
195 }
196
197 pub fn query(mut self, embedding: Embedding) -> Self {
199 self.query_vector = Some(embedding);
200 self
201 }
202
203 pub fn metric(mut self, metric: DistanceMetric) -> Self {
205 self.metric = metric;
206 self
207 }
208
209 pub fn limit(mut self, limit: usize) -> Self {
211 self.limit = limit;
212 self
213 }
214
215 pub fn select(mut self, columns: &[&str]) -> Self {
217 self.select_columns = columns.iter().map(|c| (*c).to_string()).collect();
218 self
219 }
220
221 pub fn where_clause(mut self, condition: impl Into<String>) -> Self {
223 self.where_clauses.push(condition.into());
224 self
225 }
226
227 pub fn max_distance(mut self, distance: f64) -> Self {
229 self.max_distance = Some(distance);
230 self
231 }
232
233 pub fn min_distance(mut self, distance: f64) -> Self {
235 self.min_distance = Some(distance);
236 self
237 }
238
239 pub fn without_distance(mut self) -> Self {
241 self.include_distance = false;
242 self
243 }
244
245 pub fn distance_alias(mut self, alias: impl Into<String>) -> Self {
247 self.distance_alias = alias.into();
248 self
249 }
250
251 pub fn then_order_by(mut self, clause: impl Into<String>) -> Self {
253 self.extra_order_by.push(clause.into());
254 self
255 }
256
257 pub fn offset(mut self, offset: usize) -> Self {
259 self.offset = Some(offset);
260 self
261 }
262
263 pub fn probes(mut self, probes: usize) -> Self {
265 self.search_params = self.search_params.probes(probes);
266 self
267 }
268
269 pub fn ef_search(mut self, ef: usize) -> Self {
271 self.search_params = self.search_params.ef_search(ef);
272 self
273 }
274
275 pub fn build(self) -> VectorSearchQuery {
282 self.try_build()
283 .expect("query vector must be set before building")
284 }
285
286 pub fn try_build(self) -> Option<VectorSearchQuery> {
290 let query_vector = self.query_vector?;
291
292 Some(VectorSearchQuery {
293 table: self.table,
294 column: self.column,
295 query_vector,
296 metric: self.metric,
297 limit: self.limit,
298 select_columns: self.select_columns,
299 where_clauses: self.where_clauses,
300 include_distance: self.include_distance,
301 distance_alias: self.distance_alias,
302 max_distance: self.max_distance,
303 min_distance: self.min_distance,
304 extra_order_by: self.extra_order_by,
305 offset: self.offset,
306 search_params: self.search_params,
307 })
308 }
309}
310
311fn is_valid_ts_config_name(name: &str) -> bool {
314 is_valid_sql_identifier(name)
315}
316
317pub struct HybridSearchBuilder {
342 table: String,
343 vector_column: Option<String>,
344 text_column: Option<String>,
345 query_vector: Option<Embedding>,
346 query_text: Option<String>,
347 metric: DistanceMetric,
348 vector_weight: f64,
349 text_weight: f64,
350 limit: usize,
351 language: String,
352 where_clauses: Vec<String>,
353}
354
355impl HybridSearchBuilder {
356 pub fn new(table: impl Into<String>) -> Self {
358 Self {
359 table: table.into(),
360 vector_column: None,
361 text_column: None,
362 query_vector: None,
363 query_text: None,
364 metric: DistanceMetric::Cosine,
365 vector_weight: 0.5,
366 text_weight: 0.5,
367 limit: 10,
368 language: "english".to_string(),
369 where_clauses: Vec::new(),
370 }
371 }
372
373 pub fn vector_column(mut self, column: impl Into<String>) -> Self {
375 self.vector_column = Some(column.into());
376 self
377 }
378
379 pub fn text_column(mut self, column: impl Into<String>) -> Self {
381 self.text_column = Some(column.into());
382 self
383 }
384
385 pub fn query_vector(mut self, embedding: Embedding) -> Self {
387 self.query_vector = Some(embedding);
388 self
389 }
390
391 pub fn query_text(mut self, text: impl Into<String>) -> Self {
393 self.query_text = Some(text.into());
394 self
395 }
396
397 pub fn metric(mut self, metric: DistanceMetric) -> Self {
399 self.metric = metric;
400 self
401 }
402
403 pub fn vector_weight(mut self, weight: f64) -> Self {
405 self.vector_weight = weight;
406 self
407 }
408
409 pub fn text_weight(mut self, weight: f64) -> Self {
411 self.text_weight = weight;
412 self
413 }
414
415 pub fn limit(mut self, limit: usize) -> Self {
417 self.limit = limit;
418 self
419 }
420
421 pub fn language(mut self, language: impl Into<String>) -> Self {
435 let language = language.into();
436 assert!(
437 is_valid_ts_config_name(&language),
438 "invalid text search language {language:?}: must match ^[A-Za-z_][A-Za-z0-9_]*$"
439 );
440 self.language = language;
441 self
442 }
443
444 pub fn try_language(mut self, language: impl Into<String>) -> VectorResult<Self> {
450 let language = language.into();
451 if !is_valid_ts_config_name(&language) {
452 return Err(VectorError::config(format!(
453 "invalid text search language {language:?}: must match ^[A-Za-z_][A-Za-z0-9_]*$"
454 )));
455 }
456 self.language = language;
457 Ok(self)
458 }
459
460 pub fn where_clause(mut self, condition: impl Into<String>) -> Self {
462 self.where_clauses.push(condition.into());
463 self
464 }
465
466 pub fn build(self) -> HybridSearchQuery {
468 HybridSearchQuery {
469 table: self.table,
470 vector_column: self
471 .vector_column
472 .unwrap_or_else(|| "embedding".to_string()),
473 text_column: self.text_column.unwrap_or_else(|| "content".to_string()),
474 query_vector: self.query_vector,
475 query_text: self.query_text,
476 metric: self.metric,
477 vector_weight: self.vector_weight,
478 text_weight: self.text_weight,
479 limit: self.limit,
480 language: self.language,
481 where_clauses: self.where_clauses,
482 }
483 }
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct HybridSearchQuery {
494 pub table: String,
496 pub vector_column: String,
498 pub text_column: String,
500 pub query_vector: Option<Embedding>,
502 pub query_text: Option<String>,
504 pub metric: DistanceMetric,
506 pub vector_weight: f64,
508 pub text_weight: f64,
510 pub limit: usize,
512 pub language: String,
514 pub where_clauses: Vec<String>,
516}
517
518impl HybridSearchQuery {
519 pub fn to_sql(&self) -> String {
539 let lang = self.language.replace('\'', "''");
543 let vec_distance = format!("{} {} $1", self.vector_column, self.metric.operator());
544 let text_rank = format!(
545 "ts_rank(to_tsvector('{lang}', {}), plainto_tsquery('{lang}', $2))",
546 self.text_column
547 );
548 let fts_match = format!(
549 "to_tsvector('{lang}', {}) @@ plainto_tsquery('{lang}', $2)",
550 self.text_column
551 );
552
553 let where_clause = if self.where_clauses.is_empty() {
554 String::new()
555 } else {
556 format!(" WHERE {}", self.where_clauses.join(" AND "))
557 };
558
559 let text_where = if self.where_clauses.is_empty() {
562 format!(" WHERE {fts_match}")
563 } else {
564 format!("{where_clause} AND {fts_match}")
565 };
566
567 format!(
569 "WITH vector_results AS (\
570 SELECT *, ROW_NUMBER() OVER (ORDER BY {vec_distance}) AS vec_rank \
571 FROM {table}{where_clause} \
572 ORDER BY {vec_distance} \
573 LIMIT {fetch_limit}\
574 ), \
575 text_results AS (\
576 SELECT *, ROW_NUMBER() OVER (ORDER BY {text_rank} DESC) AS text_rank \
577 FROM {table}{text_where} \
578 ORDER BY {text_rank} DESC \
579 LIMIT {fetch_limit}\
580 ) \
581 SELECT COALESCE(v.*, t.*), \
582 ({vec_weight} / (60.0 + COALESCE(v.vec_rank, 1000))) + \
583 ({text_weight} / (60.0 + COALESCE(t.text_rank, 1000))) AS rrf_score \
584 FROM vector_results v \
585 FULL OUTER JOIN text_results t ON v.id = t.id \
586 ORDER BY rrf_score DESC \
587 LIMIT {limit}",
588 table = self.table,
589 where_clause = where_clause,
590 text_where = text_where,
591 fetch_limit = self.limit * 3, vec_weight = self.vector_weight,
593 text_weight = self.text_weight,
594 limit = self.limit,
595 )
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn test_embedding() -> Embedding {
604 Embedding::new(vec![0.1, 0.2, 0.3])
605 }
606
607 #[test]
608 fn test_basic_search_query() {
609 let query = VectorSearchBuilder::new("documents", "embedding")
610 .query(test_embedding())
611 .metric(DistanceMetric::Cosine)
612 .limit(10)
613 .build();
614
615 let sql = query.to_sql();
616 assert!(sql.contains("SELECT *"));
617 assert!(sql.contains("AS distance"));
618 assert!(sql.contains("<=>"));
619 assert!(sql.contains("$1"));
620 assert!(sql.contains("FROM documents"));
621 assert!(sql.contains("LIMIT 10"));
622 }
623
624 #[test]
625 fn test_search_with_select() {
626 let query = VectorSearchBuilder::new("documents", "embedding")
627 .query(test_embedding())
628 .select(&["id", "title"])
629 .build();
630
631 let sql = query.to_sql();
632 assert!(sql.contains("SELECT id, title"));
633 }
634
635 #[test]
636 fn test_search_with_where() {
637 let query = VectorSearchBuilder::new("documents", "embedding")
638 .query(test_embedding())
639 .where_clause("category = 'tech'")
640 .where_clause("published = true")
641 .build();
642
643 let sql = query.to_sql();
644 assert!(sql.contains("WHERE"));
645 assert!(sql.contains("category = 'tech'"));
646 assert!(sql.contains("published = true"));
647 assert!(sql.contains("AND"));
648 }
649
650 #[test]
651 fn test_search_with_max_distance() {
652 let query = VectorSearchBuilder::new("documents", "embedding")
653 .query(test_embedding())
654 .metric(DistanceMetric::L2)
655 .max_distance(0.5)
656 .build();
657
658 let sql = query.to_sql();
659 assert!(sql.contains("< 0.5"));
660 }
661
662 #[test]
663 fn test_search_with_distance_range() {
664 let query = VectorSearchBuilder::new("documents", "embedding")
665 .query(test_embedding())
666 .min_distance(0.1)
667 .max_distance(0.5)
668 .build();
669
670 let sql = query.to_sql();
671 assert!(sql.contains("< 0.5"));
672 assert!(sql.contains(">= 0.1"));
673 }
674
675 #[test]
676 fn test_search_without_distance() {
677 let query = VectorSearchBuilder::new("documents", "embedding")
678 .query(test_embedding())
679 .without_distance()
680 .build();
681
682 let sql = query.to_sql();
683 assert!(!sql.contains("AS distance"));
684 }
685
686 #[test]
687 fn test_search_custom_alias() {
688 let query = VectorSearchBuilder::new("documents", "embedding")
689 .query(test_embedding())
690 .distance_alias("similarity")
691 .build();
692
693 let sql = query.to_sql();
694 assert!(sql.contains("AS similarity"));
695 }
696
697 #[test]
698 fn test_search_with_pagination() {
699 let query = VectorSearchBuilder::new("documents", "embedding")
700 .query(test_embedding())
701 .limit(10)
702 .offset(20)
703 .build();
704
705 let sql = query.to_sql();
706 assert!(sql.contains("LIMIT 10"));
707 assert!(sql.contains("OFFSET 20"));
708 }
709
710 #[test]
711 fn test_search_with_extra_order_by() {
712 let query = VectorSearchBuilder::new("documents", "embedding")
713 .query(test_embedding())
714 .then_order_by("created_at DESC")
715 .build();
716
717 let sql = query.to_sql();
718 assert!(sql.contains("ORDER BY distance, created_at DESC"));
719 }
720
721 #[test]
722 fn test_search_params() {
723 let query = VectorSearchBuilder::new("documents", "embedding")
724 .query(test_embedding())
725 .probes(10)
726 .ef_search(200)
727 .build();
728
729 let set_sql = query.param_set_sql();
730 assert_eq!(set_sql.len(), 2);
731 assert!(set_sql[0].contains("ivfflat.probes = 10"));
732 assert!(set_sql[1].contains("hnsw.ef_search = 200"));
733 }
734
735 #[test]
736 fn test_try_build_without_vector() {
737 let result = VectorSearchBuilder::new("documents", "embedding").try_build();
738 assert!(result.is_none());
739 }
740
741 #[test]
742 fn test_custom_param_index() {
743 let query = VectorSearchBuilder::new("documents", "embedding")
744 .query(test_embedding())
745 .build();
746
747 let sql = query.to_sql_with_param(3);
748 assert!(sql.contains("$3"));
749 }
750
751 #[test]
752 fn test_hybrid_search() {
753 let query = HybridSearchBuilder::new("documents")
754 .vector_column("embedding")
755 .text_column("content")
756 .query_vector(test_embedding())
757 .query_text("machine learning")
758 .metric(DistanceMetric::Cosine)
759 .vector_weight(0.7)
760 .text_weight(0.3)
761 .limit(10)
762 .build();
763
764 let sql = query.to_sql();
765 assert!(sql.contains("vector_results"));
766 assert!(sql.contains("text_results"));
767 assert!(sql.contains("rrf_score"));
768 assert!(sql.contains("<=>"));
769 assert!(sql.contains("ts_rank"));
770 assert!(sql.contains("FULL OUTER JOIN"));
771 }
772
773 #[test]
774 fn test_hybrid_search_with_where_clause_single_where() {
775 let query = HybridSearchBuilder::new("documents")
776 .vector_column("embedding")
777 .text_column("content")
778 .query_vector(test_embedding())
779 .query_text("machine learning")
780 .where_clause("category = 'tech'")
781 .build();
782
783 let sql = query.to_sql();
784 assert_eq!(sql.matches("WHERE").count(), 2);
787 assert!(sql.contains(
788 "WHERE category = 'tech' AND to_tsvector('english', content) @@ plainto_tsquery('english', $2)"
789 ));
790 }
791
792 #[test]
793 #[should_panic(expected = "invalid text search language")]
794 fn test_hybrid_language_rejects_invalid() {
795 let _ = HybridSearchBuilder::new("documents").language("english'); DROP TABLE users; --");
796 }
797
798 #[test]
799 fn test_hybrid_try_language_returns_error_instead_of_panicking() {
800 let result =
801 HybridSearchBuilder::new("documents").try_language("english'); DROP TABLE users; --");
802 assert!(result.is_err(), "invalid language should be an error");
803
804 let builder = HybridSearchBuilder::new("documents")
805 .try_language("spanish")
806 .expect("valid language should build");
807 assert_eq!(builder.build().language, "spanish");
808 }
809
810 #[test]
811 fn test_hybrid_language_escaped_when_constructed_directly() {
812 let mut query = HybridSearchBuilder::new("documents").build();
815 query.language = "english'); DROP TABLE users; --".to_string();
816 let sql = query.to_sql();
817 assert!(sql.contains("'english''); DROP TABLE users; --'"));
818 assert!(!sql.contains("'english');"));
819 }
820
821 #[test]
822 fn test_all_metrics_produce_valid_sql() {
823 for metric in [
824 DistanceMetric::L2,
825 DistanceMetric::InnerProduct,
826 DistanceMetric::Cosine,
827 DistanceMetric::L1,
828 ] {
829 let query = VectorSearchBuilder::new("t", "c")
830 .query(test_embedding())
831 .metric(metric)
832 .build();
833 let sql = query.to_sql();
834 assert!(sql.contains(metric.operator()), "failed for {metric}");
835 }
836 }
837}