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    /// Stable, human-readable identity of the semantic vector space.
31    /// Different models (or incompatible revisions of one model) must return
32    /// different ids even when their dimensions match.
33    fn space_id(&self) -> &str;
34
35    /// Vector dimension this embedder produces. `0` disables the vector
36    /// layer (the engine is fully functional without it).
37    fn dim(&self) -> usize;
38
39    /// Embeds every text, one vector per input, in input order.
40    ///
41    /// Called concurrently from several threads. An implementation that keeps
42    /// state must guard it itself.
43    ///
44    /// # Errors
45    ///
46    /// [`HostError::Embed`] describing the transport or response
47    /// problem.
48    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError>;
49}
50
51/// The no-op embedder: dimension 0, never called by the database (a
52/// structural-only memory).
53#[derive(Clone, Copy, Debug, Default)]
54pub struct NullEmbedder;
55
56impl Embedder for NullEmbedder {
57    fn space_id(&self) -> &str {
58        "none"
59    }
60
61    fn dim(&self) -> usize {
62        0
63    }
64
65    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
66        Ok(vec![Vec::new(); texts.len()])
67    }
68}
69
70/// One embedder handed to several databases.
71///
72/// [`crate::DatabaseBuilder::embedder`] takes ownership, which is right for one
73/// database and wrong for a workspace: a hundred chats do not want a hundred
74/// HTTP clients pointed at the same endpoint. Each database gets its own
75/// `SharedEmbedder` over one shared provider instead.
76///
77/// A plain refcount, with no lock in it. Sharing an [`Embedder`] needs nothing
78/// more, because `embed` takes `&self`; this type used to hold a `Mutex` and
79/// that mutex was the only reason concurrent embedding serialized. Cloning is
80/// an atomic increment, so handing one out per database costs nothing.
81#[derive(Clone)]
82pub struct SharedEmbedder(std::sync::Arc<dyn Embedder>);
83
84impl SharedEmbedder {
85    /// Wraps `inner` so it can be cloned into many databases.
86    pub fn new(inner: Box<dyn Embedder>) -> Self {
87        Self(std::sync::Arc::from(inner))
88    }
89}
90
91impl std::fmt::Debug for SharedEmbedder {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("SharedEmbedder")
94            .field("dim", &self.0.dim())
95            .finish()
96    }
97}
98
99impl Embedder for SharedEmbedder {
100    fn space_id(&self) -> &str {
101        self.0.space_id()
102    }
103
104    fn dim(&self) -> usize {
105        self.0.dim()
106    }
107
108    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
109        self.0.embed(texts)
110    }
111}
112
113/// An `/v1/embeddings` client for any OpenAI-compatible server.
114#[derive(Debug)]
115pub struct OpenAiCompatEmbedder {
116    url: String,
117    model: String,
118    space_id: String,
119    api_key: Option<String>,
120    dim: usize,
121    agent: ureq::Agent,
122}
123
124impl OpenAiCompatEmbedder {
125    /// Creates a client for the exact embeddings `endpoint_url` (e.g.
126    /// `https://api.openai.com/v1/embeddings` or
127    /// `http://localhost:11434/v1/embeddings`), a model name and the expected
128    /// dimension. The model name is also the default semantic-space id; use
129    /// [`Self::with_space_id`] to declare a stable revision or digest instead.
130    /// The URL is used as supplied; this constructor does not append or
131    /// otherwise rewrite a path. The dimension and identity are explicit — no
132    /// startup probe request, and a server disagreeing with the dimension is a
133    /// typed error, not a silently reconfigured database.
134    pub fn new(endpoint_url: &str, model: &str, dim: usize) -> Self {
135        Self {
136            url: endpoint_url.to_string(),
137            model: model.to_string(),
138            space_id: model.to_string(),
139            api_key: None,
140            dim,
141            agent: ureq::Agent::new_with_defaults(),
142        }
143    }
144
145    /// Adds a bearer API key (OpenAI et al.; local servers usually need
146    /// none).
147    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
148        self.api_key = Some(key.into());
149        self
150    }
151
152    /// Overrides the semantic vector-space identity persisted in the
153    /// database. By default this is the model name passed to [`Self::new`].
154    ///
155    /// Use an explicit id when the provider's request model is an alias, or
156    /// when two differently named endpoints are known to produce compatible
157    /// vectors. Plugmem trusts this declaration and never probes the provider
158    /// to infer it. Invalid ids are rejected when the database first uses the
159    /// embedder.
160    pub fn with_space_id(mut self, space_id: impl Into<String>) -> Self {
161        self.space_id = space_id.into();
162        self
163    }
164}
165
166impl Embedder for OpenAiCompatEmbedder {
167    fn space_id(&self) -> &str {
168        &self.space_id
169    }
170
171    fn dim(&self) -> usize {
172        self.dim
173    }
174
175    fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
176        if texts.is_empty() {
177            return Ok(Vec::new());
178        }
179        let body = serde_json::json!({ "model": self.model, "input": texts });
180        let mut request = self.agent.post(&self.url);
181        if let Some(key) = &self.api_key {
182            request = request.header("Authorization", &format!("Bearer {key}"));
183        }
184        let mut response = request
185            .send_json(&body)
186            .map_err(|e| HostError::Embed(format!("request to {}: {e}", self.url)))?;
187        let value: serde_json::Value = response
188            .body_mut()
189            .read_json()
190            .map_err(|e| HostError::Embed(format!("response body: {e}")))?;
191
192        // { "data": [ { "index": i, "embedding": [f32...] }, ... ] } —
193        // placed by the `index` field, per the contract (providers may
194        // reorder).
195        let data = value
196            .get("data")
197            .and_then(|d| d.as_array())
198            .ok_or_else(|| HostError::Embed("response has no data array".into()))?;
199        if data.len() != texts.len() {
200            return Err(HostError::Embed(format!(
201                "expected {} embeddings, got {}",
202                texts.len(),
203                data.len()
204            )));
205        }
206        let mut out = vec![Vec::new(); texts.len()];
207        for item in data {
208            let index = item
209                .get("index")
210                .and_then(|i| i.as_u64())
211                .ok_or_else(|| HostError::Embed("embedding without an index".into()))?
212                as usize;
213            let raw = item
214                .get("embedding")
215                .and_then(|e| e.as_array())
216                .ok_or_else(|| HostError::Embed("embedding is not an array".into()))?;
217            if index >= out.len() || !out[index].is_empty() {
218                return Err(HostError::Embed(format!("bad embedding index {index}")));
219            }
220            if raw.len() != self.dim {
221                return Err(HostError::Embed(format!(
222                    "dimension mismatch: server sent {}, configured {}",
223                    raw.len(),
224                    self.dim
225                )));
226            }
227            let mut v = Vec::with_capacity(raw.len());
228            for x in raw {
229                v.push(
230                    x.as_f64().ok_or_else(|| {
231                        HostError::Embed("embedding component is not a number".into())
232                    })? as f32,
233                );
234            }
235            out[index] = v;
236        }
237        Ok(out)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    use std::sync::atomic::{AtomicUsize, Ordering};
246
247    /// Counts its calls, so a test can tell one shared provider from several
248    /// independent ones. State behind an atomic because `embed` takes `&self`
249    /// — the arrangement the trait asks a stateful implementation to make.
250    struct Counting(AtomicUsize);
251    impl Embedder for Counting {
252        fn space_id(&self) -> &str {
253            "test/counting"
254        }
255
256        fn dim(&self) -> usize {
257            3
258        }
259        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
260            let total = self.0.fetch_add(texts.len(), Ordering::Relaxed) + texts.len();
261            Ok(vec![vec![total as f32; 3]; texts.len()])
262        }
263    }
264
265    /// Blocks for a moment and records how many calls were inside `embed` at
266    /// once, which is what a mutex in front of it would hold at one.
267    struct Overlapping {
268        inside: AtomicUsize,
269        peak: AtomicUsize,
270    }
271    impl Embedder for Overlapping {
272        fn space_id(&self) -> &str {
273            "test/overlapping"
274        }
275
276        fn dim(&self) -> usize {
277            1
278        }
279        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
280            let now = self.inside.fetch_add(1, Ordering::SeqCst) + 1;
281            self.peak.fetch_max(now, Ordering::SeqCst);
282            std::thread::sleep(std::time::Duration::from_millis(50));
283            self.inside.fetch_sub(1, Ordering::SeqCst);
284            Ok(vec![vec![0.0]; texts.len()])
285        }
286    }
287
288    #[test]
289    fn clones_of_a_shared_embedder_reach_the_same_provider() {
290        let shared = SharedEmbedder::new(Box::new(Counting(AtomicUsize::new(0))));
291        let a = shared.clone();
292        let b = shared.clone();
293
294        assert_eq!(a.dim(), 3);
295        assert_eq!(format!("{shared:?}"), "SharedEmbedder { dim: 3 }");
296
297        // Two databases' worth of handles, one counter behind them: the second
298        // call sees the first one's effect.
299        assert_eq!(a.embed(&["x"]).unwrap(), vec![vec![1.0; 3]]);
300        assert_eq!(b.embed(&["y", "z"]).unwrap(), vec![vec![3.0; 3]; 2]);
301    }
302
303    #[test]
304    fn concurrent_callers_are_inside_the_provider_at_the_same_time() {
305        // The invariant the `&self` signature exists for. Under the old
306        // `&mut self` trait this could not be written at all: the `Mutex` that
307        // a shared embedder needed held `peak` at 1, and four concurrent
308        // recalls against a slow provider cost four round trips.
309        let provider = std::sync::Arc::new(Overlapping {
310            inside: AtomicUsize::new(0),
311            peak: AtomicUsize::new(0),
312        });
313        let shared = SharedEmbedder(provider.clone());
314
315        std::thread::scope(|scope| {
316            for _ in 0..4 {
317                let handle = shared.clone();
318                scope.spawn(move || handle.embed(&["question"]).unwrap());
319            }
320        });
321
322        assert!(
323            provider.peak.load(Ordering::SeqCst) > 1,
324            "callers serialized: peak concurrency was {}",
325            provider.peak.load(Ordering::SeqCst)
326        );
327    }
328
329    #[test]
330    fn the_null_embedder_produces_one_empty_vector_per_text() {
331        let null = NullEmbedder;
332        assert_eq!(null.dim(), 0);
333        assert_eq!(null.embed(&["a", "b"]).unwrap(), vec![Vec::<f32>::new(); 2]);
334    }
335}