cel_memory_sqlite/embedder.rs
1//! Embedder trait + reference implementations.
2//!
3//! The [`Embedder`] trait abstracts over local (fastembed/ONNX) and cloud
4//! (OpenAI/Voyage) embedding backends. The [`SqliteMemoryProvider`] takes
5//! a `Box<dyn Embedder>` at construction and uses it for write-time
6//! embedding and retrieval-time query embedding.
7//!
8//! v1 ships two implementations:
9//!
10//! - [`MockEmbedder`] — deterministic small-dim vectors for tests. No
11//! external dependencies; always available.
12//! - `FastEmbedEmbedder` — real `bge-small-en-v1.5` model (384 dim) via
13//! the [`fastembed`] crate. Gated behind the `fastembed` feature
14//! because the model file downloads on first instantiation (~130 MB)
15//! and onnxruntime is a heavy dep.
16//!
17//! [`SqliteMemoryProvider`]: crate::SqliteMemoryProvider
18//! [`fastembed`]: https://crates.io/crates/fastembed
19
20use async_trait::async_trait;
21
22use crate::error::SqliteMemoryError;
23
24/// Embedding result alias.
25pub type EmbedResult<T> = Result<T, SqliteMemoryError>;
26
27/// An embedder turns text into a fixed-dimension vector.
28///
29/// Implementations must produce vectors of [`Embedder::dim`] length on
30/// every call; the storage layer validates and rejects mismatches.
31#[async_trait]
32pub trait Embedder: Send + Sync {
33 /// Vector dimensionality.
34 fn dim(&self) -> usize;
35
36 /// Stable model identifier (e.g., `"bge-small-en-v1.5"`).
37 fn model_name(&self) -> &str;
38
39 /// Embed one piece of text.
40 async fn embed(&self, text: &str) -> EmbedResult<Vec<f32>>;
41
42 /// Embed a batch of texts. Default implementation calls [`embed`]
43 /// sequentially; production embedders should override for batching.
44 ///
45 /// [`embed`]: Embedder::embed
46 async fn embed_batch(&self, texts: &[String]) -> EmbedResult<Vec<Vec<f32>>> {
47 let mut out = Vec::with_capacity(texts.len());
48 for t in texts {
49 out.push(self.embed(t).await?);
50 }
51 Ok(out)
52 }
53}
54
55/// Deterministic test embedder. Hashes the input text to a small vector
56/// of pseudo-random floats. **Never use in production** — produces
57/// meaningless vectors.
58///
59/// Useful for unit tests of the SQLite layer where we just need *some*
60/// vector to round-trip through `memory_vec`.
61#[derive(Debug, Clone)]
62pub struct MockEmbedder {
63 dim: usize,
64 model: String,
65}
66
67impl MockEmbedder {
68 /// Mock embedder with the default `384` dimension, matching the
69 /// `memory_vec` schema produced by the initial migration.
70 pub fn new() -> Self {
71 Self {
72 dim: 384,
73 model: "mock-384".into(),
74 }
75 }
76
77 /// Mock embedder with an arbitrary dim. Use only for tests that
78 /// override the migration schema.
79 pub fn with_dim(dim: usize) -> Self {
80 Self {
81 dim,
82 model: format!("mock-{dim}"),
83 }
84 }
85}
86
87impl Default for MockEmbedder {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93#[async_trait]
94impl Embedder for MockEmbedder {
95 fn dim(&self) -> usize {
96 self.dim
97 }
98
99 fn model_name(&self) -> &str {
100 &self.model
101 }
102
103 async fn embed(&self, text: &str) -> EmbedResult<Vec<f32>> {
104 // Tiny deterministic hash → seed → fill. Not cryptographic; just
105 // makes equal inputs produce equal outputs and slightly differs
106 // across short inputs.
107 let mut seed: u64 = 0xcbf29ce484222325;
108 for b in text.bytes() {
109 seed ^= b as u64;
110 seed = seed.wrapping_mul(0x100000001b3);
111 }
112 let mut out = Vec::with_capacity(self.dim);
113 for i in 0..self.dim {
114 seed = seed
115 .wrapping_mul(6364136223846793005)
116 .wrapping_add(1442695040888963407);
117 // Map to [-1, 1].
118 let f = ((seed >> (i % 32)) as i32) as f32 / i32::MAX as f32;
119 out.push(f.clamp(-1.0, 1.0));
120 }
121 Ok(out)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[tokio::test]
130 async fn mock_embedder_deterministic() {
131 let e = MockEmbedder::new();
132 let a = e.embed("hello").await.unwrap();
133 let b = e.embed("hello").await.unwrap();
134 assert_eq!(a, b);
135 assert_eq!(a.len(), 384);
136 }
137
138 #[tokio::test]
139 async fn mock_embedder_different_for_different_input() {
140 let e = MockEmbedder::new();
141 let a = e.embed("hello").await.unwrap();
142 let b = e.embed("world").await.unwrap();
143 assert_ne!(a, b);
144 }
145
146 #[tokio::test]
147 async fn mock_embedder_with_dim_honors_dim() {
148 let e = MockEmbedder::with_dim(8);
149 let v = e.embed("hi").await.unwrap();
150 assert_eq!(v.len(), 8);
151 assert_eq!(e.dim(), 8);
152 }
153
154 #[tokio::test]
155 async fn batch_default_works() {
156 let e = MockEmbedder::new();
157 let v = e
158 .embed_batch(&["a".to_string(), "b".to_string()])
159 .await
160 .unwrap();
161 assert_eq!(v.len(), 2);
162 assert_eq!(v[0].len(), 384);
163 }
164}