1use super::{EmbeddingRole, EmbeddingService, MAX_TEXT_BYTES, ValidatedTextBatch};
7use crate::error::{EmbedError, Result};
8use crate::model::{EmbeddingModel, ModelConfig};
9use async_trait::async_trait;
10use lattice_inference::{BertModel, QwenModel};
11use std::sync::{Arc, OnceLock};
12use tracing::{info, warn};
13
14enum LoadedModel {
16 Bert(Arc<BertModel>),
17 Qwen(Arc<QwenModel>),
18}
19
20impl LoadedModel {
23 fn encode_batch(&self, texts: &[&str]) -> std::result::Result<Vec<Vec<f32>>, String> {
24 match self {
25 LoadedModel::Bert(m) => m.encode_batch(texts).map_err(|e| e.to_string()),
26 LoadedModel::Qwen(m) => {
28 let mut results = Vec::with_capacity(texts.len());
29 for text in texts {
30 results.push(m.encode(text).map_err(|e| e.to_string())?);
31 }
32 Ok(results)
33 }
34 }
35 }
36
37 fn cache_size(&self) -> usize {
38 match self {
39 LoadedModel::Qwen(m) => m.cache_size(),
40 _ => 0,
41 }
42 }
43}
44
45pub struct NativeEmbeddingService {
52 model: Arc<OnceLock<std::result::Result<LoadedModel, String>>>,
53 model_config: ModelConfig,
54}
55
56impl Default for NativeEmbeddingService {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62const LATTICE_EMBED_DIM: &str = "LATTICE_EMBED_DIM";
63
64fn model_config_from_env(model: EmbeddingModel) -> Result<ModelConfig> {
65 let output_dim = match std::env::var(LATTICE_EMBED_DIM) {
66 Ok(raw) if raw.trim().is_empty() => None,
67 Ok(raw) => {
68 let dim = raw.trim().parse::<usize>().map_err(|e| {
69 EmbedError::InvalidInput(format!("invalid {LATTICE_EMBED_DIM}={raw:?}: {e}"))
70 })?;
71 Some(dim)
72 }
73 Err(std::env::VarError::NotPresent) => None,
74 Err(e) => {
75 return Err(EmbedError::InvalidInput(format!(
76 "invalid {LATTICE_EMBED_DIM}: {e}"
77 )));
78 }
79 };
80 ModelConfig::try_new(model, output_dim)
81}
82
83impl NativeEmbeddingService {
84 pub fn new() -> Self {
86 Self {
87 model: Arc::new(OnceLock::new()),
88 model_config: ModelConfig::new(EmbeddingModel::default()),
89 }
90 }
91
92 pub fn with_model(model_type: EmbeddingModel) -> Self {
94 Self {
95 model: Arc::new(OnceLock::new()),
96 model_config: ModelConfig::new(model_type),
97 }
98 }
99
100 pub fn with_model_config(model_config: ModelConfig) -> Result<Self> {
102 model_config.validate()?;
103 Ok(Self {
104 model: Arc::new(OnceLock::new()),
105 model_config,
106 })
107 }
108
109 pub fn with_model_from_env(model_type: EmbeddingModel) -> Result<Self> {
111 let config = model_config_from_env(model_type)?;
112 Ok(Self {
113 model: Arc::new(OnceLock::new()),
114 model_config: config,
115 })
116 }
117
118 pub fn save_cache(&self) -> Result<usize> {
120 let Some(Ok(model)) = self.model.get() else {
121 return Ok(0);
122 };
123 match model {
124 LoadedModel::Qwen(m) => {
125 let model_name = self.model_config.model.to_string();
126 let path = embedding_cache_path(&model_name, m.dimensions());
127 m.cache_save(&path)
128 .map_err(|e| EmbedError::InferenceFailed(e.to_string()))
129 }
130 _ => Ok(0),
131 }
132 }
133
134 pub fn cache_size(&self) -> usize {
136 self.model
137 .get()
138 .and_then(|r| r.as_ref().ok())
139 .map(LoadedModel::cache_size)
140 .unwrap_or(0)
141 }
142
143 pub async fn ensure_loaded(&self) -> Result<()> {
148 self.ensure_model().await.map(|_| ())
149 }
150
151 async fn ensure_model(&self) -> Result<&LoadedModel> {
155 if let Some(result) = self.model.get() {
157 return result
158 .as_ref()
159 .map_err(|e| EmbedError::ModelInitialization(e.clone()));
160 }
161
162 let model_lock = self.model.clone();
164 let model_config = self.model_config;
165
166 tokio::task::spawn_blocking(move || {
167 model_lock.get_or_init(|| load_model_sync(model_config));
169 })
170 .await
171 .map_err(|e| EmbedError::ModelInitialization(e.to_string()))?;
172
173 self.model
174 .get()
175 .expect("set by spawn_blocking")
176 .as_ref()
177 .map_err(|e| EmbedError::ModelInitialization(e.clone()))
178 }
179
180 async fn encode_prepared(
187 &self,
188 texts: &[&str],
189 model: EmbeddingModel,
190 ) -> Result<Vec<Vec<f32>>> {
191 if model != self.model_config.model {
192 return Err(EmbedError::InvalidInput(format!(
193 "requested model {:?} but this service is loaded with {:?}",
194 model, self.model_config.model
195 )));
196 }
197 super::validate_texts_bounded(
198 texts,
199 MAX_TEXT_BYTES.saturating_add(model.max_instruction_bytes()),
200 )?;
201
202 let loaded = self.ensure_model().await?;
203 loaded
204 .encode_batch(texts)
205 .map_err(EmbedError::InferenceFailed)
206 }
207
208 async fn encode_prevalidated_with_role(
209 &self,
210 texts: ValidatedTextBatch<'_>,
211 model: EmbeddingModel,
212 role: EmbeddingRole,
213 ) -> Result<Vec<Vec<f32>>> {
214 let prefix = role.instruction(model);
215 if prefix.is_none()
216 && let Some(borrowed) = texts.borrowed()
217 {
218 return self.encode_prepared(borrowed, model).await;
219 }
220
221 if prefix.is_none() {
222 let borrowed = (0..texts.len())
223 .map(|index| texts.get(index))
224 .collect::<Vec<_>>();
225 return self.encode_prepared(&borrowed, model).await;
226 }
227
228 let prepared = texts.to_owned_with_prefix(prefix);
229 let borrowed = prepared.iter().map(String::as_str).collect::<Vec<_>>();
230 self.encode_prepared(&borrowed, model).await
231 }
232}
233
234fn load_model_sync(model_config: ModelConfig) -> std::result::Result<LoadedModel, String> {
236 match model_config.model {
237 EmbeddingModel::BgeSmallEnV15
238 | EmbeddingModel::BgeBaseEnV15
239 | EmbeddingModel::BgeLargeEnV15
240 | EmbeddingModel::MultilingualE5Small
241 | EmbeddingModel::MultilingualE5Base
242 | EmbeddingModel::AllMiniLmL6V2
243 | EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
244 let model_name = match model_config.model {
245 EmbeddingModel::BgeSmallEnV15 => "bge-small-en-v1.5",
246 EmbeddingModel::BgeBaseEnV15 => "bge-base-en-v1.5",
247 EmbeddingModel::BgeLargeEnV15 => "bge-large-en-v1.5",
248 EmbeddingModel::MultilingualE5Small => "multilingual-e5-small",
249 EmbeddingModel::MultilingualE5Base => "multilingual-e5-base",
250 EmbeddingModel::AllMiniLmL6V2 => "all-minilm-l6-v2",
251 EmbeddingModel::ParaphraseMultilingualMiniLmL12V2 => {
252 "paraphrase-multilingual-minilm-l12-v2"
253 }
254 _ => unreachable!(),
255 };
256 info!(model = model_name, "loading native BERT embedding model");
257 let mut bert = BertModel::from_pretrained(model_name).map_err(|e| e.to_string())?;
258 if let Some(pooling) = model_config.model.bert_pooling() {
261 bert.set_pooling(pooling);
262 }
263 Ok(LoadedModel::Bert(Arc::new(bert)))
264 }
265 EmbeddingModel::Qwen3Embedding0_6B | EmbeddingModel::Qwen3Embedding4B => {
266 load_qwen_model(model_config)
267 }
268 other => Err(format!("unsupported model: {other:?}")),
269 }
270}
271
272fn load_qwen_model(model_config: ModelConfig) -> std::result::Result<LoadedModel, String> {
273 model_config.validate().map_err(|e| e.to_string())?;
274 let model_type = model_config.model;
275 let model_name = model_type.to_string();
276 info!(
277 model = %model_name,
278 output_dim = ?model_config.output_dim,
279 "loading Qwen embedding model"
280 );
281 let model_dir = qwen_model_dir(model_type).map_err(|e| e.to_string())?;
282 let mut model = QwenModel::from_directory(&model_dir).map_err(|e| e.to_string())?;
283 model.set_output_dim(model_config.output_dim);
284 let cache_path = embedding_cache_path(&model_name, model.dimensions());
285 match model.cache_load(&cache_path) {
286 Ok(n) if n > 0 => {
287 info!(entries = n, path = %cache_path.display(), "loaded embedding cache")
288 }
289 Ok(_) => {}
290 Err(e) => {
291 warn!(
292 path = %cache_path.display(),
293 error = %e,
294 "embedding cache failed integrity check, ignoring (will regenerate on next save)"
295 )
296 }
297 }
298 Ok(LoadedModel::Qwen(Arc::new(model)))
299}
300
301fn embedding_cache_path(model: &str, dim: usize) -> std::path::PathBuf {
303 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
304 std::path::PathBuf::from(home)
305 .join(".lattice")
306 .join("cache")
307 .join(format!("embed_{model}_{dim}d.bin"))
308}
309
310fn qwen_model_dir(model_type: EmbeddingModel) -> Result<std::path::PathBuf> {
312 if let Ok(dir) = std::env::var("LATTICE_QWEN_MODEL_DIR") {
314 return Ok(std::path::PathBuf::from(dir));
315 }
316
317 let slug = match model_type {
318 EmbeddingModel::Qwen3Embedding0_6B => "qwen3-embedding-0.6b",
319 EmbeddingModel::Qwen3Embedding4B => "qwen3-embedding-4b",
320 other => {
321 return Err(EmbedError::ModelInitialization(format!(
322 "not a Qwen model: {other}"
323 )));
324 }
325 };
326
327 let home = std::env::var("HOME")
328 .map_err(|_| EmbedError::ModelInitialization("HOME not set".into()))?;
329 let dir = std::path::PathBuf::from(home)
330 .join(".lattice")
331 .join("models")
332 .join(slug);
333
334 if dir.join("model.safetensors").exists() || dir.join("model.safetensors.index.json").exists() {
335 Ok(dir)
336 } else {
337 Err(EmbedError::ModelInitialization(format!(
338 "Qwen3 model not found at {dir}. Download it with:\n huggingface-cli download {repo} --local-dir {dir}",
339 dir = dir.display(),
340 repo = model_type.model_id()
341 )))
342 }
343}
344
345#[async_trait]
346impl EmbeddingService for NativeEmbeddingService {
347 async fn embed(&self, texts: &[String], model: EmbeddingModel) -> Result<Vec<Vec<f32>>> {
348 let texts = ValidatedTextBatch::new(texts)?;
349 self.encode_prevalidated_with_role(texts, model, EmbeddingRole::Generic)
350 .await
351 }
352
353 async fn embed_with_role(
354 &self,
355 texts: &[String],
356 model: EmbeddingModel,
357 role: EmbeddingRole,
358 ) -> Result<Vec<Vec<f32>>> {
359 let texts = ValidatedTextBatch::new(texts)?;
360 self.encode_prevalidated_with_role(texts, model, role).await
361 }
362
363 async fn embed_with_role_prevalidated(
364 &self,
365 texts: ValidatedTextBatch<'_>,
366 model: EmbeddingModel,
367 role: EmbeddingRole,
368 ) -> Result<Vec<Vec<f32>>> {
369 self.encode_prevalidated_with_role(texts, model, role).await
370 }
371
372 fn model_config(&self, model: EmbeddingModel) -> ModelConfig {
373 if model == self.model_config.model {
374 self.model_config
375 } else {
376 ModelConfig::new(model)
377 }
378 }
379
380 fn supports_model(&self, model: EmbeddingModel) -> bool {
381 model == self.model_config.model
382 }
383
384 fn name(&self) -> &'static str {
385 "native-bert"
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_cache_path_contains_dim_in_filename() {
395 let path = embedding_cache_path("qwen3-embedding-4b", 1024);
396 let filename = path.file_name().unwrap().to_str().unwrap();
397 assert_eq!(filename, "embed_qwen3-embedding-4b_1024d.bin");
398 }
399
400 #[test]
401 fn test_cache_path_different_dims_produce_different_paths() {
402 let path_1024 = embedding_cache_path("qwen3-embedding-4b", 1024);
403 let path_2560 = embedding_cache_path("qwen3-embedding-4b", 2560);
404 assert_ne!(path_1024, path_2560);
405 assert!(path_1024.to_string_lossy().contains("1024d"));
406 assert!(path_2560.to_string_lossy().contains("2560d"));
407 }
408
409 #[test]
410 fn test_cache_path_model_slug_differentiates_variants() {
411 let path_4b = embedding_cache_path("qwen3-embedding-4b", 2560);
412 let path_06b = embedding_cache_path("qwen3-embedding-0.6b", 1024);
413 assert_ne!(path_4b, path_06b);
414 assert!(path_4b.to_string_lossy().contains("qwen3-embedding-4b"));
415 assert!(path_06b.to_string_lossy().contains("qwen3-embedding-0.6b"));
416 }
417
418 #[test]
419 fn test_cache_path_same_model_same_dim_same_path() {
420 let p1 = embedding_cache_path("qwen3-embedding-4b", 1024);
421 let p2 = embedding_cache_path("qwen3-embedding-4b", 1024);
422 assert_eq!(p1, p2);
423 }
424}