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}