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}