Skip to main content

rig_postgres/
lib.rs

1//! PostgreSQL and pgvector integration for Rig.
2//!
3//! This crate provides [`PostgresVectorStore`], a Rig vector store backed by a
4//! PostgreSQL table with a `pgvector` embedding column. It supports the distance
5//! functions represented by [`PgVectorDistanceFunction`] and query filters via
6//! [`PgSearchFilter`].
7//!
8//! The root `rig` facade re-exports this crate as `rig::postgres` when the
9//! `postgres` feature is enabled.
10
11use std::{fmt::Display, ops::RangeInclusive};
12
13use rig_core::{
14    Embed,
15    embeddings::{Embedding, EmbeddingModel},
16    vector_store::{
17        InsertDocuments, VectorStoreError, VectorStoreIndex,
18        request::{SearchFilter, SqlCondition, VectorSearchRequest},
19    },
20};
21use serde::{Deserialize, Serialize, de::DeserializeOwned};
22use serde_json::Value;
23use sqlx::{PgPool, Postgres, postgres::PgArguments, query::QueryAs};
24use uuid::Uuid;
25
26pub struct PostgresVectorStore<Model: EmbeddingModel> {
27    model: Model,
28    pg_pool: PgPool,
29    documents_table: String,
30    distance_function: PgVectorDistanceFunction,
31}
32
33/* PgVector supported distances
34<-> - L2 distance
35<#> - (negative) inner product
36<=> - cosine distance
37<+> - L1 distance (added in 0.7.0)
38<~> - Hamming distance (binary vectors, added in 0.7.0)
39<%> - Jaccard distance (binary vectors, added in 0.7.0)
40 */
41pub enum PgVectorDistanceFunction {
42    L2,
43    InnerProduct,
44    Cosine,
45    L1,
46    Hamming,
47    Jaccard,
48}
49
50impl Display for PgVectorDistanceFunction {
51    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
52        match self {
53            PgVectorDistanceFunction::L2 => write!(f, "<->"),
54            PgVectorDistanceFunction::InnerProduct => write!(f, "<#>"),
55            PgVectorDistanceFunction::Cosine => write!(f, "<=>"),
56            PgVectorDistanceFunction::L1 => write!(f, "<+>"),
57            PgVectorDistanceFunction::Hamming => write!(f, "<~>"),
58            PgVectorDistanceFunction::Jaccard => write!(f, "<%>"),
59        }
60    }
61}
62
63/// Placeholder token emitted for every bind parameter. `search_query` rewrites
64/// each occurrence into its numbered form (`$3`, `$4`, ...), so every constructor
65/// below must use this token and nothing else — a stray `?` would reach Postgres
66/// verbatim.
67const PLACEHOLDER: &str = "$";
68
69/// Postgres query filter: a `WHERE` fragment plus the values to bind to it.
70#[derive(Clone, Default, Serialize, Deserialize, Debug)]
71pub struct PgSearchFilter(SqlCondition<serde_json::Value>);
72
73impl SearchFilter for PgSearchFilter {
74    type Value = serde_json::Value;
75
76    fn eq(key: impl AsRef<str>, value: Self::Value) -> Self {
77        Self(SqlCondition::binary(key, "=", PLACEHOLDER, value))
78    }
79
80    fn gt(key: impl AsRef<str>, value: Self::Value) -> Self {
81        Self(SqlCondition::binary(key, ">", PLACEHOLDER, value))
82    }
83
84    fn lt(key: impl AsRef<str>, value: Self::Value) -> Self {
85        Self(SqlCondition::binary(key, "<", PLACEHOLDER, value))
86    }
87
88    fn and(self, rhs: Self) -> Self {
89        Self(self.0.and(rhs.0))
90    }
91
92    fn or(self, rhs: Self) -> Self {
93        Self(self.0.or(rhs.0))
94    }
95}
96
97impl PgSearchFilter {
98    fn into_clause(self) -> (String, Vec<serde_json::Value>) {
99        self.0.into_parts()
100    }
101
102    #[allow(clippy::should_implement_trait)]
103    pub fn not(self) -> Self {
104        Self(self.0.not())
105    }
106
107    pub fn gte(key: String, value: <Self as SearchFilter>::Value) -> Self {
108        Self(SqlCondition::binary(key, ">=", PLACEHOLDER, value))
109    }
110
111    pub fn lte(key: String, value: <Self as SearchFilter>::Value) -> Self {
112        Self(SqlCondition::binary(key, "<=", PLACEHOLDER, value))
113    }
114
115    pub fn is_null(key: String) -> Self {
116        Self(SqlCondition::raw(format!("{key} is null")))
117    }
118
119    pub fn is_not_null(key: String) -> Self {
120        Self(SqlCondition::raw(format!("{key} is not null")))
121    }
122
123    pub fn between<T>(key: String, range: RangeInclusive<T>) -> Self
124    where
125        T: std::fmt::Display + Into<serde_json::Number> + Copy,
126    {
127        let lo = range.start();
128        let hi = range.end();
129
130        Self(SqlCondition::raw(format!("{key} between {lo} and {hi}")))
131    }
132
133    pub fn member(key: String, values: Vec<<Self as SearchFilter>::Value>) -> Self {
134        Self(SqlCondition::list(key, "is in", PLACEHOLDER, values))
135    }
136
137    // String matching ops
138
139    /// Tests whether the value at `key` matches the (case-sensitive) pattern
140    /// `pattern` should be a valid SQL string pattern, with '%' and '_' as wildcards
141    pub fn like(key: String, pattern: &'static str) -> Self {
142        Self(SqlCondition::raw(format!("{key} like {pattern}")))
143    }
144
145    /// Tests whether the value at `key` matches the SQL regex pattern
146    /// `pattern` should be a valid regex
147    pub fn similar_to(key: String, pattern: &'static str) -> Self {
148        Self(SqlCondition::raw(format!("{key} similar to {pattern}")))
149    }
150}
151
152fn bind_value<S>(
153    builder: QueryAs<'_, Postgres, S, PgArguments>,
154    value: Value,
155) -> QueryAs<'_, Postgres, S, PgArguments> {
156    match value {
157        Value::Null => builder.bind(Option::<String>::None),
158        Value::Bool(b) => builder.bind(b),
159        Value::Number(num) => {
160            if let Some(n) = num.as_f64() {
161                builder.bind(n)
162            } else if let Some(n) = num.as_i64() {
163                builder.bind(n)
164            } else if let Some(n) = num.as_u64() {
165                builder.bind(n as i64)
166            } else {
167                builder.bind(num.to_string())
168            }
169        }
170        Value::String(s) => builder.bind(s),
171        Value::Array(xs) => {
172            if let Some(xs) = xs
173                .iter()
174                .map(|v| v.as_str().map(str::to_string))
175                .collect::<Option<Vec<_>>>()
176            {
177                builder.bind(xs)
178            } else if let Some(xs) = xs.iter().map(Value::as_f64).collect::<Option<Vec<_>>>() {
179                builder.bind(xs)
180            } else if let Some(xs) = xs.iter().map(Value::as_i64).collect::<Option<Vec<_>>>() {
181                builder.bind(xs)
182            } else if let Some(xs) = xs.iter().map(Value::as_bool).collect::<Option<Vec<_>>>() {
183                builder.bind(xs)
184            } else {
185                builder.bind(Value::Array(xs))
186            }
187        }
188        // Will always be JSONB
189        object => builder.bind(object),
190    }
191}
192
193#[derive(Debug, Deserialize, sqlx::FromRow)]
194pub struct SearchResult {
195    id: Uuid,
196    document: Value,
197    //embedded_text: String,
198    distance: f64,
199}
200
201#[derive(Debug, Deserialize, sqlx::FromRow)]
202pub struct SearchResultOnlyId {
203    id: Uuid,
204    distance: f64,
205}
206
207impl SearchResult {
208    pub fn into_result<T: DeserializeOwned>(self) -> Result<(f64, String, T), VectorStoreError> {
209        let document: T =
210            serde_json::from_value(self.document).map_err(VectorStoreError::JsonError)?;
211        Ok((self.distance, self.id.to_string(), document))
212    }
213}
214
215impl<Model> PostgresVectorStore<Model>
216where
217    Model: EmbeddingModel,
218{
219    pub fn new(
220        model: Model,
221        pg_pool: PgPool,
222        documents_table: Option<String>,
223        distance_function: PgVectorDistanceFunction,
224    ) -> Self {
225        Self {
226            model,
227            pg_pool,
228            documents_table: documents_table.unwrap_or(String::from("documents")),
229            distance_function,
230        }
231    }
232
233    pub fn with_defaults(model: Model, pg_pool: PgPool) -> Self {
234        Self::new(model, pg_pool, None, PgVectorDistanceFunction::Cosine)
235    }
236
237    /// Validates the sample count, embeds the query, and runs the similarity
238    /// search, returning one row per result.
239    async fn run_search<R>(
240        &self,
241        req: &VectorSearchRequest<PgSearchFilter>,
242        with_document: bool,
243    ) -> Result<Vec<R>, VectorStoreError>
244    where
245        R: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
246    {
247        if req.samples() > i64::MAX as u64 {
248            return Err(VectorStoreError::DatastoreError(
249                format!(
250                    "The maximum amount of samples to return with the `rig` Postgres integration cannot be larger than {}",
251                    i64::MAX
252                )
253                .into(),
254            ));
255        }
256
257        let embedded_query: pgvector::Vector = self
258            .model
259            .embed_text(req.query())
260            .await?
261            .vec
262            .iter()
263            .map(|&x| x as f32)
264            .collect::<Vec<f32>>()
265            .into();
266
267        let (search_query, params) = self.search_query(with_document, req);
268        let builder = sqlx::query_as(sqlx::AssertSqlSafe(search_query))
269            .bind(embedded_query)
270            .bind(req.samples() as i64);
271
272        let builder = params.iter().cloned().fold(builder, bind_value);
273
274        builder
275            .fetch_all(&self.pg_pool)
276            .await
277            .map_err(VectorStoreError::datastore)
278    }
279
280    fn search_query(
281        &self,
282        with_document: bool,
283        req: &VectorSearchRequest<PgSearchFilter>,
284    ) -> (String, Vec<serde_json::Value>) {
285        let document = if with_document { ", document" } else { "" };
286
287        let thresh = req
288            .threshold()
289            .map(|t| PgSearchFilter::gt("distance", t.into()));
290        let filter = match (thresh, req.filter()) {
291            (Some(thresh), Some(filt)) => Some(thresh.and(filt.clone())),
292            (Some(thresh), _) => Some(thresh),
293            (_, Some(filt)) => Some(filt.clone()),
294            _ => None,
295        };
296        let (where_clause, params) = match filter {
297            Some(f) => {
298                let (expr, params) = f.into_clause();
299                (String::from("WHERE") + &expr, params)
300            }
301            None => (Default::default(), Default::default()),
302        };
303
304        let mut counter = 3;
305        let mut buf = String::with_capacity(where_clause.len() * 2);
306
307        for c in where_clause.chars() {
308            buf.push(c);
309
310            if c == '$' {
311                buf.push_str(counter.to_string().as_str());
312                counter += 1;
313            }
314        }
315
316        let where_clause = buf;
317
318        let query = format!(
319            "
320            SELECT id{}, distance FROM ( \
321              SELECT DISTINCT ON (id) id{}, embedding {} $1 as distance \
322              FROM {} \
323              {where_clause} \
324              ORDER BY id, distance \
325            ) as d \
326            ORDER BY distance \
327            LIMIT $2",
328            document, document, self.distance_function, self.documents_table
329        );
330
331        (query, params)
332    }
333}
334
335impl<Model> InsertDocuments for PostgresVectorStore<Model>
336where
337    Model: EmbeddingModel + Send + Sync,
338{
339    async fn insert_documents<Doc: Serialize + Embed + Send>(
340        &self,
341        documents: Vec<(Doc, Vec<Embedding>)>,
342    ) -> Result<(), VectorStoreError> {
343        for (document, embeddings) in documents {
344            let id = Uuid::new_v4();
345            let json_document = serde_json::to_value(&document)?;
346
347            for embedding in embeddings {
348                let embedding_text = embedding.document;
349                let embedding: Vec<f64> = embedding.vec;
350
351                sqlx::query(sqlx::AssertSqlSafe(format!(
352                    "INSERT INTO {} (id, document, embedded_text, embedding) VALUES ($1, $2, $3, $4)",
353                    self.documents_table
354                )))
355                .bind(id)
356                .bind(&json_document)
357                .bind(&embedding_text)
358                .bind(&embedding)
359                .execute(&self.pg_pool)
360                .await
361                .map_err(VectorStoreError::datastore)?;
362            }
363        }
364
365        Ok(())
366    }
367}
368
369impl<Model> VectorStoreIndex for PostgresVectorStore<Model>
370where
371    Model: EmbeddingModel,
372{
373    type Filter = PgSearchFilter;
374
375    /// Get the top n documents based on the distance to the given query.
376    /// The result is a list of tuples of the form (score, id, document)
377    async fn top_n<T: for<'a> Deserialize<'a> + Send>(
378        &self,
379        req: VectorSearchRequest<PgSearchFilter>,
380    ) -> Result<Vec<(f64, String, T)>, VectorStoreError> {
381        let rows: Vec<SearchResult> = self.run_search(&req, true).await?;
382
383        let rows: Vec<(f64, String, T)> = rows
384            .into_iter()
385            .flat_map(SearchResult::into_result)
386            .collect();
387
388        Ok(rows)
389    }
390
391    /// Same as `top_n` but returns the document ids only.
392    async fn top_n_ids(
393        &self,
394        req: VectorSearchRequest<PgSearchFilter>,
395    ) -> Result<Vec<(f64, String)>, VectorStoreError> {
396        let rows: Vec<SearchResultOnlyId> = self.run_search(&req, false).await?;
397
398        let rows: Vec<(f64, String)> = rows
399            .into_iter()
400            .map(|row| (row.distance, row.id.to_string()))
401            .collect();
402
403        Ok(rows)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{PgSearchFilter, SearchFilter};
410    use serde_json::json;
411
412    /// `gte`/`lte`/`member` previously emitted `?` placeholders while
413    /// `eq`/`gt`/`lt` emitted `$`; the query renumbering only rewrites `$`, so
414    /// any `?` would reach Postgres verbatim and break the query.
415    #[test]
416    fn every_parameterised_operator_uses_dollar_placeholders() {
417        let gte = PgSearchFilter::gte("price".into(), json!(5));
418        let lte = PgSearchFilter::lte("price".into(), json!(10));
419
420        let (cond, values) = gte.and(lte).into_clause();
421        assert_eq!(cond, "(price >= $) AND (price <= $)");
422        assert!(!cond.contains('?'));
423        assert_eq!(cond.matches('$').count(), values.len());
424
425        let member = PgSearchFilter::member("id".into(), vec![json!(1), json!(2)]);
426        let (cond, values) = PgSearchFilter::eq("kind", json!("fruit"))
427            .and(member)
428            .into_clause();
429        assert_eq!(cond, "(kind = $) AND (id is in ($, $))");
430        assert!(!cond.contains('?'));
431        assert_eq!(cond.matches('$').count(), values.len());
432    }
433}