Skip to main content

plugmem_host/
embedder.rs

1//! The embedder contract and its implementations.
2//!
3//! The engine takes ready vectors; computing them is the host's job.
4//! One HTTP client covers the whole OpenAI-compatible ecosystem —
5//! OpenAI itself, Ollama (`http://localhost:11434/v1/embeddings`), LM Studio,
6//! vLLM, llama.cpp-server — because they all speak the same
7//! `/v1/embeddings` shape; a provider-specific client would be a second
8//! implementation of the same JSON (records the decision).
9
10use crate::error::HostError;
11
12/// Turns texts into embedding vectors. Batched by design — providers
13/// price and perform far better on batches.
14///
15/// `embed` takes `&self`, and the trait requires `Sync`, because an embedder is
16/// a *client* of a remote service, not a piece of mutable state. Every caller
17/// in this workspace shares one instance across threads (a database's writer,
18/// the napi binding's libuv workers, the MCP worker pool), and a `&mut self`
19/// signature forced every one of them to put a `Mutex` in front of it. That
20/// mutex serialized the HTTP round trips: four concurrent recalls against a
21/// 300 ms provider took 1200 ms, with the provider seeing one request at a
22/// time. With `&self` they take 300 ms and the provider sees four.
23///
24/// An implementation that genuinely needs mutable state (a local cache, a
25/// rate-limit budget) brings its own interior mutability, which is the right
26/// place for it: only that implementation knows what may overlap and what may
27/// not. [`OpenAiCompatEmbedder`] needs none — a `ureq::Agent` is a
28/// connection-pool handle built for concurrent use.
29pub trait Embedder: Send + Sync {
30    /// Vector dimension this embedder produces. `0` disables the vector
31    /// layer (the engine is fully functional without it).
32    fn dim(&self) -> usize;
33
34    /// Embeds every text, one vector per input, in input order.
35    ///
36    /// Called concurrently from several threads. An implementation that keeps
37    /// state must guard it itself.
38    ///
39    /// # Errors
40    ///
41    /// [`HostError::Embed`] describing the transport or response
42    /// problem.
43    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError>;
44}
45
46/// The no-op embedder: dimension 0, never called by the database (a
47/// structural-only memory).
48#[derive(Clone, Copy, Debug, Default)]
49pub struct NullEmbedder;
50
51impl Embedder for NullEmbedder {
52    fn dim(&self) -> usize {
53        0
54    }
55
56    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
57        Ok(vec![Vec::new(); texts.len()])
58    }
59}
60
61/// One embedder handed to several databases.
62///
63/// [`crate::DatabaseBuilder::embedder`] takes ownership, which is right for one
64/// database and wrong for a workspace: a hundred chats do not want a hundred
65/// HTTP clients pointed at the same endpoint. Each database gets its own
66/// `SharedEmbedder` over one shared provider instead.
67///
68/// A plain refcount, with no lock in it. Sharing an [`Embedder`] needs nothing
69/// more, because `embed` takes `&self`; this type used to hold a `Mutex` and
70/// that mutex was the only reason concurrent embedding serialized. Cloning is
71/// an atomic increment, so handing one out per database costs nothing.
72#[derive(Clone)]
73pub struct SharedEmbedder(std::sync::Arc<dyn Embedder>);
74
75impl SharedEmbedder {
76    /// Wraps `inner` so it can be cloned into many databases.
77    pub fn new(inner: Box<dyn Embedder>) -> Self {
78        Self(std::sync::Arc::from(inner))
79    }
80}
81
82impl std::fmt::Debug for SharedEmbedder {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("SharedEmbedder")
85            .field("dim", &self.0.dim())
86            .finish()
87    }
88}
89
90impl Embedder for SharedEmbedder {
91    fn dim(&self) -> usize {
92        self.0.dim()
93    }
94
95    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
96        self.0.embed(texts)
97    }
98}
99
100/// An `/v1/embeddings` client for any OpenAI-compatible server.
101#[derive(Debug)]
102pub struct OpenAiCompatEmbedder {
103    url: String,
104    model: String,
105    api_key: Option<String>,
106    dim: usize,
107    agent: ureq::Agent,
108}
109
110impl OpenAiCompatEmbedder {
111    /// Creates a client for the exact embeddings `endpoint_url` (e.g.
112    /// `https://api.openai.com/v1/embeddings` or
113    /// `http://localhost:11434/v1/embeddings`), a model name and the expected
114    /// dimension. The URL is used as supplied; this constructor does not
115    /// append or otherwise rewrite a path. The dimension is explicit — no
116    /// startup probe request, and a server disagreeing with it is a typed
117    /// error, not a silently reconfigured database.
118    pub fn new(endpoint_url: &str, model: &str, dim: usize) -> Self {
119        Self {
120            url: endpoint_url.to_string(),
121            model: model.to_string(),
122            api_key: None,
123            dim,
124            agent: ureq::Agent::new_with_defaults(),
125        }
126    }
127
128    /// Adds a bearer API key (OpenAI et al.; local servers usually need
129    /// none).
130    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
131        self.api_key = Some(key.into());
132        self
133    }
134}
135
136impl Embedder for OpenAiCompatEmbedder {
137    fn dim(&self) -> usize {
138        self.dim
139    }
140
141    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
142        if texts.is_empty() {
143            return Ok(Vec::new());
144        }
145        let body = serde_json::json!({ "model": self.model, "input": texts });
146        let mut request = self.agent.post(&self.url);
147        if let Some(key) = &self.api_key {
148            request = request.header("Authorization", &format!("Bearer {key}"));
149        }
150        let mut response = request
151            .send_json(&body)
152            .map_err(|e| HostError::Embed(format!("request to {}: {e}", self.url)))?;
153        let value: serde_json::Value = response
154            .body_mut()
155            .read_json()
156            .map_err(|e| HostError::Embed(format!("response body: {e}")))?;
157
158        // { "data": [ { "index": i, "embedding": [f32...] }, ... ] } —
159        // placed by the `index` field, per the contract (providers may
160        // reorder).
161        let data = value
162            .get("data")
163            .and_then(|d| d.as_array())
164            .ok_or_else(|| HostError::Embed("response has no data array".into()))?;
165        if data.len() != texts.len() {
166            return Err(HostError::Embed(format!(
167                "expected {} embeddings, got {}",
168                texts.len(),
169                data.len()
170            )));
171        }
172        let mut out = vec![Vec::new(); texts.len()];
173        for item in data {
174            let index = item
175                .get("index")
176                .and_then(|i| i.as_u64())
177                .ok_or_else(|| HostError::Embed("embedding without an index".into()))?
178                as usize;
179            let raw = item
180                .get("embedding")
181                .and_then(|e| e.as_array())
182                .ok_or_else(|| HostError::Embed("embedding is not an array".into()))?;
183            if index >= out.len() || !out[index].is_empty() {
184                return Err(HostError::Embed(format!("bad embedding index {index}")));
185            }
186            if raw.len() != self.dim {
187                return Err(HostError::Embed(format!(
188                    "dimension mismatch: server sent {}, configured {}",
189                    raw.len(),
190                    self.dim
191                )));
192            }
193            let mut v = Vec::with_capacity(raw.len());
194            for x in raw {
195                v.push(
196                    x.as_f64().ok_or_else(|| {
197                        HostError::Embed("embedding component is not a number".into())
198                    })? as f32,
199                );
200            }
201            out[index] = v;
202        }
203        Ok(out)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    use std::sync::atomic::{AtomicUsize, Ordering};
212
213    /// Counts its calls, so a test can tell one shared provider from several
214    /// independent ones. State behind an atomic because `embed` takes `&self`
215    /// — the arrangement the trait asks a stateful implementation to make.
216    struct Counting(AtomicUsize);
217    impl Embedder for Counting {
218        fn dim(&self) -> usize {
219            3
220        }
221        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
222            let total = self.0.fetch_add(texts.len(), Ordering::Relaxed) + texts.len();
223            Ok(vec![vec![total as f32; 3]; texts.len()])
224        }
225    }
226
227    /// Blocks for a moment and records how many calls were inside `embed` at
228    /// once, which is what a mutex in front of it would hold at one.
229    struct Overlapping {
230        inside: AtomicUsize,
231        peak: AtomicUsize,
232    }
233    impl Embedder for Overlapping {
234        fn dim(&self) -> usize {
235            1
236        }
237        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
238            let now = self.inside.fetch_add(1, Ordering::SeqCst) + 1;
239            self.peak.fetch_max(now, Ordering::SeqCst);
240            std::thread::sleep(std::time::Duration::from_millis(50));
241            self.inside.fetch_sub(1, Ordering::SeqCst);
242            Ok(vec![vec![0.0]; texts.len()])
243        }
244    }
245
246    #[test]
247    fn clones_of_a_shared_embedder_reach_the_same_provider() {
248        let shared = SharedEmbedder::new(Box::new(Counting(AtomicUsize::new(0))));
249        let a = shared.clone();
250        let b = shared.clone();
251
252        assert_eq!(a.dim(), 3);
253        assert_eq!(format!("{shared:?}"), "SharedEmbedder { dim: 3 }");
254
255        // Two databases' worth of handles, one counter behind them: the second
256        // call sees the first one's effect.
257        assert_eq!(a.embed(&["x"]).unwrap(), vec![vec![1.0; 3]]);
258        assert_eq!(b.embed(&["y", "z"]).unwrap(), vec![vec![3.0; 3]; 2]);
259    }
260
261    #[test]
262    fn concurrent_callers_are_inside_the_provider_at_the_same_time() {
263        // The invariant the `&self` signature exists for. Under the old
264        // `&mut self` trait this could not be written at all: the `Mutex` that
265        // a shared embedder needed held `peak` at 1, and four concurrent
266        // recalls against a slow provider cost four round trips.
267        let provider = std::sync::Arc::new(Overlapping {
268            inside: AtomicUsize::new(0),
269            peak: AtomicUsize::new(0),
270        });
271        let shared = SharedEmbedder(provider.clone());
272
273        std::thread::scope(|scope| {
274            for _ in 0..4 {
275                let handle = shared.clone();
276                scope.spawn(move || handle.embed(&["question"]).unwrap());
277            }
278        });
279
280        assert!(
281            provider.peak.load(Ordering::SeqCst) > 1,
282            "callers serialized: peak concurrency was {}",
283            provider.peak.load(Ordering::SeqCst)
284        );
285    }
286
287    #[test]
288    fn the_null_embedder_produces_one_empty_vector_per_text() {
289        let null = NullEmbedder;
290        assert_eq!(null.dim(), 0);
291        assert_eq!(null.embed(&["a", "b"]).unwrap(), vec![Vec::<f32>::new(); 2]);
292    }
293}