Skip to main content

lattice_embed/service/
mod.rs

1//! Async embedding-service contract and native implementations.
2//!
3//! The trait defines generic, query, and passage embedding; native builds additionally expose
4//! a lazy local-inference service and an LRU caching wrapper. See `docs/service.md` for the
5//! lifecycle, prompt handling, validation rules, and cache behavior.
6
7#[cfg(feature = "native")]
8mod cached;
9#[cfg(feature = "native")]
10mod native;
11
12#[cfg(test)]
13mod tests;
14
15use crate::error::{EmbedError, Result};
16use crate::model::{EmbeddingModel, ModelConfig};
17use async_trait::async_trait;
18
19#[cfg(test)]
20std::thread_local! {
21    static VALIDATE_TEXTS_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
22}
23
24// Re-exports
25#[cfg(feature = "native")]
26pub use cached::CachedEmbeddingService;
27#[cfg(feature = "native")]
28pub use native::NativeEmbeddingService;
29
30/// **Stable**: default maximum batch size to prevent OOM.
31///
32/// This limit prevents accidentally passing huge batches that could exhaust memory.
33/// Can be overridden by using chunked calls if larger batches are needed.
34pub const DEFAULT_MAX_BATCH_SIZE: usize = 1000;
35
36/// **Stable**: maximum allowed text length in UTF-8 bytes.
37///
38/// This limit prevents OOM attacks via extremely large input texts.
39/// 32KB is sufficient for most embedding use cases while preventing abuse.
40///
41/// Multibyte text can exceed this limit well before its `chars()` count does;
42/// the guard checks `str::len()` (bytes), not character count.
43pub const MAX_TEXT_BYTES: usize = 32768;
44
45/// Deprecated alias for [`MAX_TEXT_BYTES`] — the old name implied character
46/// count, but the guard has always counted UTF-8 bytes.
47#[deprecated(
48    since = "0.7.0",
49    note = "use MAX_TEXT_BYTES; this limit counts UTF-8 bytes, not chars"
50)]
51pub const MAX_TEXT_CHARS: usize = MAX_TEXT_BYTES;
52
53/// **Stable**: role of text in asymmetric retrieval.
54///
55/// Selects query, passage, or generic preparation and cache-key namespace.
56/// See [`docs/service.md`](../../docs/service.md#trait-api-details) for retrieval-role semantics.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum EmbeddingRole {
59    /// Query / question text — may receive a query-side prompt prefix.
60    Query,
61    /// Document / passage text — may receive a passage-side prompt prefix.
62    Passage,
63    /// Generic text with no role-specific prefix (backwards-compatible default).
64    Generic,
65}
66
67impl EmbeddingRole {
68    /// Returns this role's short cache-key tag.
69    #[inline]
70    pub(crate) const fn cache_tag(self) -> &'static str {
71        match self {
72            EmbeddingRole::Query => "role:query",
73            EmbeddingRole::Passage => "role:passage",
74            EmbeddingRole::Generic => "role:generic",
75        }
76    }
77
78    /// Returns the instruction this role prepends for `model`, if any.
79    #[inline]
80    pub(crate) const fn instruction(self, model: EmbeddingModel) -> Option<&'static str> {
81        match self {
82            EmbeddingRole::Query => model.query_instruction(),
83            EmbeddingRole::Passage => model.document_instruction(),
84            EmbeddingRole::Generic => None,
85        }
86    }
87}
88
89/// Enforces the published request bounds against caller-supplied text.
90///
91/// This is the contract check, so it runs on what the caller actually passed
92/// and before any instruction is prepended. Guards that run downstream of
93/// preparation are memory backstops and size themselves with
94/// [`EmbeddingModel::max_instruction_bytes`]; they are not this check.
95pub(crate) fn validate_texts(texts: &[String]) -> Result<()> {
96    #[cfg(test)]
97    VALIDATE_TEXTS_CALLS.set(VALIDATE_TEXTS_CALLS.get() + 1);
98    validate_texts_bounded(texts, MAX_TEXT_BYTES)
99}
100
101#[cfg(test)]
102pub(crate) fn reset_validate_texts_calls() {
103    VALIDATE_TEXTS_CALLS.set(0);
104}
105
106#[cfg(test)]
107pub(crate) fn validate_texts_calls() -> usize {
108    VALIDATE_TEXTS_CALLS.get()
109}
110
111/// Shared body of the caller-text contract check and the prepared-text backstop.
112///
113/// `max_bytes` is [`MAX_TEXT_BYTES`] on caller text, and that value plus the
114/// model's longest instruction on text that has already been prepared. The
115/// reported `max` is `max_bytes` so an error names the bound that actually
116/// rejected the input rather than a constant the caller cannot relate to.
117pub(crate) fn validate_texts_bounded<T: AsRef<str>>(texts: &[T], max_bytes: usize) -> Result<()> {
118    if texts.is_empty() {
119        return Err(EmbedError::InvalidInput("no texts provided".into()));
120    }
121    if texts.len() > DEFAULT_MAX_BATCH_SIZE {
122        return Err(EmbedError::InvalidInput(format!(
123            "batch size {} exceeds maximum {}",
124            texts.len(),
125            DEFAULT_MAX_BATCH_SIZE
126        )));
127    }
128    for text in texts {
129        let text = text.as_ref();
130        if text.len() > max_bytes {
131            return Err(EmbedError::TextTooLong {
132                length: text.len(),
133                max: max_bytes,
134            });
135        }
136    }
137    Ok(())
138}
139
140enum ValidatedTextBatchInner<'a> {
141    Contiguous(&'a [String]),
142    Borrowed(&'a [&'a str]),
143}
144
145/// Internal capability proving the caller-text request bounds were checked.
146///
147/// The constructors are crate-private, so external callers cannot manufacture
148/// this capability to bypass [`EmbeddingService`] input validation.
149#[doc(hidden)]
150pub struct ValidatedTextBatch<'a> {
151    inner: ValidatedTextBatchInner<'a>,
152}
153
154impl<'a> ValidatedTextBatch<'a> {
155    pub(in crate::service) fn new(texts: &'a [String]) -> Result<Self> {
156        validate_texts(texts)?;
157        Ok(Self {
158            inner: ValidatedTextBatchInner::Contiguous(texts),
159        })
160    }
161
162    pub(in crate::service) fn borrowed_subset<'b>(
163        &'b self,
164        texts: &'b [&'b str],
165    ) -> ValidatedTextBatch<'b> {
166        ValidatedTextBatch {
167            inner: ValidatedTextBatchInner::Borrowed(texts),
168        }
169    }
170
171    pub(in crate::service) fn len(&self) -> usize {
172        match self.inner {
173            ValidatedTextBatchInner::Contiguous(texts) => texts.len(),
174            ValidatedTextBatchInner::Borrowed(texts) => texts.len(),
175        }
176    }
177
178    pub(in crate::service) fn get(&self, index: usize) -> &str {
179        match self.inner {
180            ValidatedTextBatchInner::Contiguous(texts) => texts[index].as_str(),
181            ValidatedTextBatchInner::Borrowed(texts) => texts[index],
182        }
183    }
184
185    fn contiguous(&self) -> Option<&'a [String]> {
186        match self.inner {
187            ValidatedTextBatchInner::Contiguous(texts) => Some(texts),
188            ValidatedTextBatchInner::Borrowed(_) => None,
189        }
190    }
191
192    pub(in crate::service) fn borrowed(&self) -> Option<&'a [&'a str]> {
193        match self.inner {
194            ValidatedTextBatchInner::Contiguous(_) => None,
195            ValidatedTextBatchInner::Borrowed(texts) => Some(texts),
196        }
197    }
198
199    pub(in crate::service) fn to_owned_with_prefix(&self, prefix: Option<&str>) -> Vec<String> {
200        (0..self.len())
201            .map(|index| {
202                let text = self.get(index);
203                match prefix {
204                    None => text.to_owned(),
205                    Some(prefix) => {
206                        let mut prepared = String::with_capacity(prefix.len() + text.len());
207                        prepared.push_str(prefix);
208                        prepared.push_str(text);
209                        prepared
210                    }
211                }
212            })
213            .collect()
214    }
215}
216
217/// **Stable**: external consumers may depend on this; breaking changes require a SemVer bump.
218///
219/// Async interface for producing one embedding per input text.
220///
221/// See [`docs/service.md`](../../docs/service.md#trait-api-details) for role handling and implementation requirements.
222#[async_trait]
223pub trait EmbeddingService: Send + Sync {
224    /// **Stable**: generate embeddings for multiple texts.
225    ///
226    /// Returns a vector of embeddings, one for each input text, in the same order.
227    /// Applies no role-specific prompt prefix (equivalent to `Generic` role).
228    /// Use [`EmbeddingService::embed_query`] / [`EmbeddingService::embed_passage`]
229    /// for asymmetric retrieval models.
230    async fn embed(&self, texts: &[String], model: EmbeddingModel) -> Result<Vec<Vec<f32>>>;
231
232    /// **Stable**: generate an embedding for a single text.
233    ///
234    /// This is a convenience method that calls `embed` with a single-element slice.
235    async fn embed_one(&self, text: &str, model: EmbeddingModel) -> Result<Vec<f32>> {
236        let texts = vec![text.to_string()];
237        let mut embeddings = self.embed(&texts, model).await?;
238        embeddings
239            .pop()
240            .ok_or_else(|| EmbedError::Internal("no embedding generated".into()))
241    }
242
243    /// **Stable**: embed texts under a retrieval role, applying that role's instruction.
244    ///
245    /// The published length cap applies to the text the caller supplies, so this
246    /// validates first and prepends the instruction second. Doing it the other
247    /// way round charges the caller for bytes the service itself added, which
248    /// rejects text that is within the documented limit.
249    ///
250    /// Implementors that enforce a text-length cap inside [`EmbeddingService::embed`]
251    /// receive prepared text here, which is longer than the caller's by up to
252    /// [`EmbeddingModel::max_instruction_bytes`]. Size that guard accordingly, or
253    /// override this method to reach the backend without passing through it.
254    ///
255    /// See [`docs/service.md`](../../docs/service.md#trait-api-details) for role and cache behavior.
256    async fn embed_with_role(
257        &self,
258        texts: &[String],
259        model: EmbeddingModel,
260        role: EmbeddingRole,
261    ) -> Result<Vec<Vec<f32>>> {
262        validate_texts(texts)?;
263        let prepared = apply_prefix(texts, role.instruction(model));
264        self.embed(&prepared, model).await
265    }
266
267    /// Internal delegation hook for caller text already checked against the public bounds.
268    ///
269    /// External callers cannot construct [`ValidatedTextBatch`]. The default
270    /// preserves external implementors' role overrides by delegating through
271    /// [`EmbeddingService::embed_with_role`].
272    #[doc(hidden)]
273    async fn embed_with_role_prevalidated(
274        &self,
275        texts: ValidatedTextBatch<'_>,
276        model: EmbeddingModel,
277        role: EmbeddingRole,
278    ) -> Result<Vec<Vec<f32>>> {
279        if let Some(contiguous) = texts.contiguous() {
280            return self.embed_with_role(contiguous, model, role).await;
281        }
282        let owned = texts.to_owned_with_prefix(None);
283        self.embed_with_role(&owned, model, role).await
284    }
285
286    /// **Stable**: embed query texts after applying the model's query instruction.
287    ///
288    /// See [`docs/service.md`](../../docs/service.md#trait-api-details) for role and cache behavior.
289    async fn embed_query(&self, texts: &[String], model: EmbeddingModel) -> Result<Vec<Vec<f32>>> {
290        self.embed_with_role(texts, model, EmbeddingRole::Query)
291            .await
292    }
293
294    /// **Stable**: embed passages after applying the model's document instruction.
295    ///
296    /// See [`docs/service.md`](../../docs/service.md#trait-api-details) for role and cache behavior.
297    async fn embed_passage(
298        &self,
299        texts: &[String],
300        model: EmbeddingModel,
301    ) -> Result<Vec<Vec<f32>>> {
302        self.embed_with_role(texts, model, EmbeddingRole::Passage)
303            .await
304    }
305
306    /// **Unstable**: returns the effective configuration used for this model's cache keys.
307    ///
308    /// See [`docs/service.md`](../../docs/service.md#trait-api-details) for output-dimension behavior.
309    fn model_config(&self, model: EmbeddingModel) -> ModelConfig {
310        ModelConfig::new(model)
311    }
312
313    /// **Stable**: check if the service supports a given model.
314    fn supports_model(&self, model: EmbeddingModel) -> bool;
315
316    /// **Stable**: get the name/identifier of this service.
317    fn name(&self) -> &'static str;
318}
319
320/// Prepends an optional prompt prefix to each text, cloning unchanged inputs when absent.
321pub(crate) fn apply_prefix(texts: &[String], prefix: Option<&str>) -> Vec<String> {
322    match prefix {
323        None => texts.to_vec(),
324        Some(p) => texts.iter().map(|t| format!("{p}{t}")).collect(),
325    }
326}