Skip to main content

daimon_plugin_pgvector/
store.rs

1//! [`PgVectorStore`] — a pgvector-backed [`VectorStore`] implementation.
2
3use std::collections::HashMap;
4
5use daimon_core::vector_store::VectorStore;
6use daimon_core::{DaimonError, Document, Result, ScoredDocument};
7use deadpool_postgres::Pool;
8use pgvector::Vector;
9
10use crate::DistanceMetric;
11
12/// A [`VectorStore`] backed by PostgreSQL with the pgvector extension.
13///
14/// Use [`PgVectorStoreBuilder`](crate::PgVectorStoreBuilder) to construct.
15///
16/// Statements are prepared through deadpool's per-connection statement cache
17/// (`prepare_cached`), so the constant upsert/query/delete/count SQL is
18/// parsed and planned once per pooled connection instead of per call.
19pub struct PgVectorStore {
20    pub(crate) pool: Pool,
21    pub(crate) table: String,
22    pub(crate) dimensions: usize,
23    pub(crate) distance_metric: DistanceMetric,
24}
25
26impl PgVectorStore {
27    /// Returns the distance operator used in SQL ORDER BY clauses.
28    fn distance_operator(&self) -> &'static str {
29        match self.distance_metric {
30            DistanceMetric::Cosine => "<=>",
31            DistanceMetric::L2 => "<->",
32            DistanceMetric::InnerProduct => "<#>",
33        }
34    }
35
36    /// Builds the SQL expression that converts pgvector's distance operator
37    /// (`$1` is the query vector) into a similarity score.
38    ///
39    /// See [`VectorStore::query`] for the rationale behind each transform.
40    /// L2 uses `1 / (1 + distance)` so the score stays in `(0, 1]` and
41    /// monotonic; a naive `1 - distance` would be unbounded and go negative.
42    fn score_expr(&self) -> String {
43        let op = self.distance_operator();
44        match self.distance_metric {
45            DistanceMetric::Cosine => format!("1.0 - (embedding {op} $1)"),
46            DistanceMetric::L2 => format!("1.0 / (1.0 + (embedding {op} $1))"),
47            DistanceMetric::InnerProduct => format!("-(embedding {op} $1)"),
48        }
49    }
50
51    /// Returns a reference to the underlying connection pool.
52    pub fn pool(&self) -> &Pool {
53        &self.pool
54    }
55
56    /// Returns the table name used by this store.
57    pub fn table(&self) -> &str {
58        &self.table
59    }
60
61    /// Returns the configured vector dimensions.
62    pub fn dimensions(&self) -> usize {
63        self.dimensions
64    }
65}
66
67/// Rows per multi-row `INSERT`: Postgres caps statements at 65535 bind
68/// parameters and each row binds 4 (id, embedding, content, metadata).
69const MAX_ROWS_PER_INSERT: usize = 65_535 / 4;
70
71/// Builds the multi-row upsert statement for `rows` rows.
72fn multi_upsert_sql(table: &str, rows: usize) -> String {
73    let mut values = String::with_capacity(rows * 24);
74    for i in 0..rows {
75        if i > 0 {
76            values.push_str(", ");
77        }
78        let base = i * 4;
79        values.push_str(&format!(
80            "(${}, ${}, ${}, ${})",
81            base + 1,
82            base + 2,
83            base + 3,
84            base + 4
85        ));
86    }
87    format!(
88        "INSERT INTO {table} (id, embedding, content, metadata) VALUES {values} \
89         ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding, \
90         content = EXCLUDED.content, metadata = EXCLUDED.metadata"
91    )
92}
93
94impl VectorStore for PgVectorStore {
95    async fn upsert(&self, id: &str, embedding: Vec<f32>, document: Document) -> Result<()> {
96        if embedding.len() != self.dimensions {
97            return Err(DaimonError::Other(format!(
98                "embedding dimension mismatch: expected {}, got {}",
99                self.dimensions,
100                embedding.len()
101            )));
102        }
103
104        let client = self
105            .pool
106            .get()
107            .await
108            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
109
110        let vec = Vector::from(embedding);
111        let metadata = serde_json::to_value(&document.metadata)
112            .map_err(|e| DaimonError::Other(format!("metadata serialization error: {e}")))?;
113
114        let sql = format!(
115            "INSERT INTO {} (id, embedding, content, metadata) VALUES ($1, $2, $3, $4) \
116             ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding, \
117             content = EXCLUDED.content, metadata = EXCLUDED.metadata",
118            self.table
119        );
120
121        let stmt = client
122            .prepare_cached(&sql)
123            .await
124            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
125        client
126            .execute(&stmt, &[&id, &vec, &document.content, &metadata])
127            .await
128            .map_err(|e| DaimonError::Other(format!("pgvector upsert error: {e}")))?;
129
130        Ok(())
131    }
132
133    async fn upsert_many(&self, items: Vec<(String, Vec<f32>, Document)>) -> Result<()> {
134        if items.is_empty() {
135            return Ok(());
136        }
137        for (_, embedding, _) in &items {
138            if embedding.len() != self.dimensions {
139                return Err(DaimonError::Other(format!(
140                    "embedding dimension mismatch: expected {}, got {}",
141                    self.dimensions,
142                    embedding.len()
143                )));
144            }
145        }
146
147        // Postgres rejects duplicate ids within one ON CONFLICT statement
148        // ("cannot affect row a second time"), so dedupe keeping the last
149        // occurrence — the same outcome the sequential upsert loop produced.
150        let mut index: HashMap<String, usize> = HashMap::with_capacity(items.len());
151        let mut rows: Vec<(String, Vector, String, serde_json::Value)> =
152            Vec::with_capacity(items.len());
153        for (id, embedding, document) in items {
154            let metadata = serde_json::to_value(&document.metadata)
155                .map_err(|e| DaimonError::Other(format!("metadata serialization error: {e}")))?;
156            let row = (id, Vector::from(embedding), document.content, metadata);
157            match index.entry(row.0.clone()) {
158                std::collections::hash_map::Entry::Occupied(e) => rows[*e.get()] = row,
159                std::collections::hash_map::Entry::Vacant(e) => {
160                    e.insert(rows.len());
161                    rows.push(row);
162                }
163            }
164        }
165
166        let client = self
167            .pool
168            .get()
169            .await
170            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
171
172        for chunk in rows.chunks(MAX_ROWS_PER_INSERT) {
173            let sql = multi_upsert_sql(&self.table, chunk.len());
174            let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
175                Vec::with_capacity(chunk.len() * 4);
176            for (id, vec, content, metadata) in chunk {
177                params.push(id);
178                params.push(vec);
179                params.push(content);
180                params.push(metadata);
181            }
182            let stmt = client
183                .prepare_cached(&sql)
184                .await
185                .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
186            client
187                .execute(&stmt, &params)
188                .await
189                .map_err(|e| DaimonError::Other(format!("pgvector upsert error: {e}")))?;
190        }
191
192        Ok(())
193    }
194
195    async fn query(&self, embedding: Vec<f32>, top_k: usize) -> Result<Vec<ScoredDocument>> {
196        if embedding.len() != self.dimensions {
197            return Err(DaimonError::Other(format!(
198                "embedding dimension mismatch: expected {}, got {}",
199                self.dimensions,
200                embedding.len()
201            )));
202        }
203
204        let client = self
205            .pool
206            .get()
207            .await
208            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
209
210        let vec = Vector::from(embedding);
211        let op = self.distance_operator();
212        let score_expr = self.score_expr();
213
214        let sql = format!(
215            "SELECT id, content, metadata, {score_expr} AS score \
216             FROM {} ORDER BY embedding {op} $1 LIMIT $2",
217            self.table
218        );
219
220        let stmt = client
221            .prepare_cached(&sql)
222            .await
223            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
224        let rows = client
225            .query(&stmt, &[&vec, &(top_k as i64)])
226            .await
227            .map_err(|e| DaimonError::Other(format!("pgvector query error: {e}")))?;
228
229        let mut results = Vec::with_capacity(rows.len());
230        for row in rows {
231            let content: String = row.get("content");
232            let metadata_val: serde_json::Value = row.get("metadata");
233            let score: f64 = row.get("score");
234
235            let metadata: HashMap<String, serde_json::Value> =
236                serde_json::from_value(metadata_val).unwrap_or_default();
237
238            let doc = Document {
239                content,
240                metadata,
241                score: Some(score),
242            };
243            results.push(ScoredDocument::new(doc, score));
244        }
245
246        Ok(results)
247    }
248
249    async fn delete(&self, id: &str) -> Result<bool> {
250        let client = self
251            .pool
252            .get()
253            .await
254            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
255
256        let sql = format!("DELETE FROM {} WHERE id = $1", self.table);
257        let stmt = client
258            .prepare_cached(&sql)
259            .await
260            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
261        let deleted = client
262            .execute(&stmt, &[&id])
263            .await
264            .map_err(|e| DaimonError::Other(format!("pgvector delete error: {e}")))?;
265
266        Ok(deleted > 0)
267    }
268
269    async fn count(&self) -> Result<usize> {
270        let client = self
271            .pool
272            .get()
273            .await
274            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
275
276        let sql = format!("SELECT COUNT(*) AS cnt FROM {}", self.table);
277        let stmt = client
278            .prepare_cached(&sql)
279            .await
280            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
281        let row = client
282            .query_one(&stmt, &[])
283            .await
284            .map_err(|e| DaimonError::Other(format!("pgvector count error: {e}")))?;
285
286        let count: i64 = row.get("cnt");
287        Ok(count as usize)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_distance_operator() {
297        let store = PgVectorStore {
298            pool: create_dummy_pool(),
299            table: "t".into(),
300            dimensions: 3,
301            distance_metric: DistanceMetric::Cosine,
302        };
303        assert_eq!(store.distance_operator(), "<=>");
304
305        let store = PgVectorStore {
306            dimensions: 3,
307            distance_metric: DistanceMetric::L2,
308            ..store
309        };
310        assert_eq!(store.distance_operator(), "<->");
311
312        let store = PgVectorStore {
313            distance_metric: DistanceMetric::InnerProduct,
314            ..store
315        };
316        assert_eq!(store.distance_operator(), "<#>");
317    }
318
319    #[test]
320    fn test_score_expr_per_metric() {
321        let base = PgVectorStore {
322            pool: create_dummy_pool(),
323            table: "t".into(),
324            dimensions: 3,
325            distance_metric: DistanceMetric::Cosine,
326        };
327        assert_eq!(base.score_expr(), "1.0 - (embedding <=> $1)");
328
329        let l2 = PgVectorStore {
330            distance_metric: DistanceMetric::L2,
331            ..base
332        };
333        // L2 must use the bounded transform, not `1 - distance`.
334        assert_eq!(l2.score_expr(), "1.0 / (1.0 + (embedding <-> $1))");
335
336        let ip = PgVectorStore {
337            distance_metric: DistanceMetric::InnerProduct,
338            ..l2
339        };
340        assert_eq!(ip.score_expr(), "-(embedding <#> $1)");
341    }
342
343    #[test]
344    fn test_multi_upsert_sql_placeholders() {
345        let one = multi_upsert_sql("t", 1);
346        assert!(one.contains("VALUES ($1, $2, $3, $4) ON CONFLICT"));
347
348        let two = multi_upsert_sql("t", 2);
349        assert!(two.contains("VALUES ($1, $2, $3, $4), ($5, $6, $7, $8) ON CONFLICT"));
350
351        // The largest chunk stays under Postgres's 65535-parameter cap.
352        let max = multi_upsert_sql("t", MAX_ROWS_PER_INSERT);
353        assert!(max.contains(&format!("${}", MAX_ROWS_PER_INSERT * 4)));
354        assert!(!max.contains(&format!("${}", MAX_ROWS_PER_INSERT * 4 + 1)));
355        const { assert!(MAX_ROWS_PER_INSERT * 4 <= 65_535) };
356    }
357
358    fn create_dummy_pool() -> Pool {
359        let cfg = deadpool_postgres::Config {
360            host: Some("localhost".into()),
361            port: Some(5432),
362            dbname: Some("test".into()),
363            ..Default::default()
364        };
365        cfg.create_pool(None, tokio_postgres::NoTls).unwrap()
366    }
367}