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}