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}