Skip to main content

rig_scylladb/
lib.rs

1//! ScyllaDB vector store integration for Rig.
2//!
3//! This crate provides [`ScyllaDbVectorStore`], a Rig vector store backed by a
4//! ScyllaDB keyspace and table. It stores document payloads and embeddings in
5//! ScyllaDB and performs application-level cosine similarity search.
6//!
7//! The root `rig` facade re-exports this crate as `rig::scylladb` when the
8//! `scylladb` feature is enabled.
9
10use rig_core::{
11    Embed,
12    embeddings::{Embedding, EmbeddingModel},
13    vector_store::{
14        InsertDocuments, VectorStoreError, VectorStoreIndex,
15        request::{
16            DynamicSearchFilter, Filter, FilterError, SearchFilter, SqlCondition,
17            VectorSearchRequest,
18        },
19    },
20};
21use scylla::{
22    client::{Compression, session::Session, session_builder::SessionBuilder},
23    statement::prepared::PreparedStatement,
24    value::CqlValue,
25};
26use serde::{Deserialize, Serialize};
27use std::{
28    collections::HashMap,
29    hash::{DefaultHasher, Hash, Hasher},
30    sync::{Arc, RwLock},
31};
32use uuid::Uuid;
33
34/// Represents a vector store implementation using ScyllaDB as the backend.
35///
36/// ScyllaDB is a high-performance NoSQL database that's compatible with Apache Cassandra
37/// and provides excellent performance for vector storage and similarity search operations.
38pub struct ScyllaDbVectorStore<M: EmbeddingModel> {
39    /// Model used to generate embeddings for the vector store
40    model: M,
41    /// Session instance for ScyllaDB communication
42    pub session: Arc<Session>,
43    /// Keyspace and table name for vector storage
44    keyspace: String,
45    table: String,
46    /// The number of dimensions for vectors
47    dimensions: usize,
48    /// Prepared statements for optimized queries
49    insert_stmt: PreparedStatement,
50    search_stmt: PreparedStatement,
51    get_by_id_stmt: PreparedStatement,
52    /// Cache for statements which cannot be prepared AOT
53    cache: Arc<RwLock<HashMap<u64, PreparedStatement>>>,
54}
55
56/// Converts a `serde_json::Value` to a `CqlValue` for use in ScyllaDB queries.
57fn cql_value_from_json(value: serde_json::Value) -> Result<CqlValue, FilterError> {
58    use scylla::value::CqlVarint;
59    use serde_json::Value;
60
61    match value {
62        Value::Bool(b) => Ok(CqlValue::Boolean(b)),
63        Value::Number(n) => {
64            if let Some(i) = n.as_i64() {
65                Ok(CqlValue::BigInt(i))
66            } else if let Some(u) = n.as_u64() {
67                // u64 values that don't fit in i64 - use Varint with big-endian bytes
68                // Add a leading zero byte to ensure it's interpreted as positive
69                let mut bytes = vec![0u8];
70                bytes.extend_from_slice(&u.to_be_bytes());
71                Ok(CqlValue::Varint(CqlVarint::from_signed_bytes_be(bytes)))
72            } else if let Some(f) = n.as_f64() {
73                Ok(CqlValue::Double(f))
74            } else {
75                Err(FilterError::Expected {
76                    expected: "Valid number".into(),
77                    got: "Invalid number".into(),
78                })
79            }
80        }
81        Value::String(s) => Ok(CqlValue::Text(s)),
82        Value::Array(arr) => Ok(CqlValue::List(
83            arr.into_iter()
84                .map(cql_value_from_json)
85                .collect::<Result<_, _>>()?,
86        )),
87        Value::Object(map) => {
88            let pairs = map
89                .into_iter()
90                .map(|(k, v)| Ok((CqlValue::Text(k), cql_value_from_json(v)?)))
91                .collect::<Result<Vec<_>, FilterError>>()?;
92            Ok(CqlValue::Map(pairs))
93        }
94        Value::Null => Ok(CqlValue::Empty),
95    }
96}
97
98/// Placeholder token CQL expects for every bind parameter.
99const PLACEHOLDER: &str = "?";
100
101/// ScyllaDB query filter: a CQL `WHERE` fragment plus the values to bind to it.
102#[derive(Clone, Debug)]
103pub struct ScyllaSearchFilter(SqlCondition<CqlValue>);
104
105/// Only the condition is hashed: it is what the prepared-statement cache is
106/// keyed on, and the bound parameters do not change the statement text.
107impl std::hash::Hash for ScyllaSearchFilter {
108    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
109        self.0.condition().hash(state)
110    }
111}
112
113impl SearchFilter for ScyllaSearchFilter {
114    type Value = CqlValue;
115
116    fn eq(key: impl AsRef<str>, value: Self::Value) -> Self {
117        Self(SqlCondition::binary(key, "=", PLACEHOLDER, value))
118    }
119
120    fn gt(key: impl AsRef<str>, value: Self::Value) -> Self {
121        Self(SqlCondition::binary(key, ">", PLACEHOLDER, value))
122    }
123
124    fn lt(key: impl AsRef<str>, value: Self::Value) -> Self {
125        Self(SqlCondition::binary(key, "<", PLACEHOLDER, value))
126    }
127
128    fn and(self, rhs: Self) -> Self {
129        Self(self.0.and(rhs.0))
130    }
131
132    fn or(self, rhs: Self) -> Self {
133        Self(self.0.or(rhs.0))
134    }
135}
136
137impl ScyllaSearchFilter {
138    fn condition(&self) -> &str {
139        self.0.condition()
140    }
141
142    fn params(&self) -> &[CqlValue] {
143        self.0.params()
144    }
145
146    #[allow(clippy::should_implement_trait)]
147    pub fn not(self) -> Self {
148        Self(self.0.not())
149    }
150
151    pub fn gte(key: String, value: <Self as SearchFilter>::Value) -> Self {
152        Self(SqlCondition::binary(key, ">=", PLACEHOLDER, value))
153    }
154
155    pub fn lte(key: String, value: <Self as SearchFilter>::Value) -> Self {
156        Self(SqlCondition::binary(key, "<=", PLACEHOLDER, value))
157    }
158
159    pub fn ne(key: String, value: <Self as SearchFilter>::Value) -> Self {
160        Self(SqlCondition::binary(key, "!=", PLACEHOLDER, value))
161    }
162
163    pub fn member(key: String, values: Vec<<Self as SearchFilter>::Value>) -> Self {
164        Self(SqlCondition::list(key, "IN", PLACEHOLDER, values))
165    }
166}
167
168impl TryFrom<Filter<serde_json::Value>> for ScyllaSearchFilter {
169    type Error = FilterError;
170
171    fn try_from(value: Filter<serde_json::Value>) -> Result<Self, Self::Error> {
172        value.try_interpret(cql_value_from_json)
173    }
174}
175
176impl DynamicSearchFilter for ScyllaSearchFilter {
177    fn from_dynamic_filter(filter: Filter<serde_json::Value>) -> Result<Self, FilterError> {
178        Self::try_from(filter)
179    }
180}
181
182impl<M> ScyllaDbVectorStore<M>
183where
184    M: EmbeddingModel,
185{
186    /// Creates a new instance of `ScyllaDbVectorStore`.
187    ///
188    /// # Arguments
189    /// * `model` - Embedding model instance
190    /// * `session` - ScyllaDB session
191    /// * `keyspace` - Keyspace name (will be created if it doesn't exist)
192    /// * `table` - Table name for storing vectors
193    /// * `dimensions` - Number of dimensions for the vectors
194    pub async fn new(
195        model: M,
196        session: Session,
197        keyspace: &str,
198        table: &str,
199        dimensions: usize,
200    ) -> Result<Self, VectorStoreError> {
201        let session = Arc::new(session);
202
203        // Create keyspace if it doesn't exist
204        let create_keyspace_cql = format!(
205            "CREATE KEYSPACE IF NOT EXISTS {keyspace} WITH REPLICATION = {{
206                'class': 'SimpleStrategy',
207                'replication_factor': 1
208            }}"
209        );
210        session
211            .query_unpaged(create_keyspace_cql, &[])
212            .await
213            .map_err(VectorStoreError::datastore)?;
214
215        // Create table for storing vectors
216        // Note: Once ScyllaDB vector search is fully available, we'll use VECTOR type
217        // For now, we use a list of floats and implement similarity search in application code
218        let create_table_cql = format!(
219            "CREATE TABLE IF NOT EXISTS {keyspace}.{table} (
220                id UUID PRIMARY KEY,
221                vector LIST<FLOAT>,
222                metadata TEXT,
223                created_at BIGINT
224            )"
225        );
226        session
227            .query_unpaged(create_table_cql, &[])
228            .await
229            .map_err(VectorStoreError::datastore)?;
230
231        // Prepare statements for better performance
232        let insert_stmt = session
233            .prepare(format!(
234                "INSERT INTO {keyspace}.{table} (id, vector, metadata, created_at) VALUES (?, ?, ?, ?)"
235            ))
236            .await
237            .map_err(VectorStoreError::datastore)?;
238
239        let search_stmt = session
240            .prepare(format!(
241                "SELECT id, vector, metadata, created_at FROM {keyspace}.{table}"
242            ))
243            .await
244            .map_err(VectorStoreError::datastore)?;
245
246        let get_by_id_stmt = session
247            .prepare(format!(
248                "SELECT id, vector, metadata, created_at FROM {keyspace}.{table} WHERE id = ?"
249            ))
250            .await
251            .map_err(VectorStoreError::datastore)?;
252
253        Ok(Self {
254            model,
255            session,
256            keyspace: keyspace.to_string(),
257            table: table.to_string(),
258            dimensions,
259            insert_stmt,
260            search_stmt,
261            get_by_id_stmt,
262            cache: Default::default(),
263        })
264    }
265
266    /// Get the session reference
267    pub fn session(&self) -> &Arc<Session> {
268        &self.session
269    }
270
271    /// Get the keyspace name
272    pub fn keyspace(&self) -> &str {
273        &self.keyspace
274    }
275
276    /// Get the table name
277    pub fn table(&self) -> &str {
278        &self.table
279    }
280
281    /// Get a document by its ID
282    pub async fn get_by_id<T: for<'a> Deserialize<'a> + Send>(
283        &self,
284        id: &str,
285    ) -> Result<Option<T>, VectorStoreError> {
286        let uuid = Uuid::parse_str(id).map_err(VectorStoreError::datastore)?;
287
288        let result = self
289            .session
290            .execute_unpaged(&self.get_by_id_stmt, (uuid,))
291            .await
292            .map_err(VectorStoreError::datastore)?;
293
294        let rows_result = result
295            .into_rows_result()
296            .map_err(VectorStoreError::datastore)?;
297
298        if let Some(first_row) = rows_result
299            .rows::<(Uuid, Vec<f32>, String, i64)>()
300            .map_err(VectorStoreError::datastore)?
301            .next()
302        {
303            let (_, _, metadata, _) = first_row.map_err(VectorStoreError::datastore)?;
304
305            let payload: T = serde_json::from_str(&metadata)?;
306            return Ok(Some(payload));
307        }
308
309        Ok(None)
310    }
311
312    /// Calculate cosine similarity between two vectors
313    fn cosine_similarity(vec1: &[f32], vec2: &[f32]) -> f32 {
314        let dot_product: f32 = vec1.iter().zip(vec2.iter()).map(|(a, b)| a * b).sum();
315        let norm1: f32 = vec1.iter().map(|x| x * x).sum::<f32>().sqrt();
316        let norm2: f32 = vec2.iter().map(|x| x * x).sum::<f32>().sqrt();
317
318        if norm1 == 0.0 || norm2 == 0.0 {
319            0.0
320        } else {
321            dot_product / (norm1 * norm2)
322        }
323    }
324
325    /// Generate query vector from text
326    async fn generate_query_vector(&self, query: &str) -> Result<Vec<f32>, VectorStoreError> {
327        let embedding = self.model.embed_text(query).await?;
328        Ok(embedding.vec.iter().map(|&x| x as f32).collect())
329    }
330
331    async fn get_filter_statement_or_default(
332        &self,
333        req: &VectorSearchRequest<ScyllaSearchFilter>,
334    ) -> Result<PreparedStatement, VectorStoreError> {
335        if let Some(filter) = req.filter() {
336            let mut hasher = DefaultHasher::new();
337            filter.hash(&mut hasher);
338            let filter_hash = hasher.finish();
339
340            let statement = if let Some(cached) = self
341                .cache
342                .read()
343                .ok()
344                .and_then(|cache| cache.get(&filter_hash).cloned())
345            {
346                cached
347            } else {
348                let query = format!(
349                    "SELECT id, vector, metadata, created_at FROM {}.{} WHERE {} ALLOW FILTERING",
350                    self.keyspace,
351                    self.table,
352                    filter.condition()
353                );
354
355                let prepared = self
356                    .session
357                    .prepare(query)
358                    .await
359                    .map_err(VectorStoreError::datastore)?;
360
361                let mut cache = self.cache.write().map_err(|e| {
362                    VectorStoreError::DatastoreError(
363                        format!("Error writing statement cache: {e}").into(),
364                    )
365                })?;
366                cache.insert(filter_hash, prepared.clone());
367                prepared
368            };
369
370            Ok(statement)
371        } else {
372            Ok(self.search_stmt.clone())
373        }
374    }
375
376    /// Runs the (optionally filtered) scan and scores every row against the
377    /// query, returning the sorted, truncated `(score, id, metadata)` list.
378    async fn search_candidates(
379        &self,
380        req: &VectorSearchRequest<ScyllaSearchFilter>,
381    ) -> Result<Vec<(f64, String, String)>, VectorStoreError> {
382        let query_vector = self.generate_query_vector(req.query()).await?;
383
384        let statement = self.get_filter_statement_or_default(req).await?;
385        let params = req
386            .filter()
387            .as_ref()
388            .map(ScyllaSearchFilter::params)
389            .unwrap_or_default();
390
391        // Fetch all vectors (this will be optimized once ScyllaDB vector search is available)
392        let results = self
393            .session
394            .execute_unpaged(&statement, params)
395            .await
396            .map_err(VectorStoreError::datastore)?;
397
398        let rows_result = results
399            .into_rows_result()
400            .map_err(VectorStoreError::datastore)?;
401
402        let mut candidates = Vec::new();
403
404        for row_result in rows_result
405            .rows::<(Uuid, Vec<f32>, String, i64)>()
406            .map_err(VectorStoreError::datastore)?
407        {
408            let (id, vector, metadata, _) = row_result.map_err(VectorStoreError::datastore)?;
409
410            let score = Self::cosine_similarity(&query_vector, &vector) as f64;
411
412            if req.threshold().is_some_and(|threshold| score < threshold) {
413                continue;
414            }
415
416            candidates.push((score, id.to_string(), metadata));
417        }
418
419        // Sort by similarity score (descending) and take top n
420        candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
421        candidates.truncate(req.samples() as usize);
422
423        Ok(candidates)
424    }
425}
426
427impl<Model> InsertDocuments for ScyllaDbVectorStore<Model>
428where
429    Model: EmbeddingModel + Send + Sync,
430{
431    async fn insert_documents<Doc: Serialize + Embed + Send>(
432        &self,
433        documents: Vec<(Doc, Vec<Embedding>)>,
434    ) -> Result<(), VectorStoreError> {
435        for (document, embeddings) in documents {
436            let metadata = serde_json::to_string(&document)?;
437            let now = chrono::Utc::now().timestamp();
438
439            for embedding in embeddings.into_iter() {
440                let vector: Vec<f32> = embedding.vec.into_iter().map(|x| x as f32).collect();
441
442                if vector.len() != self.dimensions {
443                    return Err(VectorStoreError::DatastoreError(
444                        format!(
445                            "Vector dimension mismatch: expected {}, got {}",
446                            self.dimensions,
447                            vector.len()
448                        )
449                        .into(),
450                    ));
451                }
452
453                let id = Uuid::new_v4();
454
455                self.session
456                    .execute_unpaged(&self.insert_stmt, (id, vector, &metadata, now))
457                    .await
458                    .map_err(VectorStoreError::datastore)?;
459            }
460        }
461
462        Ok(())
463    }
464}
465
466impl<M> VectorStoreIndex for ScyllaDbVectorStore<M>
467where
468    M: EmbeddingModel + std::marker::Sync + Send,
469{
470    type Filter = ScyllaSearchFilter;
471
472    /// Search for the top `n` nearest neighbors to the given query.
473    /// Returns a vector of tuples containing the score, ID, and payload of the nearest neighbors.
474    ///
475    /// Note: This implementation performs a brute-force search since ScyllaDB's native vector
476    /// search is still in development. Once available, this will be optimized to use native
477    /// vector search capabilities with ANN (Approximate Nearest Neighbor) algorithms.
478    async fn top_n<T: for<'a> Deserialize<'a> + Send>(
479        &self,
480        req: VectorSearchRequest<ScyllaSearchFilter>,
481    ) -> Result<Vec<(f64, String, T)>, VectorStoreError> {
482        self.search_candidates(&req)
483            .await?
484            .into_iter()
485            .map(|(score, id, metadata)| Ok((score, id, serde_json::from_str(&metadata)?)))
486            .collect()
487    }
488
489    /// Search for the top `n` nearest neighbors to the given query.
490    /// Returns a vector of tuples containing the score and ID of the nearest neighbors.
491    async fn top_n_ids(
492        &self,
493        req: VectorSearchRequest<ScyllaSearchFilter>,
494    ) -> Result<Vec<(f64, String)>, VectorStoreError> {
495        Ok(self
496            .search_candidates(&req)
497            .await?
498            .into_iter()
499            .map(|(score, id, _)| (score, id))
500            .collect())
501    }
502}
503
504/// Convenience function to create a ScyllaDB session
505pub async fn create_session(uri: &str) -> Result<Session, VectorStoreError> {
506    SessionBuilder::new()
507        .known_node(uri)
508        .compression(Some(Compression::Lz4))
509        .build()
510        .await
511        .map_err(VectorStoreError::datastore)
512}
513
514#[cfg(test)]
515mod tests {
516    use super::{CqlValue, ScyllaSearchFilter, SearchFilter};
517
518    /// CQL binds positionally, so the rendered condition must carry exactly one
519    /// `?` per parameter — including `IN`, which renders one per value.
520    #[test]
521    fn every_parameterised_operator_uses_question_mark_placeholders() {
522        let filter = ScyllaSearchFilter::gte("price".into(), CqlValue::BigInt(5))
523            .and(ScyllaSearchFilter::member(
524                "id".into(),
525                vec![CqlValue::BigInt(1), CqlValue::BigInt(2)],
526            ))
527            .or(ScyllaSearchFilter::ne("kind".into(), CqlValue::Text("veg".into())).not());
528
529        assert_eq!(
530            filter.condition(),
531            "((price >= ?) AND (id IN (?, ?))) OR (NOT (kind != ?))"
532        );
533        assert_eq!(filter.condition().matches('?').count(), 4);
534        assert_eq!(filter.params().len(), 4);
535    }
536}