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 id: String = row.try_get("id").map_err(|e| {
232                DaimonError::Other(format!("pgvector row missing/invalid id column: {e}"))
233            })?;
234            let content: String = row.try_get("content").map_err(|e| {
235                DaimonError::Other(format!("pgvector row missing/invalid content column: {e}"))
236            })?;
237            let metadata_val: serde_json::Value = row.try_get("metadata").map_err(|e| {
238                DaimonError::Other(format!("pgvector row missing/invalid metadata column: {e}"))
239            })?;
240            let score: f64 = row.try_get("score").map_err(|e| {
241                DaimonError::Other(format!("pgvector row missing/invalid score column: {e}"))
242            })?;
243
244            let metadata: HashMap<String, serde_json::Value> =
245                serde_json::from_value(metadata_val).unwrap_or_default();
246
247            let doc = Document {
248                content,
249                metadata,
250                score: Some(score),
251            };
252            results.push(ScoredDocument::new(id, doc, score));
253        }
254
255        Ok(results)
256    }
257
258    async fn delete(&self, id: &str) -> Result<bool> {
259        let client = self
260            .pool
261            .get()
262            .await
263            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
264
265        let sql = format!("DELETE FROM {} WHERE id = $1", self.table);
266        let stmt = client
267            .prepare_cached(&sql)
268            .await
269            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
270        let deleted = client
271            .execute(&stmt, &[&id])
272            .await
273            .map_err(|e| DaimonError::Other(format!("pgvector delete error: {e}")))?;
274
275        Ok(deleted > 0)
276    }
277
278    async fn count(&self) -> Result<usize> {
279        let client = self
280            .pool
281            .get()
282            .await
283            .map_err(|e| DaimonError::Other(format!("pgvector pool error: {e}")))?;
284
285        let sql = format!("SELECT COUNT(*) AS cnt FROM {}", self.table);
286        let stmt = client
287            .prepare_cached(&sql)
288            .await
289            .map_err(|e| DaimonError::Other(format!("pgvector prepare error: {e}")))?;
290        let row = client
291            .query_one(&stmt, &[])
292            .await
293            .map_err(|e| DaimonError::Other(format!("pgvector count error: {e}")))?;
294
295        let count: i64 = row.get("cnt");
296        Ok(count as usize)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_distance_operator() {
306        let store = PgVectorStore {
307            pool: create_dummy_pool(),
308            table: "t".into(),
309            dimensions: 3,
310            distance_metric: DistanceMetric::Cosine,
311        };
312        assert_eq!(store.distance_operator(), "<=>");
313
314        let store = PgVectorStore {
315            dimensions: 3,
316            distance_metric: DistanceMetric::L2,
317            ..store
318        };
319        assert_eq!(store.distance_operator(), "<->");
320
321        let store = PgVectorStore {
322            distance_metric: DistanceMetric::InnerProduct,
323            ..store
324        };
325        assert_eq!(store.distance_operator(), "<#>");
326    }
327
328    #[test]
329    fn test_score_expr_per_metric() {
330        let base = PgVectorStore {
331            pool: create_dummy_pool(),
332            table: "t".into(),
333            dimensions: 3,
334            distance_metric: DistanceMetric::Cosine,
335        };
336        assert_eq!(base.score_expr(), "1.0 - (embedding <=> $1)");
337
338        let l2 = PgVectorStore {
339            distance_metric: DistanceMetric::L2,
340            ..base
341        };
342        // L2 must use the bounded transform, not `1 - distance`.
343        assert_eq!(l2.score_expr(), "1.0 / (1.0 + (embedding <-> $1))");
344
345        let ip = PgVectorStore {
346            distance_metric: DistanceMetric::InnerProduct,
347            ..l2
348        };
349        assert_eq!(ip.score_expr(), "-(embedding <#> $1)");
350    }
351
352    #[test]
353    fn test_multi_upsert_sql_placeholders() {
354        let one = multi_upsert_sql("t", 1);
355        assert!(one.contains("VALUES ($1, $2, $3, $4) ON CONFLICT"));
356
357        let two = multi_upsert_sql("t", 2);
358        assert!(two.contains("VALUES ($1, $2, $3, $4), ($5, $6, $7, $8) ON CONFLICT"));
359
360        // The largest chunk stays under Postgres's 65535-parameter cap.
361        let max = multi_upsert_sql("t", MAX_ROWS_PER_INSERT);
362        assert!(max.contains(&format!("${}", MAX_ROWS_PER_INSERT * 4)));
363        assert!(!max.contains(&format!("${}", MAX_ROWS_PER_INSERT * 4 + 1)));
364        const { assert!(MAX_ROWS_PER_INSERT * 4 <= 65_535) };
365    }
366
367    fn create_dummy_pool() -> Pool {
368        let cfg = deadpool_postgres::Config {
369            host: Some("localhost".into()),
370            port: Some(5432),
371            dbname: Some("test".into()),
372            ..Default::default()
373        };
374        cfg.create_pool(None, tokio_postgres::NoTls).unwrap()
375    }
376}