pond/embed.rs
1//! The embedding stage: candle XLM-RoBERTa FP16 ([`CandleEmbedder`]) plus
2//! the batch-oriented [`EmbedWorker`] that fills `messages.vector` /
3//! `messages.embedding_model` (spec.md#search). One message produces one
4//! vector - there is no chunking.
5//!
6//! [`LazyEmbedder`] caches a loaded backend for `pond mcp` / `pond serve`
7//! and drops it after [`DEFAULT_IDLE_EVICTION`] of no use. The drop is
8//! clean under macOS `phys_footprint` (post-drop drops to ~107 MiB
9//! regardless of backend), so time-weighted RSS over an interactive MCP
10//! session stays well under the per-instance budget despite the macOS
11//! Metal buffer pool's `iokit_mapped` retention during active queries.
12//!
13//! The worker accumulates messages and calls the model once per fixed-size
14//! batch, never once per message, and writes each batch's vectors to
15//! `messages` in one column-update commit.
16
17use std::sync::Arc;
18use std::sync::OnceLock;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::time::{Duration, Instant};
21
22use anyhow::{Context, Result, anyhow};
23use candle_core::{DType, Device, Tensor};
24use candle_nn::VarBuilder;
25use candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel};
26use tokenizers::Tokenizer;
27use tokio::sync::Mutex;
28use tokio_stream::StreamExt;
29
30use crate::sessions::{EmbeddedMessage, PendingMessage, Store, embedding_dim};
31
32/// e5's training context. The tokenizer truncates input past it before
33/// inference - one message, one vector, bounded embed cost.
34pub(crate) const MAX_TOKENS: usize = 512;
35
36/// The candle e5 backend: XLM-RoBERTa FP16 weights on the GPU (Metal on
37/// macOS, CUDA on a `cuda`-feature non-macOS build, CPU otherwise).
38/// `forward` is `&self`, so no interior mutability is needed.
39pub struct CandleEmbedder {
40 model: XLMRobertaModel,
41 tokenizer: Tokenizer,
42 device: Device,
43}
44
45impl CandleEmbedder {
46 /// Load the configured XLM-RoBERTa model from HuggingFace (cached after
47 /// the first download) onto the best available device.
48 pub fn load() -> Result<Self> {
49 let device = select_device();
50 let id = model_id();
51 let api = hf_hub::api::sync::Api::new().context("init HuggingFace hub client")?;
52 let repo = api.model(id.to_owned());
53 // The weights are ~500 MB and the ureq-only hf-hub build renders no
54 // download progress, so a first run looks hung for minutes without
55 // this notice. Cache-hit runs stay silent.
56 if hf_hub::Cache::default()
57 .model(id.to_owned())
58 .get("model.safetensors")
59 .is_none()
60 {
61 let _ = crate::output::line_err(&format!(
62 "downloading embedding model {id} (~500 MB, one-time; cached under ~/.cache/huggingface)..."
63 ));
64 }
65 let fetch = |file: &str| {
66 repo.get(file)
67 .with_context(|| format!("fetch {file} for {id}"))
68 };
69
70 let config: Config =
71 serde_json::from_str(&std::fs::read_to_string(fetch("config.json")?)?)?;
72 if config.hidden_size != embedding_dim() {
73 return Err(anyhow!(
74 "[embeddings].dim = {} but model {id:?} reports hidden_size = {}; \
75 set [embeddings].dim to match the model's output width.",
76 embedding_dim(),
77 config.hidden_size,
78 ));
79 }
80 // mmap the safetensors file: candle's `safetensors::load` path uses
81 // `std::fs::read` which retains an owned `Vec<u8>` of the full FP32
82 // weights in the system allocator after drop on macOS. mmap avoids
83 // the owned-heap path. Note: candle's Metal pool retains FP32->F16
84 // cast transients regardless (iokit_mapped contribution to
85 // phys_footprint, candle-core/src/metal_backend/device.rs:44-57).
86 let model_path = fetch("model.safetensors")?;
87 #[allow(unsafe_code)]
88 let vb =
89 unsafe { VarBuilder::from_mmaped_safetensors(&[model_path], DType::F16, &device)? };
90 let model = XLMRobertaModel::new(&config, vb)
91 .map_err(|error| anyhow!("load {id} weights: {error}"))?;
92
93 let mut tokenizer = Tokenizer::from_file(fetch("tokenizer.json")?)
94 .map_err(|error| anyhow!("load e5 tokenizer: {error}"))?;
95 tokenizer.with_padding(Some(tokenizers::PaddingParams {
96 strategy: tokenizers::PaddingStrategy::BatchLongest,
97 pad_id: config.pad_token_id,
98 ..Default::default()
99 }));
100 tokenizer
101 .with_truncation(Some(tokenizers::TruncationParams {
102 max_length: MAX_TOKENS,
103 ..Default::default()
104 }))
105 .map_err(|error| anyhow!("configure e5 tokenizer: {error}"))?;
106
107 tracing::info!(model = %id, device = device_label(&device), "loaded embedding model");
108 Ok(Self {
109 model,
110 tokenizer,
111 device,
112 })
113 }
114}
115
116impl Embedder for CandleEmbedder {
117 fn device(&self) -> &str {
118 device_label(&self.device)
119 }
120
121 fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
122 if texts.is_empty() {
123 return Ok(Vec::new());
124 }
125 let encodings = self
126 .tokenizer
127 .encode_batch(texts.to_vec(), true)
128 .map_err(|error| anyhow!("tokenize embedding batch: {error}"))?;
129 let mut ids = Vec::with_capacity(encodings.len());
130 let mut masks = Vec::with_capacity(encodings.len());
131 for encoding in &encodings {
132 ids.push(Tensor::new(encoding.get_ids(), &self.device)?);
133 masks.push(Tensor::new(encoding.get_attention_mask(), &self.device)?);
134 }
135 let input_ids = Tensor::stack(&ids, 0)?;
136 let attention_mask = Tensor::stack(&masks, 0)?;
137 let token_type_ids = input_ids.zeros_like()?;
138 let hidden = self
139 .model
140 .forward(
141 &input_ids,
142 &attention_mask,
143 &token_type_ids,
144 None,
145 None,
146 None,
147 )?
148 .to_dtype(DType::F32)?;
149 let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?;
150 let summed = hidden.broadcast_mul(&mask)?.sum(1)?;
151 let counts = mask.sum(1)?;
152 let mean = summed.broadcast_div(&counts)?;
153 let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?;
154 mean.broadcast_div(&norm)?
155 .to_vec2::<f32>()
156 .map_err(|error| anyhow!("read embedding vectors: {error}"))
157 }
158}
159
160fn select_device() -> Device {
161 #[cfg(target_os = "macos")]
162 let device = Device::metal_if_available(0);
163 #[cfg(not(target_os = "macos"))]
164 let device = Device::cuda_if_available(0);
165 device.unwrap_or_else(|error| {
166 tracing::warn!(%error, "GPU device unavailable, falling back to CPU");
167 Device::Cpu
168 })
169}
170
171fn device_label(device: &Device) -> &'static str {
172 match device {
173 Device::Cpu => "cpu",
174 Device::Cuda(_) => "cuda",
175 Device::Metal(_) => "metal",
176 }
177}
178
179/// Arc-shared factory used by [`LazyEmbedder`] to build the backend on
180/// first call (or on reload after idle eviction). Arc so the loader can be
181/// cloned into `spawn_blocking` without consuming `&self`.
182type EmbedLoader = Arc<dyn Fn() -> Result<Arc<dyn Embedder>> + Send + Sync>;
183
184/// How long the cached backend can sit unused before [`LazyEmbedder::get`]
185/// drops it. One minute returns the ~790 MB model to the idle floor quickly
186/// between interactive-MCP bursts; the reload is one cached model-load
187/// (~358 ms) on the first query after a quiet window.
188pub const DEFAULT_IDLE_EVICTION: Duration = Duration::from_secs(60);
189
190struct CachedBackend {
191 backend: Arc<dyn Embedder>,
192 last_use: Instant,
193}
194
195/// Lazy holder for an [`Embedder`] with idle eviction. The model isn't
196/// loaded until the first hybrid/vector call asks for it - idle `pond mcp`
197/// / `pond serve` processes pay nothing while no vector queries land. After
198/// `idle_threshold` of inactivity the cached backend is dropped - by the
199/// background reaper ([`Self::spawn_idle_reaper`]) in the long-lived
200/// serve/mcp processes, else on the next `get` call; under macOS
201/// `phys_footprint` the drop reclaims
202/// ~365-585 MiB cleanly (the post-drop floor is ~107 MiB regardless of
203/// backend). Reload cost is one synchronous model-load (300-500 ms),
204/// absorbed inside the human-paced gap between MCP queries.
205pub struct LazyEmbedder {
206 loader: EmbedLoader,
207 state: Mutex<Option<CachedBackend>>,
208 idle_threshold: Duration,
209}
210
211impl std::fmt::Debug for LazyEmbedder {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 f.debug_struct("LazyEmbedder")
214 .field("idle_threshold", &self.idle_threshold)
215 .finish_non_exhaustive()
216 }
217}
218
219impl LazyEmbedder {
220 /// candle XLM-RoBERTa FP16 (Metal on macOS / CUDA with `--features cuda`
221 /// / CPU otherwise). The pond default for every entry point.
222 pub fn candle() -> Self {
223 Self::with_loader(Arc::new(|| {
224 Ok(Arc::new(CandleEmbedder::load()?) as Arc<dyn Embedder>)
225 }))
226 }
227
228 /// Build a `LazyEmbedder` from an explicit loader. Used by the bench
229 /// harness to override the idle threshold; production callers use
230 /// [`Self::candle`].
231 pub fn with_loader(loader: EmbedLoader) -> Self {
232 Self {
233 loader,
234 state: Mutex::new(None),
235 idle_threshold: DEFAULT_IDLE_EVICTION,
236 }
237 }
238
239 /// Override the idle-eviction threshold. Pass `Duration::MAX` to disable
240 /// eviction entirely - useful in benches that want a stable steady-state.
241 #[must_use]
242 pub fn with_idle_threshold(mut self, threshold: Duration) -> Self {
243 self.idle_threshold = threshold;
244 self
245 }
246
247 /// Pre-seed with an already-constructed backend. Used by integration
248 /// tests that want to inject a fake `Embedder` without paying the real
249 /// model-load cost. Eviction is disabled so the test fake survives the
250 /// whole test even if a test stalls.
251 pub fn from_loaded(backend: Arc<dyn Embedder>) -> Self {
252 let preloaded = Arc::clone(&backend);
253 let loader: EmbedLoader = Arc::new(move || Ok(Arc::clone(&preloaded)));
254 Self {
255 loader,
256 state: Mutex::new(Some(CachedBackend {
257 backend,
258 last_use: Instant::now(),
259 })),
260 idle_threshold: Duration::MAX,
261 }
262 }
263
264 /// Background idle eviction: without it the model dropped only on the
265 /// NEXT `get()` past the threshold, so a process whose last query was a
266 /// vector burst kept ~500 MiB resident indefinitely (measured ~894 MiB
267 /// RSS settling). A tick makes "zero cost when idle" literally true.
268 /// In-flight embeds are unaffected: they hold their own `Arc` clone, so
269 /// reaping drops only the cache's reference; `last_use` refreshes on
270 /// every `get()`.
271 pub fn spawn_idle_reaper(self: &Arc<Self>) {
272 let this = Arc::clone(self);
273 tokio::spawn(async move {
274 // Floor keeps a tiny test threshold from busy-looping; cap keeps
275 // worst-case reap latency at threshold + 30s.
276 let tick = this
277 .idle_threshold
278 .min(Duration::from_secs(30))
279 .max(Duration::from_millis(10));
280 loop {
281 tokio::time::sleep(tick).await;
282 let mut state = this.state.lock().await;
283 if let Some(cached) = &*state
284 && Instant::now().duration_since(cached.last_use) > this.idle_threshold
285 {
286 tracing::info!(
287 idle_secs = this.idle_threshold.as_secs(),
288 "evicting idle embedder",
289 );
290 *state = None;
291 }
292 }
293 });
294 }
295
296 /// Load (on first call or after eviction) or return the cached handle.
297 /// The candle load is synchronous and blocking, so it runs on
298 /// `spawn_blocking`; the async caller sees a clean `await` point.
299 pub async fn get(&self) -> Result<Arc<dyn Embedder>> {
300 let mut state = self.state.lock().await;
301 let now = Instant::now();
302 if let Some(cached) = &*state
303 && now.duration_since(cached.last_use) > self.idle_threshold
304 {
305 tracing::info!(
306 idle_secs = self.idle_threshold.as_secs(),
307 "evicting idle embedder",
308 );
309 *state = None;
310 }
311 if let Some(cached) = state.as_mut() {
312 cached.last_use = now;
313 return Ok(Arc::clone(&cached.backend));
314 }
315 let loader = Arc::clone(&self.loader);
316 let backend = tokio::task::spawn_blocking(move || loader())
317 .await
318 .map_err(|join_error| anyhow!("embedder load panicked: {join_error}"))??;
319 *state = Some(CachedBackend {
320 backend: Arc::clone(&backend),
321 last_use: now,
322 });
323 Ok(backend)
324 }
325}
326
327/// Default embedding model pond ships a loader for (spec.md#search). Used when
328/// `[embeddings].model` is absent. `pond optimize` stamps the runtime model id
329/// (see [`model_id`]) into `messages.embedding_model` with every vector.
330/// e5-small (384-dim) is the default; the paraphrase benchmark set showed no
331/// statistically-significant quality loss vs e5-base while halving vector
332/// storage and ~halving model RSS.
333pub const DEFAULT_MODEL_ID: &str = "intfloat/multilingual-e5-small";
334
335/// Process-wide model id, seeded once at startup from `[embeddings].model` via
336/// [`init_model_id`]. `OnceLock` (not `const`) so a temporary config file can
337/// pick e5-small / e5-large for an experiment without touching every call site.
338/// Uninitialized -> [`DEFAULT_MODEL_ID`], keeping unit tests config-free.
339static MODEL_ID_RUNTIME: OnceLock<String> = OnceLock::new();
340
341/// The active model id. Returns the value installed by [`init_model_id`] or
342/// [`DEFAULT_MODEL_ID`] when nothing has installed one (tests, ad-hoc tooling).
343pub fn model_id() -> &'static str {
344 MODEL_ID_RUNTIME
345 .get()
346 .map(String::as_str)
347 .unwrap_or(DEFAULT_MODEL_ID)
348}
349
350/// Seed [`model_id`] from config. First call wins; later calls with a different
351/// id are silently ignored - the process loads its config once.
352pub fn init_model_id(id: String) {
353 MODEL_ID_RUNTIME.get_or_init(|| id);
354}
355
356/// Messages per model-inference + write batch. e5 truncates at 512 tokens, so
357/// a 32-row batch's padded attention transient stays bounded.
358pub const DEFAULT_BATCH_SIZE: usize = 32;
359
360/// Messages buffered and length-sorted before being cut into model batches.
361/// The tokenizer pads every batch to its longest member, so a batch mixing a short
362/// and a long message embeds the short one at the long one's length. Sorting a
363/// window first clusters similar-length messages, so each batch pads near its
364/// own longest, not the corpus worst case. Bounded so peak memory stays one
365/// window, not the whole backlog. See [`EmbedWorker::with_sort_window`].
366pub const DEFAULT_SORT_WINDOW: usize = 2048;
367
368/// Format a search query for the embedder. e5 is an asymmetric retriever:
369/// its model card prescribes `query: ` on the search side, `passage: ` on
370/// documents. Used by `pond_search` to prepare the query text before the
371/// candle/Metal embed call.
372pub fn format_query(query: &str) -> String {
373 format!("query: {query}")
374}
375
376/// Format a document (one message's `search_text`) for the embedder - the
377/// `passage: ` half of the pair documented on [`format_query`].
378pub fn format_passage(text: &str) -> String {
379 format!("passage: {text}")
380}
381
382/// Embed `texts` as documents, returning one vector per input in input order.
383/// Length-sorts before chunking into `batch_size` model calls so each padded
384/// batch clusters similar lengths (the tokenizer pads to the batch's longest
385/// member); `on_batch` fires once per model call with that call's size. Shared
386/// by [`EmbedWorker`] (backlog) and the ingest write path (inline embed) so a
387/// vector is byte-identical whichever path produced it.
388pub(crate) fn embed_passages(
389 backend: &dyn Embedder,
390 texts: &[&str],
391 batch_size: usize,
392 mut on_batch: impl FnMut(usize),
393) -> Result<Vec<Vec<f32>>> {
394 let mut order: Vec<usize> = (0..texts.len()).collect();
395 order.sort_unstable_by_key(|&index| texts[index].len());
396 let mut out: Vec<Vec<f32>> = vec![Vec::new(); texts.len()];
397 for chunk in order.chunks(batch_size.max(1)) {
398 let batch = chunk
399 .iter()
400 .map(|&index| format_passage(texts[index]))
401 .collect::<Vec<_>>();
402 let vectors = backend.embed(&batch)?;
403 if vectors.len() != chunk.len() {
404 return Err(anyhow!(
405 "backend returned {} vectors for {} messages",
406 vectors.len(),
407 chunk.len(),
408 ));
409 }
410 for (&index, vector) in chunk.iter().zip(vectors) {
411 out[index] = vector;
412 }
413 on_batch(chunk.len());
414 }
415 Ok(out)
416}
417
418/// The embedding seam (spec.md#search): text in, vectors out. The real
419/// backend is [`CandleEmbedder`]; tests substitute an instrumented fake
420/// to assert batching behavior. The vector width is checked at the write
421/// boundary and the model id is whatever [`model_id`] returns at the
422/// time of the write.
423pub trait Embedder: Send + Sync {
424 /// A short label naming the hardware/runtime: `"metal"`, `"cuda"`,
425 /// or `"cpu"`. Used by `pond optimize` to surface what backend ran the
426 /// inference; benches print it alongside latency.
427 fn device(&self) -> &str;
428
429 /// Embed a batch of texts. The returned vectors are L2-normalized and
430 /// [`embedding_dim`] long, one per input.
431 fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
432}
433
434/// Outcome of an [`EmbedWorker::run`] pass.
435#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
436pub struct EmbedSummary {
437 /// Messages embedded; one vector each.
438 pub messages: usize,
439 /// Model-inference + write batches issued.
440 pub batches: usize,
441 /// Set when the run exited via the cancel flag instead of stream end -
442 /// the caller uses this to print an interrupted notice and decide whether
443 /// to still rebuild downstream indices.
444 pub cancelled: bool,
445}
446
447/// Per-batch stats handed to a progress callback. Lets `pond optimize` drive an
448/// `indicatif` bar without leaking the crate into this module's API.
449#[derive(Debug, Clone, Copy)]
450pub struct BatchProgress {
451 /// Messages embedded in this batch.
452 pub batch_messages: usize,
453 /// Running message total across the run.
454 pub total_messages: usize,
455 /// Running batch count across the run.
456 pub total_batches: usize,
457}
458
459type ProgressFn = Box<dyn Fn(BatchProgress) + Send + Sync>;
460
461/// Fills `messages.vector` / `messages.embedding_model` for the backlog of
462/// un-embedded messages. Reads `messages.search_text` directly, batches it
463/// through the backend one vector each, and writes each batch back to
464/// `messages` by primary key.
465pub struct EmbedWorker<'a, B: Embedder> {
466 store: &'a Store,
467 backend: &'a B,
468 include_stale: bool,
469 /// Optional cap on total messages embedded in one `run` - `None` in
470 /// production (embed everything), set by the benchmark harness to a fixed
471 /// count so a run is a stable, comparable workload.
472 limit: Option<usize>,
473 /// Messages buffered and length-sorted per `drain_window` pass
474 /// ([`DEFAULT_SORT_WINDOW`]); the benchmark sweeps it through
475 /// [`EmbedWorker::with_sort_window`].
476 sort_window: usize,
477 /// Messages per model-inference batch ([`DEFAULT_BATCH_SIZE`]); the
478 /// benchmark sweeps it through [`EmbedWorker::with_batch_size`] to size
479 /// the inference-throughput vs padding-waste trade-off.
480 batch_size: usize,
481 /// Optional per-batch progress callback. Called once per `flush()` with
482 /// the running totals; `pond optimize` wires this to an `indicatif` bar.
483 progress: Option<ProgressFn>,
484 /// Set externally (Ctrl-C handler in `pond optimize`): the pull loop drains
485 /// the in-memory window before exiting so partial work is committed.
486 cancel: Option<Arc<AtomicBool>>,
487}
488
489impl<'a, B: Embedder> EmbedWorker<'a, B> {
490 /// Build a worker over `store`'s un-embedded backlog. A backend whose
491 /// vectors are the wrong width is rejected at the write boundary
492 /// (`embedding_update_batch`), so there is nothing to validate here.
493 pub fn new(store: &'a Store, backend: &'a B) -> Self {
494 Self {
495 store,
496 backend,
497 include_stale: false,
498 limit: None,
499 sort_window: DEFAULT_SORT_WINDOW,
500 batch_size: DEFAULT_BATCH_SIZE,
501 progress: None,
502 cancel: None,
503 }
504 }
505
506 /// Honour `flag` as a cooperative cancellation signal. The pull loop checks
507 /// it before each new stream message; once set, the worker drains the
508 /// current window (committing the embedded slice) and returns with
509 /// `EmbedSummary { cancelled: true, .. }`. `pond optimize` wires this to a
510 /// Ctrl-C handler so an interrupted run doesn't lose its in-memory window.
511 pub fn with_cancel(mut self, flag: Arc<AtomicBool>) -> Self {
512 self.cancel = Some(flag);
513 self
514 }
515
516 fn cancelled(&self) -> bool {
517 self.cancel
518 .as_ref()
519 .is_some_and(|f| f.load(Ordering::Relaxed))
520 }
521
522 /// Override the length-sort window (default [`DEFAULT_SORT_WINDOW`]). The
523 /// benchmark harness sweeps this to size the padding-waste vs. throughput
524 /// trade-off; a window of [`DEFAULT_BATCH_SIZE`] disables sorting.
525 pub fn with_sort_window(mut self, window: usize) -> Self {
526 self.sort_window = window.max(self.batch_size);
527 self
528 }
529
530 /// Override the model-inference batch size (default [`DEFAULT_BATCH_SIZE`]).
531 /// The benchmark harness sweeps this to size inference throughput vs the
532 /// padded-attention memory transient; larger batches amortize per-call
533 /// overhead but pad more aggressively.
534 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
535 self.batch_size = batch_size.max(1);
536 self.sort_window = self.sort_window.max(self.batch_size);
537 self
538 }
539
540 /// Register a per-batch progress callback. Called once after each
541 /// `flush()` with the messages in the just-finished batch and the running
542 /// totals. `pond optimize` uses this to drive an `indicatif` progress bar.
543 pub fn with_progress(
544 mut self,
545 callback: impl Fn(BatchProgress) + Send + Sync + 'static,
546 ) -> Self {
547 self.progress = Some(Box::new(callback));
548 self
549 }
550
551 /// Cap the run at `limit` messages (default: no cap). The benchmark harness
552 /// uses this to embed a fixed, comparable slice of a corpus.
553 pub fn with_limit(mut self, limit: usize) -> Self {
554 self.limit = Some(limit.max(1));
555 self
556 }
557
558 pub fn include_stale(mut self) -> Self {
559 self.include_stale = true;
560 self
561 }
562
563 /// Embed every message whose `vector` is still null. Idempotent: a re-run
564 /// over an already-embedded corpus finds an empty backlog and is a no-op.
565 ///
566 /// Messages are pulled from a streaming scan, so peak memory is one stream
567 /// page plus the staged batch - not the whole corpus.
568 pub async fn run(&self) -> Result<EmbedSummary> {
569 let mut summary = EmbedSummary::default();
570 let mut window: Vec<PendingMessage> = Vec::with_capacity(self.sort_window);
571 let mut pulled = 0usize;
572
573 let mut stream = if self.include_stale {
574 Box::pin(self.store.pending_or_stale_messages())
575 as std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<PendingMessage>> + '_>>
576 } else {
577 Box::pin(self.store.pending_embedding_messages())
578 as std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<PendingMessage>> + '_>>
579 };
580 while let Some(pending) = stream.next().await {
581 // Stop pulling once the message cap is reached or cancellation
582 // fires; the staged window is still drained below, so the
583 // already-embedded slice commits cleanly.
584 if self.limit.is_some_and(|limit| pulled >= limit) || self.cancelled() {
585 break;
586 }
587 window.push(pending?);
588 pulled += 1;
589 if window.len() >= self.sort_window {
590 self.drain_window(&mut window, &mut summary).await?;
591 }
592 }
593 self.drain_window(&mut window, &mut summary).await?;
594 summary.cancelled = self.cancelled();
595
596 tracing::info!(
597 model = model_id(),
598 messages = summary.messages,
599 batches = summary.batches,
600 cancelled = summary.cancelled,
601 "embed worker finished",
602 );
603 Ok(summary)
604 }
605
606 /// One `merge_update` per window: it streams the target column once, so
607 /// amortizing it over a window-sized batch beats issuing it per model batch
608 /// (`embed_passages` does the per-batch length-sort). Empties `window`.
609 async fn drain_window(
610 &self,
611 window: &mut Vec<PendingMessage>,
612 summary: &mut EmbedSummary,
613 ) -> Result<()> {
614 if window.is_empty() {
615 return Ok(());
616 }
617 let pending = std::mem::take(window);
618 let texts = pending
619 .iter()
620 .map(|message| message.search_text.as_str())
621 .collect::<Vec<_>>();
622 let vectors = embed_passages(self.backend, &texts, self.batch_size, |batch_messages| {
623 summary.messages += batch_messages;
624 summary.batches += 1;
625 if let Some(progress) = &self.progress {
626 progress(BatchProgress {
627 batch_messages,
628 total_messages: summary.messages,
629 total_batches: summary.batches,
630 });
631 }
632 })?;
633 let rows = pending
634 .into_iter()
635 .zip(vectors)
636 .map(|(message, vector)| EmbeddedMessage {
637 session_id: message.session_id,
638 id: message.id,
639 vector,
640 })
641 .collect::<Vec<_>>();
642 if !rows.is_empty() {
643 self.store.write_embeddings(&rows).await?;
644 }
645 Ok(())
646 }
647}
648
649#[cfg(test)]
650#[allow(clippy::unwrap_used)]
651mod tests {
652 use super::*;
653 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
654
655 #[test]
656 fn e5_prefixes_apply_the_asymmetric_retrieval_pair() {
657 assert_eq!(
658 format_query("how does retry backoff work"),
659 "query: how does retry backoff work",
660 );
661 assert_eq!(
662 format_passage("retry uses exponential backoff"),
663 "passage: retry uses exponential backoff",
664 );
665 }
666
667 /// Counts how many times `LazyEmbedder` invokes its loader. Lets the
668 /// idle-eviction test detect reloads without spinning up a real model.
669 struct CountingEmbedder;
670 impl Embedder for CountingEmbedder {
671 fn device(&self) -> &str {
672 "test"
673 }
674 fn embed(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>> {
675 Ok(vec![])
676 }
677 }
678
679 /// `LazyEmbedder` keys eviction on `std::time::Instant`, which isn't
680 /// affected by `tokio::time::pause`. The test uses a tiny real
681 /// threshold so the suite runs in <100 ms.
682 #[tokio::test(flavor = "multi_thread")]
683 async fn lazy_embedder_evicts_after_idle_threshold() {
684 let loads = Arc::new(AtomicUsize::new(0));
685 let counter = Arc::clone(&loads);
686 let loader: EmbedLoader = Arc::new(move || {
687 counter.fetch_add(1, AtomicOrdering::SeqCst);
688 Ok(Arc::new(CountingEmbedder) as Arc<dyn Embedder>)
689 });
690 let embedder =
691 LazyEmbedder::with_loader(loader).with_idle_threshold(Duration::from_millis(20));
692
693 embedder.get().await.unwrap();
694 assert_eq!(
695 loads.load(AtomicOrdering::SeqCst),
696 1,
697 "first get loads once"
698 );
699
700 embedder.get().await.unwrap();
701 assert_eq!(
702 loads.load(AtomicOrdering::SeqCst),
703 1,
704 "back-to-back get reuses the cached backend",
705 );
706
707 tokio::time::sleep(Duration::from_millis(60)).await;
708 embedder.get().await.unwrap();
709 assert_eq!(
710 loads.load(AtomicOrdering::SeqCst),
711 2,
712 "get after the idle threshold triggers a reload",
713 );
714 }
715
716 /// Same real-clock caveat as above: the reaper keys on
717 /// `std::time::Instant`, immune to `tokio::time::pause`.
718 #[tokio::test(flavor = "multi_thread")]
719 async fn idle_reaper_evicts_without_an_intervening_get() {
720 let loader: EmbedLoader = Arc::new(|| Ok(Arc::new(CountingEmbedder) as Arc<dyn Embedder>));
721 let embedder = Arc::new(
722 LazyEmbedder::with_loader(loader).with_idle_threshold(Duration::from_millis(20)),
723 );
724 embedder.spawn_idle_reaper();
725 embedder.get().await.unwrap();
726 assert!(embedder.state.lock().await.is_some());
727
728 let deadline = std::time::Instant::now() + Duration::from_secs(2);
729 while embedder.state.lock().await.is_some() && std::time::Instant::now() < deadline {
730 tokio::time::sleep(Duration::from_millis(10)).await;
731 }
732 assert!(
733 embedder.state.lock().await.is_none(),
734 "reaper dropped the idle backend with no get() call",
735 );
736 }
737
738 #[tokio::test(flavor = "multi_thread")]
739 async fn lazy_embedder_from_loaded_never_evicts() {
740 let preloaded = LazyEmbedder::from_loaded(Arc::new(CountingEmbedder));
741 preloaded.get().await.unwrap();
742 // Wait past any reasonable threshold; the from_loaded path uses
743 // Duration::MAX so the fake stays alive for the whole test.
744 tokio::time::sleep(Duration::from_millis(60)).await;
745 preloaded.get().await.unwrap();
746 }
747}