Skip to main content

fraiseql_core/utils/
vector.rs

1//! Vector query builder for pgvector similarity search.
2//!
3//! This module provides SQL query generation for pgvector operations including:
4//! - Similarity search with configurable distance metrics
5//! - Vector insert and upsert operations
6//! - Proper parameter binding for vector data
7//!
8//! # Example
9//!
10//! ```
11//! use fraiseql_core::utils::vector::{VectorQueryBuilder, VectorSearchQuery};
12//! use fraiseql_core::schema::DistanceMetric;
13//!
14//! let builder = VectorQueryBuilder::new();
15//! let query = VectorSearchQuery {
16//!     table: "documents".to_string(),
17//!     embedding_column: "embedding".to_string(),
18//!     select_columns: vec!["id".to_string(), "content".to_string()],
19//!     distance_metric: DistanceMetric::Cosine,
20//!     limit: 10,
21//!     where_clause: None,
22//!     order_by: None,
23//!     include_distance: false,
24//!     offset: None,
25//! };
26//!
27//! let (sql, _params) = builder.similarity_search(&query, &[0.1, 0.2, 0.3]);
28//! assert!(sql.contains("documents"));
29//! ```
30
31use serde::{Deserialize, Serialize};
32
33use crate::schema::{DistanceMetric, VectorConfig};
34
35/// A SQL parameter value for vector queries.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[non_exhaustive]
38pub enum VectorParam {
39    /// A vector embedding (array of floats).
40    Vector(Vec<f32>),
41    /// An integer value.
42    Int(i64),
43    /// A string value.
44    String(String),
45    /// A JSON value.
46    Json(serde_json::Value),
47}
48
49impl VectorParam {
50    /// Convert to SQL literal string for debugging.
51    #[must_use]
52    pub fn to_sql_literal(&self) -> String {
53        match self {
54            VectorParam::Vector(v) => {
55                let values: Vec<String> = v.iter().map(std::string::ToString::to_string).collect();
56                format!("'[{}]'::vector", values.join(","))
57            },
58            VectorParam::Int(i) => i.to_string(),
59            VectorParam::String(s) => format!("'{}'", s.replace('\'', "''")),
60            VectorParam::Json(j) => format!("'{j}'::jsonb"),
61        }
62    }
63}
64
65/// Configuration for a similarity search query.
66#[derive(Debug, Clone)]
67pub struct VectorSearchQuery {
68    /// Table or view to query.
69    pub table:            String,
70    /// Column containing the vector embedding.
71    pub embedding_column: String,
72    /// Columns to select (empty = all).
73    pub select_columns:   Vec<String>,
74    /// Distance metric to use.
75    pub distance_metric:  DistanceMetric,
76    /// Maximum number of results.
77    pub limit:            u32,
78    /// Optional WHERE clause (without "WHERE" keyword).
79    pub where_clause:     Option<String>,
80    /// Optional additional ORDER BY clause (applied after distance ordering).
81    pub order_by:         Option<String>,
82    /// Whether to include the distance score in results.
83    pub include_distance: bool,
84    /// Optional offset for pagination.
85    pub offset:           Option<u32>,
86}
87
88impl Default for VectorSearchQuery {
89    fn default() -> Self {
90        Self {
91            table:            String::new(),
92            embedding_column: "embedding".to_string(),
93            select_columns:   Vec::new(),
94            distance_metric:  DistanceMetric::Cosine,
95            limit:            10,
96            where_clause:     None,
97            order_by:         None,
98            include_distance: false,
99            offset:           None,
100        }
101    }
102}
103
104impl VectorSearchQuery {
105    /// Create a new search query for a table.
106    pub fn new(table: impl Into<String>) -> Self {
107        Self {
108            table: table.into(),
109            ..Default::default()
110        }
111    }
112
113    /// Set the embedding column.
114    pub fn with_embedding_column(mut self, column: impl Into<String>) -> Self {
115        self.embedding_column = column.into();
116        self
117    }
118
119    /// Set the columns to select.
120    #[must_use = "builder method returns modified builder"]
121    pub fn with_select_columns(mut self, columns: Vec<String>) -> Self {
122        self.select_columns = columns;
123        self
124    }
125
126    /// Set the distance metric.
127    #[must_use = "builder method returns modified builder"]
128    pub const fn with_distance_metric(mut self, metric: DistanceMetric) -> Self {
129        self.distance_metric = metric;
130        self
131    }
132
133    /// Set the result limit.
134    #[must_use = "builder method returns modified builder"]
135    pub const fn with_limit(mut self, limit: u32) -> Self {
136        self.limit = limit;
137        self
138    }
139
140    /// Set a WHERE clause filter.
141    pub fn with_where(mut self, clause: impl Into<String>) -> Self {
142        self.where_clause = Some(clause.into());
143        self
144    }
145
146    /// Include distance score in results.
147    #[must_use = "builder method returns modified builder"]
148    pub const fn with_distance_score(mut self) -> Self {
149        self.include_distance = true;
150        self
151    }
152
153    /// Set pagination offset.
154    #[must_use = "builder method returns modified builder"]
155    pub const fn with_offset(mut self, offset: u32) -> Self {
156        self.offset = Some(offset);
157        self
158    }
159}
160
161/// Configuration for a vector insert/upsert operation.
162#[derive(Debug, Clone)]
163pub struct VectorInsertQuery {
164    /// Table to insert into.
165    pub table:            String,
166    /// Columns to insert (in order).
167    pub columns:          Vec<String>,
168    /// Name of the vector column.
169    pub vector_column:    String,
170    /// Whether to upsert (ON CONFLICT DO UPDATE).
171    pub upsert:           bool,
172    /// Conflict column(s) for upsert.
173    pub conflict_columns: Vec<String>,
174    /// Columns to update on conflict (empty = all non-conflict columns).
175    pub update_columns:   Vec<String>,
176    /// Whether to return inserted IDs.
177    pub returning:        Option<String>,
178}
179
180impl Default for VectorInsertQuery {
181    fn default() -> Self {
182        Self {
183            table:            String::new(),
184            columns:          Vec::new(),
185            vector_column:    "embedding".to_string(),
186            upsert:           false,
187            conflict_columns: vec!["id".to_string()],
188            update_columns:   Vec::new(),
189            returning:        Some("id".to_string()),
190        }
191    }
192}
193
194impl VectorInsertQuery {
195    /// Create a new insert query.
196    pub fn new(table: impl Into<String>) -> Self {
197        Self {
198            table: table.into(),
199            ..Default::default()
200        }
201    }
202
203    /// Set the columns to insert.
204    #[must_use = "builder method returns modified builder"]
205    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
206        self.columns = columns;
207        self
208    }
209
210    /// Set the vector column name.
211    pub fn with_vector_column(mut self, column: impl Into<String>) -> Self {
212        self.vector_column = column.into();
213        self
214    }
215
216    /// Enable upsert mode.
217    #[must_use = "builder method returns modified builder"]
218    pub fn with_upsert(mut self, conflict_columns: Vec<String>) -> Self {
219        self.upsert = true;
220        self.conflict_columns = conflict_columns;
221        self
222    }
223
224    /// Set columns to update on conflict.
225    #[must_use = "builder method returns modified builder"]
226    pub fn with_update_columns(mut self, columns: Vec<String>) -> Self {
227        self.update_columns = columns;
228        self
229    }
230
231    /// Set the RETURNING clause.
232    pub fn with_returning(mut self, column: impl Into<String>) -> Self {
233        self.returning = Some(column.into());
234        self
235    }
236}
237
238/// Builder for pgvector SQL queries.
239///
240/// This struct generates SQL for vector similarity search and manipulation
241/// operations using `PostgreSQL`'s `pgvector` extension.
242#[must_use = "call .build() to construct the final value"]
243#[derive(Debug, Clone, Default)]
244pub struct VectorQueryBuilder {
245    /// Parameter placeholder style ($1 vs ?).
246    placeholder_style: PlaceholderStyle,
247}
248
249/// Style of parameter placeholders in generated SQL.
250#[derive(Debug, Clone, Copy, Default)]
251#[non_exhaustive]
252pub enum PlaceholderStyle {
253    /// `PostgreSQL` style: `$1`, `$2`, `$3`
254    #[default]
255    Dollar,
256    /// MySQL/SQLite style: `?`, `?`, `?`
257    QuestionMark,
258}
259
260impl VectorQueryBuilder {
261    /// Create a new vector query builder.
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Create a builder with question mark placeholders.
267    pub const fn with_question_marks() -> Self {
268        Self {
269            placeholder_style: PlaceholderStyle::QuestionMark,
270        }
271    }
272
273    /// Generate a parameter placeholder.
274    fn placeholder(&self, index: usize) -> String {
275        match self.placeholder_style {
276            PlaceholderStyle::Dollar => format!("${index}"),
277            PlaceholderStyle::QuestionMark => "?".to_string(),
278        }
279    }
280
281    /// Build a similarity search query.
282    ///
283    /// Generates SQL like:
284    /// ```sql
285    /// SELECT id, content, (embedding <=> $1::vector) AS distance
286    /// FROM documents
287    /// WHERE metadata->>'type' = 'article'
288    /// ORDER BY embedding <=> $1::vector
289    /// LIMIT 10
290    /// ```
291    ///
292    /// # Arguments
293    /// * `query` - The search query configuration
294    /// * `query_embedding` - The embedding vector to search for
295    ///
296    /// # Returns
297    /// A tuple of (SQL string, parameter values)
298    #[must_use]
299    pub fn similarity_search(
300        &self,
301        query: &VectorSearchQuery,
302        query_embedding: &[f32],
303    ) -> (String, Vec<VectorParam>) {
304        let mut params = Vec::new();
305        let mut param_idx = 1;
306
307        // Add the query embedding as the first parameter
308        params.push(VectorParam::Vector(query_embedding.to_vec()));
309        let embedding_placeholder = format!("{}::vector", self.placeholder(param_idx));
310        param_idx += 1;
311
312        let distance_op = query.distance_metric.operator();
313
314        // Build SELECT clause
315        let select_clause = if query.select_columns.is_empty() {
316            if query.include_distance {
317                format!(
318                    "*, ({} {} {}) AS distance",
319                    query.embedding_column, distance_op, embedding_placeholder
320                )
321            } else {
322                "*".to_string()
323            }
324        } else {
325            let cols = query.select_columns.join(", ");
326            if query.include_distance {
327                format!(
328                    "{}, ({} {} {}) AS distance",
329                    cols, query.embedding_column, distance_op, embedding_placeholder
330                )
331            } else {
332                cols
333            }
334        };
335
336        // Build WHERE clause
337        let where_clause = if let Some(ref clause) = query.where_clause {
338            format!("\nWHERE {clause}")
339        } else {
340            String::new()
341        };
342
343        // Build ORDER BY clause (always order by distance for similarity search)
344        let order_clause = format!(
345            "\nORDER BY {} {} {}",
346            query.embedding_column, distance_op, embedding_placeholder
347        );
348
349        // Build LIMIT clause
350        let limit_clause = format!("\nLIMIT {}", self.placeholder(param_idx));
351        params.push(VectorParam::Int(i64::from(query.limit)));
352        param_idx += 1;
353
354        // Build OFFSET clause
355        let offset_clause = if let Some(offset) = query.offset {
356            let clause = format!("\nOFFSET {}", self.placeholder(param_idx));
357            params.push(VectorParam::Int(i64::from(offset)));
358            clause
359        } else {
360            String::new()
361        };
362
363        let sql = format!(
364            "SELECT {}\nFROM {}{}{}{}{}",
365            select_clause, query.table, where_clause, order_clause, limit_clause, offset_clause
366        );
367
368        (sql, params)
369    }
370
371    /// Build a single vector insert query.
372    ///
373    /// Generates SQL like:
374    /// ```sql
375    /// INSERT INTO documents (id, content, embedding)
376    /// VALUES ($1, $2, $3::vector)
377    /// RETURNING id
378    /// ```
379    #[must_use]
380    pub fn insert_one(
381        &self,
382        query: &VectorInsertQuery,
383        values: &[VectorParam],
384    ) -> (String, Vec<VectorParam>) {
385        let columns = query.columns.join(", ");
386
387        let placeholders: Vec<String> = values
388            .iter()
389            .enumerate()
390            .map(|(i, v)| {
391                let ph = self.placeholder(i + 1);
392                if matches!(v, VectorParam::Vector(_)) {
393                    format!("{ph}::vector")
394                } else {
395                    ph
396                }
397            })
398            .collect();
399
400        let values_clause = placeholders.join(", ");
401
402        let returning_clause = if let Some(ref col) = query.returning {
403            format!("\nRETURNING {col}")
404        } else {
405            String::new()
406        };
407
408        let sql = if query.upsert {
409            let conflict_cols = query.conflict_columns.join(", ");
410
411            // Determine which columns to update
412            let update_cols: Vec<&String> = if query.update_columns.is_empty() {
413                // Update all non-conflict columns
414                query.columns.iter().filter(|c| !query.conflict_columns.contains(c)).collect()
415            } else {
416                query.update_columns.iter().collect()
417            };
418
419            let update_clause: String = update_cols
420                .iter()
421                .map(|c| format!("{c} = EXCLUDED.{c}"))
422                .collect::<Vec<_>>()
423                .join(", ");
424
425            format!(
426                "INSERT INTO {} ({})\nVALUES ({})\nON CONFLICT ({}) DO UPDATE SET {}{}",
427                query.table, columns, values_clause, conflict_cols, update_clause, returning_clause
428            )
429        } else {
430            format!(
431                "INSERT INTO {} ({})\nVALUES ({}){}",
432                query.table, columns, values_clause, returning_clause
433            )
434        };
435
436        (sql, values.to_vec())
437    }
438
439    /// Build a batch vector insert query.
440    ///
441    /// Generates SQL like:
442    /// ```sql
443    /// INSERT INTO documents (id, content, embedding)
444    /// VALUES
445    ///   ($1, $2, $3::vector),
446    ///   ($4, $5, $6::vector),
447    ///   ($7, $8, $9::vector)
448    /// RETURNING id
449    /// ```
450    #[must_use]
451    pub fn insert_batch(
452        &self,
453        query: &VectorInsertQuery,
454        rows: &[Vec<VectorParam>],
455    ) -> (String, Vec<VectorParam>) {
456        if rows.is_empty() {
457            return (String::new(), Vec::new());
458        }
459
460        let columns = query.columns.join(", ");
461        let cols_per_row = query.columns.len();
462
463        let mut all_params = Vec::new();
464        let mut values_clauses = Vec::new();
465
466        for (row_idx, row) in rows.iter().enumerate() {
467            let base_idx = row_idx * cols_per_row + 1;
468            let placeholders: Vec<String> = row
469                .iter()
470                .enumerate()
471                .map(|(i, v)| {
472                    let ph = self.placeholder(base_idx + i);
473                    if matches!(v, VectorParam::Vector(_)) {
474                        format!("{ph}::vector")
475                    } else {
476                        ph
477                    }
478                })
479                .collect();
480
481            values_clauses.push(format!("({})", placeholders.join(", ")));
482            all_params.extend(row.clone());
483        }
484
485        let returning_clause = if let Some(ref col) = query.returning {
486            format!("\nRETURNING {col}")
487        } else {
488            String::new()
489        };
490
491        let sql = format!(
492            "INSERT INTO {} ({})\nVALUES\n  {}{}",
493            query.table,
494            columns,
495            values_clauses.join(",\n  "),
496            returning_clause
497        );
498
499        (sql, all_params)
500    }
501
502    /// Build a query to create a vector index.
503    ///
504    /// Generates SQL like:
505    /// ```sql
506    /// CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
507    /// ```
508    #[must_use]
509    pub fn create_index(&self, config: &VectorConfig, table: &str, column: &str) -> Option<String> {
510        config.index_type.index_sql(table, column, config.distance_metric)
511    }
512}