Skip to main content

rig_s3vectors/
lib.rs

1// Tests assert on filter shapes, where a failed conversion should panic loudly
2// rather than be handled; matches the other vector-store crates (e.g. rig-lancedb).
3#![cfg_attr(test, allow(clippy::expect_used))]
4//! AWS S3Vectors vector store integration for Rig.
5//!
6//! This crate provides [`S3VectorsVectorStore`], a Rig vector store backed by
7//! AWS S3Vectors indexes. It uses the AWS SDK client supplied by the caller and
8//! maps Rig search filters to S3Vectors filter documents through
9//! [`S3SearchFilter`].
10//!
11//! The root `rig` facade re-exports this crate as `rig::s3vectors` when the
12//! `s3vectors` feature is enabled.
13
14use aws_sdk_s3vectors::{
15    Client,
16    types::{PutInputVector, VectorData},
17};
18use aws_smithy_types::Document;
19use rig_core::{
20    embeddings::EmbeddingModel,
21    vector_store::{
22        InsertDocuments, VectorStoreError, VectorStoreIndex,
23        request::{DynamicSearchFilter, Filter, FilterError, SearchFilter, VectorSearchRequest},
24    },
25};
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use std::collections::HashMap;
29use uuid::Uuid;
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct CreateRecord {
33    document: serde_json::Value,
34    embedded_text: String,
35}
36
37/// S3Vectors filter backed by the AWS SDK's native document type.
38#[derive(Clone, Debug)]
39pub struct S3SearchFilter(aws_smithy_types::Document);
40
41impl SearchFilter for S3SearchFilter {
42    type Value = aws_smithy_types::Document;
43
44    fn eq(key: impl AsRef<str>, value: Self::Value) -> Self {
45        Self(document_comparison(key, "$eq", value))
46    }
47
48    fn gt(key: impl AsRef<str>, value: Self::Value) -> Self {
49        Self(document_comparison(key, "$gt", value))
50    }
51
52    fn lt(key: impl AsRef<str>, value: Self::Value) -> Self {
53        Self(document_comparison(key, "$lt", value))
54    }
55
56    fn and(self, rhs: Self) -> Self {
57        Self(document_object([(
58            "$and",
59            Document::Array(vec![self.0, rhs.0]),
60        )]))
61    }
62
63    fn or(self, rhs: Self) -> Self {
64        Self(document_object([(
65            "$or",
66            Document::Array(vec![self.0, rhs.0]),
67        )]))
68    }
69}
70
71/// Builds a `Document::Object` from the given entries.
72fn document_object<K>(entries: impl IntoIterator<Item = (K, Document)>) -> Document
73where
74    K: Into<String>,
75{
76    Document::Object(
77        entries
78            .into_iter()
79            .map(|(key, value)| (key.into(), value))
80            .collect(),
81    )
82}
83
84/// Builds the `{ key: { op: value } }` shape S3Vectors uses for comparison
85/// operators such as `$eq` and `$gt`.
86fn document_comparison(key: impl AsRef<str>, op: &str, value: Document) -> Document {
87    document_object([(key.as_ref(), document_object([(op, value)]))])
88}
89
90impl DynamicSearchFilter for S3SearchFilter {
91    fn from_dynamic_filter(filter: Filter<serde_json::Value>) -> Result<Self, FilterError> {
92        Ok(filter.interpret_with(|value| json_value_to_document(&value)))
93    }
94}
95
96impl S3SearchFilter {
97    pub fn inner(&self) -> &aws_smithy_types::Document {
98        &self.0
99    }
100
101    pub fn into_inner(self) -> aws_smithy_types::Document {
102        self.0
103    }
104
105    pub fn gte(key: String, value: <Self as SearchFilter>::Value) -> Self {
106        Self(document_comparison(key, "$gte", value))
107    }
108
109    pub fn lte(key: String, value: <Self as SearchFilter>::Value) -> Self {
110        Self(document_comparison(key, "$lte", value))
111    }
112
113    pub fn exists(key: String) -> Self {
114        Self(document_object([(
115            "$exists",
116            document_object([(key, Document::Bool(true))]),
117        )]))
118    }
119
120    #[allow(clippy::should_implement_trait)]
121    pub fn not(self) -> Self {
122        Self(document_object([("$not", self.0)]))
123    }
124}
125
126pub struct S3VectorsVectorStore<M> {
127    embedding_model: M,
128    client: Client,
129    bucket_name: String,
130    index_name: String,
131}
132
133impl<M> S3VectorsVectorStore<M>
134where
135    M: EmbeddingModel,
136{
137    pub fn new(
138        embedding_model: M,
139        client: aws_sdk_s3vectors::Client,
140        bucket_name: &str,
141        index_name: &str,
142    ) -> Self {
143        Self {
144            embedding_model,
145            client,
146            bucket_name: bucket_name.to_string(),
147            index_name: index_name.to_string(),
148        }
149    }
150
151    pub fn bucket_name(&self) -> &str {
152        &self.bucket_name
153    }
154
155    pub fn set_bucket_name(&mut self, bucket_name: &str) {
156        self.bucket_name = bucket_name.to_string();
157    }
158
159    pub fn index_name(&self) -> &str {
160        &self.index_name
161    }
162
163    pub fn set_index_name(&mut self, index_name: &str) {
164        self.index_name = index_name.to_string();
165    }
166
167    pub fn client(&self) -> &Client {
168        &self.client
169    }
170
171    /// Validates the sample count, embeds the query, and runs the S3Vectors
172    /// query, returning the `(distance, vector)` pairs passing the threshold.
173    async fn run_query(
174        &self,
175        req: &VectorSearchRequest<S3SearchFilter>,
176        return_metadata: bool,
177    ) -> Result<Vec<(f64, aws_sdk_s3vectors::types::QueryOutputVector)>, VectorStoreError> {
178        if req.samples() > i32::MAX as u64 {
179            return Err(VectorStoreError::DatastoreError(format!("The number of samples to return with the `rig` AWS S3Vectors integration cannot be higher than {}", i32::MAX).into()));
180        }
181
182        let embedding = self
183            .embedding_model
184            .embed_text(req.query())
185            .await?
186            .vec
187            .into_iter()
188            .map(|x| x as f32)
189            .collect();
190
191        let mut query_builder = self
192            .client
193            .query_vectors()
194            .query_vector(VectorData::Float32(embedding))
195            .top_k(req.samples() as i32)
196            .return_distance(true)
197            .vector_bucket_name(self.bucket_name())
198            .index_name(self.index_name());
199
200        if return_metadata {
201            query_builder = query_builder.return_metadata(true);
202        }
203
204        if let Some(filter) = req.filter() {
205            query_builder = query_builder.filter(filter.inner().clone())
206        }
207
208        let query = query_builder
209            .send()
210            .await
211            .map_err(VectorStoreError::datastore)?;
212
213        Ok(query
214            .vectors
215            .into_iter()
216            .map(|x| {
217                let distance = x.distance.ok_or_else(|| {
218                    VectorStoreError::DatastoreError("S3Vectors response missing distance".into())
219                })? as f64;
220
221                Ok((distance, x))
222            })
223            .collect::<Result<Vec<_>, VectorStoreError>>()?
224            .into_iter()
225            .filter(|(distance, _)| {
226                !req.threshold()
227                    .is_some_and(|threshold| *distance < threshold)
228            })
229            .collect())
230    }
231}
232
233impl<M> InsertDocuments for S3VectorsVectorStore<M>
234where
235    M: EmbeddingModel,
236{
237    async fn insert_documents<Doc: serde::Serialize + rig_core::Embed + Send>(
238        &self,
239        documents: Vec<(Doc, Vec<rig_core::embeddings::Embedding>)>,
240    ) -> Result<(), rig_core::vector_store::VectorStoreError> {
241        let docs: Vec<PutInputVector> =
242            rig_core::vector_store::flatten_embedded(documents, |json_value, y| {
243                let document = CreateRecord {
244                    document: json_value.clone(),
245                    embedded_text: y.document,
246                };
247                let document =
248                    serde_json::to_value(&document).map_err(VectorStoreError::JsonError)?;
249                let document = json_value_to_document(&document);
250                let vec = y.vec.into_iter().map(|item| item as f32).collect();
251                PutInputVector::builder()
252                    .metadata(document.clone())
253                    .data(VectorData::Float32(vec))
254                    .key(Uuid::new_v4())
255                    .build()
256                    .map_err(|x| {
257                        VectorStoreError::DatastoreError(
258                            format!("Couldn't build vector input: {x}").into(),
259                        )
260                    })
261            })
262            .map_err(|x| {
263                VectorStoreError::DatastoreError(
264                    format!("Could not build vector store data: {x}").into(),
265                )
266            })?;
267
268        self.client
269            .put_vectors()
270            .vector_bucket_name(self.bucket_name())
271            .set_vectors(Some(docs))
272            .set_index_name(Some(self.index_name.clone()))
273            .send()
274            .await
275            .map_err(|x| {
276                VectorStoreError::DatastoreError(
277                    format!("Error while submitting document insertion request: {x}").into(),
278                )
279            })?;
280
281        Ok(())
282    }
283}
284
285fn json_value_to_document(value: &Value) -> Document {
286    match value {
287        Value::Null => Document::Null,
288        Value::Bool(b) => Document::Bool(*b),
289        Value::Number(n) => {
290            if let Some(i) = n.as_i64() {
291                Document::Number(aws_smithy_types::Number::NegInt(i))
292            } else if let Some(u) = n.as_u64() {
293                Document::Number(aws_smithy_types::Number::PosInt(u))
294            } else if let Some(f) = n.as_f64() {
295                Document::Number(aws_smithy_types::Number::Float(f))
296            } else {
297                Document::Null // fallback, should never happen
298            }
299        }
300        Value::String(s) => Document::String(s.clone()),
301        Value::Array(arr) => Document::Array(arr.iter().map(json_value_to_document).collect()),
302        Value::Object(obj) => Document::Object(
303            obj.iter()
304                .map(|(k, v)| (k.clone(), json_value_to_document(v)))
305                .collect::<HashMap<_, _>>(),
306        ),
307    }
308}
309
310fn document_to_json_value(value: &Document) -> Value {
311    match value {
312        Document::Null => Value::Null,
313        Document::Bool(b) => Value::Bool(*b),
314        Document::Number(n) => match n {
315            aws_smithy_types::Number::Float(f) => serde_json::Number::from_f64(*f)
316                .map(Value::Number)
317                .unwrap_or_else(|| Value::String(f.to_string())),
318            aws_smithy_types::Number::NegInt(i) => {
319                serde_json::Value::Number(serde_json::Number::from(*i))
320            }
321            aws_smithy_types::Number::PosInt(u) => {
322                serde_json::Value::Number(serde_json::Number::from(*u))
323            }
324        },
325        Document::String(s) => Value::String(s.clone()),
326        Document::Array(arr) => Value::Array(arr.iter().map(document_to_json_value).collect()),
327        Document::Object(obj) => {
328            let res = obj
329                .iter()
330                .map(|(k, v)| (k.clone(), document_to_json_value(v)))
331                .collect::<serde_json::Map<String, serde_json::Value>>();
332
333            serde_json::Value::Object(res)
334        }
335    }
336}
337
338impl<M> VectorStoreIndex for S3VectorsVectorStore<M>
339where
340    M: EmbeddingModel,
341{
342    type Filter = S3SearchFilter;
343
344    async fn top_n<T: for<'a> serde::Deserialize<'a> + Send>(
345        &self,
346        req: VectorSearchRequest<S3SearchFilter>,
347    ) -> Result<Vec<(f64, String, T)>, VectorStoreError> {
348        self.run_query(&req, true)
349            .await?
350            .into_iter()
351            .map(|(distance, x)| {
352                let metadata_document = x.metadata.ok_or_else(|| {
353                    VectorStoreError::DatastoreError("S3Vectors response missing metadata".into())
354                })?;
355                let val = document_to_json_value(&metadata_document);
356                let metadata: T = serde_json::from_value(val)?;
357
358                Ok((distance, x.key, metadata))
359            })
360            .collect()
361    }
362
363    async fn top_n_ids(
364        &self,
365        req: VectorSearchRequest<S3SearchFilter>,
366    ) -> Result<Vec<(f64, String)>, VectorStoreError> {
367        Ok(self
368            .run_query(&req, false)
369            .await?
370            .into_iter()
371            .map(|(distance, x)| (distance, x.key))
372            .collect())
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn dynamic_filter_compiles_to_native_aws_documents() {
382        let filter = Filter::eq("status", serde_json::json!("ready"))
383            .and(Filter::eq("tags", serde_json::json!(["rust", "ai"])))
384            .and(Filter::gt("score", serde_json::json!(4.5)));
385
386        let compiled = S3SearchFilter::from_dynamic_filter(filter)
387            .expect("JSON values should compile to AWS documents");
388
389        assert_eq!(
390            document_to_json_value(compiled.inner()),
391            serde_json::json!({
392                "$and": [{
393                    "$and": [
394                        { "status": { "$eq": "ready" } },
395                        { "tags": { "$eq": ["rust", "ai"] } }
396                    ]
397                }, {
398                    "score": { "$gt": 4.5 }
399                }]
400            })
401        );
402    }
403
404    #[test]
405    fn extension_operators_build_the_documented_filter_shapes() {
406        let number = |n| Document::Number(aws_smithy_types::Number::PosInt(n));
407        let filter = S3SearchFilter::gte("score".into(), number(5))
408            .or(S3SearchFilter::lte("score".into(), number(1)))
409            .or(S3SearchFilter::exists("status".into()))
410            .not();
411
412        assert_eq!(
413            document_to_json_value(filter.inner()),
414            serde_json::json!({
415                "$not": {
416                    "$or": [
417                        { "$or": [
418                            { "score": { "$gte": 5 } },
419                            { "score": { "$lte": 1 } }
420                        ]},
421                        { "$exists": { "status": true } }
422                    ]
423                }
424            })
425        );
426    }
427}