Skip to main content

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}