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 on the
199/// next `get` call; under macOS `phys_footprint` the drop reclaims
200/// ~365-585 MiB cleanly (the post-drop floor is ~107 MiB regardless of
201/// backend). Reload cost is one synchronous model-load (300-500 ms),
202/// absorbed inside the human-paced gap between MCP queries.
203pub struct LazyEmbedder {
204 loader: EmbedLoader,
205 state: Mutex<Option<CachedBackend>>,
206 idle_threshold: Duration,
207}
208
209impl std::fmt::Debug for LazyEmbedder {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("LazyEmbedder")
212 .field("idle_threshold", &self.idle_threshold)
213 .finish_non_exhaustive()
214 }
215}
216
217impl LazyEmbedder {
218 /// candle XLM-RoBERTa FP16 (Metal on macOS / CUDA with `--features cuda`
219 /// / CPU otherwise). The pond default for every entry point.
220 pub fn candle() -> Self {
221 Self::with_loader(Arc::new(|| {
222 Ok(Arc::new(CandleEmbedder::load()?) as Arc<dyn Embedder>)
223 }))
224 }
225
226 /// Build a `LazyEmbedder` from an explicit loader. Used by the bench
227 /// harness to override the idle threshold; production callers use
228 /// [`Self::candle`].
229 pub fn with_loader(loader: EmbedLoader) -> Self {
230 Self {
231 loader,
232 state: Mutex::new(None),
233 idle_threshold: DEFAULT_IDLE_EVICTION,
234 }
235 }
236
237 /// Override the idle-eviction threshold. Pass `Duration::MAX` to disable
238 /// eviction entirely - useful in benches that want a stable steady-state.
239 #[must_use]
240 pub fn with_idle_threshold(mut self, threshold: Duration) -> Self {
241 self.idle_threshold = threshold;
242 self
243 }
244
245 /// Pre-seed with an already-constructed backend. Used by integration
246 /// tests that want to inject a fake `Embedder` without paying the real
247 /// model-load cost. Eviction is disabled so the test fake survives the
248 /// whole test even if a test stalls.
249 pub fn from_loaded(backend: Arc<dyn Embedder>) -> Self {
250 let preloaded = Arc::clone(&backend);
251 let loader: EmbedLoader = Arc::new(move || Ok(Arc::clone(&preloaded)));
252 Self {
253 loader,
254 state: Mutex::new(Some(CachedBackend {
255 backend,
256 last_use: Instant::now(),
257 })),
258 idle_threshold: Duration::MAX,
259 }
260 }
261
262 /// Load (on first call or after eviction) or return the cached handle.
263 /// The candle load is synchronous and blocking, so it runs on
264 /// `spawn_blocking`; the async caller sees a clean `await` point.
265 pub async fn get(&self) -> Result<Arc<dyn Embedder>> {
266 let mut state = self.state.lock().await;
267 let now = Instant::now();
268 if let Some(cached) = &*state
269 && now.duration_since(cached.last_use) > self.idle_threshold
270 {
271 tracing::info!(
272 idle_secs = self.idle_threshold.as_secs(),
273 "evicting idle embedder",
274 );
275 *state = None;
276 }
277 if let Some(cached) = state.as_mut() {
278 cached.last_use = now;
279 return Ok(Arc::clone(&cached.backend));
280 }
281 let loader = Arc::clone(&self.loader);
282 let backend = tokio::task::spawn_blocking(move || loader())
283 .await
284 .map_err(|join_error| anyhow!("embedder load panicked: {join_error}"))??;
285 *state = Some(CachedBackend {
286 backend: Arc::clone(&backend),
287 last_use: now,
288 });
289 Ok(backend)
290 }
291}
292
293/// Default embedding model pond ships a loader for (spec.md#search). Used when
294/// `[embeddings].model` is absent. `pond optimize` stamps the runtime model id
295/// (see [`model_id`]) into `messages.embedding_model` with every vector.
296/// e5-small (384-dim) is the default; the paraphrase benchmark set showed no
297/// statistically-significant quality loss vs e5-base while halving vector
298/// storage and ~halving model RSS.
299pub const DEFAULT_MODEL_ID: &str = "intfloat/multilingual-e5-small";
300
301/// Process-wide model id, seeded once at startup from `[embeddings].model` via
302/// [`init_model_id`]. `OnceLock` (not `const`) so a temporary config file can
303/// pick e5-small / e5-large for an experiment without touching every call site.
304/// Uninitialized -> [`DEFAULT_MODEL_ID`], keeping unit tests config-free.
305static MODEL_ID_RUNTIME: OnceLock<String> = OnceLock::new();
306
307/// The active model id. Returns the value installed by [`init_model_id`] or
308/// [`DEFAULT_MODEL_ID`] when nothing has installed one (tests, ad-hoc tooling).
309pub fn model_id() -> &'static str {
310 MODEL_ID_RUNTIME
311 .get()
312 .map(String::as_str)
313 .unwrap_or(DEFAULT_MODEL_ID)
314}
315
316/// Seed [`model_id`] from config. First call wins; later calls with a different
317/// id are silently ignored - the process loads its config once.
318pub fn init_model_id(id: String) {
319 MODEL_ID_RUNTIME.get_or_init(|| id);
320}
321
322/// Messages per model-inference + write batch. e5 truncates at 512 tokens, so
323/// a 32-row batch's padded attention transient stays bounded.
324pub const DEFAULT_BATCH_SIZE: usize = 32;
325
326/// Messages buffered and length-sorted before being cut into model batches.
327/// The tokenizer pads every batch to its longest member, so a batch mixing a short
328/// and a long message embeds the short one at the long one's length. Sorting a
329/// window first clusters similar-length messages, so each batch pads near its
330/// own longest, not the corpus worst case. Bounded so peak memory stays one
331/// window, not the whole backlog. See [`EmbedWorker::with_sort_window`].
332pub const DEFAULT_SORT_WINDOW: usize = 2048;
333
334/// Format a search query for the embedder. e5 is an asymmetric retriever:
335/// its model card prescribes `query: ` on the search side, `passage: ` on
336/// documents. Used by `pond_search` to prepare the query text before the
337/// candle/Metal embed call.
338pub fn format_query(query: &str) -> String {
339 format!("query: {query}")
340}
341
342/// Format a document (one message's `search_text`) for the embedder - the
343/// `passage: ` half of the pair documented on [`format_query`].
344pub fn format_passage(text: &str) -> String {
345 format!("passage: {text}")
346}
347
348/// Embed `texts` as documents, returning one vector per input in input order.
349/// Length-sorts before chunking into `batch_size` model calls so each padded
350/// batch clusters similar lengths (the tokenizer pads to the batch's longest
351/// member); `on_batch` fires once per model call with that call's size. Shared
352/// by [`EmbedWorker`] (backlog) and the ingest write path (inline embed) so a
353/// vector is byte-identical whichever path produced it.
354pub(crate) fn embed_passages(
355 backend: &dyn Embedder,
356 texts: &[&str],
357 batch_size: usize,
358 mut on_batch: impl FnMut(usize),
359) -> Result<Vec<Vec<f32>>> {
360 let mut order: Vec<usize> = (0..texts.len()).collect();
361 order.sort_unstable_by_key(|&index| texts[index].len());
362 let mut out: Vec<Vec<f32>> = vec![Vec::new(); texts.len()];
363 for chunk in order.chunks(batch_size.max(1)) {
364 let batch = chunk
365 .iter()
366 .map(|&index| format_passage(texts[index]))
367 .collect::<Vec<_>>();
368 let vectors = backend.embed(&batch)?;
369 if vectors.len() != chunk.len() {
370 return Err(anyhow!(
371 "backend returned {} vectors for {} messages",
372 vectors.len(),
373 chunk.len(),
374 ));
375 }
376 for (&index, vector) in chunk.iter().zip(vectors) {
377 out[index] = vector;
378 }
379 on_batch(chunk.len());
380 }
381 Ok(out)
382}
383
384/// The embedding seam (spec.md#search): text in, vectors out. The real
385/// backend is [`CandleEmbedder`]; tests substitute an instrumented fake
386/// to assert batching behavior. The vector width is checked at the write
387/// boundary and the model id is whatever [`model_id`] returns at the
388/// time of the write.
389pub trait Embedder: Send + Sync {
390 /// A short label naming the hardware/runtime: `"metal"`, `"cuda"`,
391 /// or `"cpu"`. Used by `pond optimize` to surface what backend ran the
392 /// inference; benches print it alongside latency.
393 fn device(&self) -> &str;
394
395 /// Embed a batch of texts. The returned vectors are L2-normalized and
396 /// [`embedding_dim`] long, one per input.
397 fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
398}
399
400/// Outcome of an [`EmbedWorker::run`] pass.
401#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
402pub struct EmbedSummary {
403 /// Messages embedded; one vector each.
404 pub messages: usize,
405 /// Model-inference + write batches issued.
406 pub batches: usize,
407 /// Set when the run exited via the cancel flag instead of stream end -
408 /// the caller uses this to print an interrupted notice and decide whether
409 /// to still rebuild downstream indices.
410 pub cancelled: bool,
411}
412
413/// Per-batch stats handed to a progress callback. Lets `pond optimize` drive an
414/// `indicatif` bar without leaking the crate into this module's API.
415#[derive(Debug, Clone, Copy)]
416pub struct BatchProgress {
417 /// Messages embedded in this batch.
418 pub batch_messages: usize,
419 /// Running message total across the run.
420 pub total_messages: usize,
421 /// Running batch count across the run.
422 pub total_batches: usize,
423}
424
425type ProgressFn = Box<dyn Fn(BatchProgress) + Send + Sync>;
426
427/// Fills `messages.vector` / `messages.embedding_model` for the backlog of
428/// un-embedded messages. Reads `messages.search_text` directly, batches it
429/// through the backend one vector each, and writes each batch back to
430/// `messages` by primary key.
431pub struct EmbedWorker<'a, B: Embedder> {
432 store: &'a Store,
433 backend: &'a B,
434 include_stale: bool,
435 /// Optional cap on total messages embedded in one `run` - `None` in
436 /// production (embed everything), set by the benchmark harness to a fixed
437 /// count so a run is a stable, comparable workload.
438 limit: Option<usize>,
439 /// Messages buffered and length-sorted per `drain_window` pass
440 /// ([`DEFAULT_SORT_WINDOW`]); the benchmark sweeps it through
441 /// [`EmbedWorker::with_sort_window`].
442 sort_window: usize,
443 /// Messages per model-inference batch ([`DEFAULT_BATCH_SIZE`]); the
444 /// benchmark sweeps it through [`EmbedWorker::with_batch_size`] to size
445 /// the inference-throughput vs padding-waste trade-off.
446 batch_size: usize,
447 /// Optional per-batch progress callback. Called once per `flush()` with
448 /// the running totals; `pond optimize` wires this to an `indicatif` bar.
449 progress: Option<ProgressFn>,
450 /// Set externally (Ctrl-C handler in `pond optimize`): the pull loop drains
451 /// the in-memory window before exiting so partial work is committed.
452 cancel: Option<Arc<AtomicBool>>,
453}
454
455impl<'a, B: Embedder> EmbedWorker<'a, B> {
456 /// Build a worker over `store`'s un-embedded backlog. A backend whose
457 /// vectors are the wrong width is rejected at the write boundary
458 /// (`embedding_update_batch`), so there is nothing to validate here.
459 pub fn new(store: &'a Store, backend: &'a B) -> Self {
460 Self {
461 store,
462 backend,
463 include_stale: false,
464 limit: None,
465 sort_window: DEFAULT_SORT_WINDOW,
466 batch_size: DEFAULT_BATCH_SIZE,
467 progress: None,
468 cancel: None,
469 }
470 }
471
472 /// Honour `flag` as a cooperative cancellation signal. The pull loop checks
473 /// it before each new stream message; once set, the worker drains the
474 /// current window (committing the embedded slice) and returns with
475 /// `EmbedSummary { cancelled: true, .. }`. `pond optimize` wires this to a
476 /// Ctrl-C handler so an interrupted run doesn't lose its in-memory window.
477 pub fn with_cancel(mut self, flag: Arc<AtomicBool>) -> Self {
478 self.cancel = Some(flag);
479 self
480 }
481
482 fn cancelled(&self) -> bool {
483 self.cancel
484 .as_ref()
485 .is_some_and(|f| f.load(Ordering::Relaxed))
486 }
487
488 /// Override the length-sort window (default [`DEFAULT_SORT_WINDOW`]). The
489 /// benchmark harness sweeps this to size the padding-waste vs. throughput
490 /// trade-off; a window of [`DEFAULT_BATCH_SIZE`] disables sorting.
491 pub fn with_sort_window(mut self, window: usize) -> Self {
492 self.sort_window = window.max(self.batch_size);
493 self
494 }
495
496 /// Override the model-inference batch size (default [`DEFAULT_BATCH_SIZE`]).
497 /// The benchmark harness sweeps this to size inference throughput vs the
498 /// padded-attention memory transient; larger batches amortize per-call
499 /// overhead but pad more aggressively.
500 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
501 self.batch_size = batch_size.max(1);
502 self.sort_window = self.sort_window.max(self.batch_size);
503 self
504 }
505
506 /// Register a per-batch progress callback. Called once after each
507 /// `flush()` with the messages in the just-finished batch and the running
508 /// totals. `pond optimize` uses this to drive an `indicatif` progress bar.
509 pub fn with_progress(
510 mut self,
511 callback: impl Fn(BatchProgress) + Send + Sync + 'static,
512 ) -> Self {
513 self.progress = Some(Box::new(callback));
514 self
515 }
516
517 /// Cap the run at `limit` messages (default: no cap). The benchmark harness
518 /// uses this to embed a fixed, comparable slice of a corpus.
519 pub fn with_limit(mut self, limit: usize) -> Self {
520 self.limit = Some(limit.max(1));
521 self
522 }
523
524 pub fn include_stale(mut self) -> Self {
525 self.include_stale = true;
526 self
527 }
528
529 /// Embed every message whose `vector` is still null. Idempotent: a re-run
530 /// over an already-embedded corpus finds an empty backlog and is a no-op.
531 ///
532 /// Messages are pulled from a streaming scan, so peak memory is one stream
533 /// page plus the staged batch - not the whole corpus.
534 pub async fn run(&self) -> Result<EmbedSummary> {
535 let mut summary = EmbedSummary::default();
536 let mut window: Vec<PendingMessage> = Vec::with_capacity(self.sort_window);
537 let mut pulled = 0usize;
538
539 let mut stream = if self.include_stale {
540 Box::pin(self.store.pending_or_stale_messages())
541 as std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<PendingMessage>> + '_>>
542 } else {
543 Box::pin(self.store.pending_embedding_messages())
544 as std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<PendingMessage>> + '_>>
545 };
546 while let Some(pending) = stream.next().await {
547 // Stop pulling once the message cap is reached or cancellation
548 // fires; the staged window is still drained below, so the
549 // already-embedded slice commits cleanly.
550 if self.limit.is_some_and(|limit| pulled >= limit) || self.cancelled() {
551 break;
552 }
553 window.push(pending?);
554 pulled += 1;
555 if window.len() >= self.sort_window {
556 self.drain_window(&mut window, &mut summary).await?;
557 }
558 }
559 self.drain_window(&mut window, &mut summary).await?;
560 summary.cancelled = self.cancelled();
561
562 tracing::info!(
563 model = model_id(),
564 messages = summary.messages,
565 batches = summary.batches,
566 cancelled = summary.cancelled,
567 "embed worker finished",
568 );
569 Ok(summary)
570 }
571
572 /// One `merge_update` per window: it streams the target column once, so
573 /// amortizing it over a window-sized batch beats issuing it per model batch
574 /// (`embed_passages` does the per-batch length-sort). Empties `window`.
575 async fn drain_window(
576 &self,
577 window: &mut Vec<PendingMessage>,
578 summary: &mut EmbedSummary,
579 ) -> Result<()> {
580 if window.is_empty() {
581 return Ok(());
582 }
583 let pending = std::mem::take(window);
584 let texts = pending
585 .iter()
586 .map(|message| message.search_text.as_str())
587 .collect::<Vec<_>>();
588 let vectors = embed_passages(self.backend, &texts, self.batch_size, |batch_messages| {
589 summary.messages += batch_messages;
590 summary.batches += 1;
591 if let Some(progress) = &self.progress {
592 progress(BatchProgress {
593 batch_messages,
594 total_messages: summary.messages,
595 total_batches: summary.batches,
596 });
597 }
598 })?;
599 let rows = pending
600 .into_iter()
601 .zip(vectors)
602 .map(|(message, vector)| EmbeddedMessage {
603 session_id: message.session_id,
604 id: message.id,
605 vector,
606 })
607 .collect::<Vec<_>>();
608 if !rows.is_empty() {
609 self.store.write_embeddings(&rows).await?;
610 }
611 Ok(())
612 }
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used)]
617mod tests {
618 use super::*;
619 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
620
621 #[test]
622 fn e5_prefixes_apply_the_asymmetric_retrieval_pair() {
623 assert_eq!(
624 format_query("how does retry backoff work"),
625 "query: how does retry backoff work",
626 );
627 assert_eq!(
628 format_passage("retry uses exponential backoff"),
629 "passage: retry uses exponential backoff",
630 );
631 }
632
633 /// Counts how many times `LazyEmbedder` invokes its loader. Lets the
634 /// idle-eviction test detect reloads without spinning up a real model.
635 struct CountingEmbedder;
636 impl Embedder for CountingEmbedder {
637 fn device(&self) -> &str {
638 "test"
639 }
640 fn embed(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>> {
641 Ok(vec![])
642 }
643 }
644
645 /// `LazyEmbedder` keys eviction on `std::time::Instant`, which isn't
646 /// affected by `tokio::time::pause`. The test uses a tiny real
647 /// threshold so the suite runs in <100 ms.
648 #[tokio::test(flavor = "multi_thread")]
649 async fn lazy_embedder_evicts_after_idle_threshold() {
650 let loads = Arc::new(AtomicUsize::new(0));
651 let counter = Arc::clone(&loads);
652 let loader: EmbedLoader = Arc::new(move || {
653 counter.fetch_add(1, AtomicOrdering::SeqCst);
654 Ok(Arc::new(CountingEmbedder) as Arc<dyn Embedder>)
655 });
656 let embedder =
657 LazyEmbedder::with_loader(loader).with_idle_threshold(Duration::from_millis(20));
658
659 embedder.get().await.unwrap();
660 assert_eq!(
661 loads.load(AtomicOrdering::SeqCst),
662 1,
663 "first get loads once"
664 );
665
666 embedder.get().await.unwrap();
667 assert_eq!(
668 loads.load(AtomicOrdering::SeqCst),
669 1,
670 "back-to-back get reuses the cached backend",
671 );
672
673 tokio::time::sleep(Duration::from_millis(60)).await;
674 embedder.get().await.unwrap();
675 assert_eq!(
676 loads.load(AtomicOrdering::SeqCst),
677 2,
678 "get after the idle threshold triggers a reload",
679 );
680 }
681
682 #[tokio::test(flavor = "multi_thread")]
683 async fn lazy_embedder_from_loaded_never_evicts() {
684 let preloaded = LazyEmbedder::from_loaded(Arc::new(CountingEmbedder));
685 preloaded.get().await.unwrap();
686 // Wait past any reasonable threshold; the from_loaded path uses
687 // Duration::MAX so the fake stays alive for the whole test.
688 tokio::time::sleep(Duration::from_millis(60)).await;
689 preloaded.get().await.unwrap();
690 }
691}