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