1use crate::error::{Result, RuvectorError};
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41use unicode_normalization::UnicodeNormalization;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum EmbeddingRole {
47 Query,
48 Passage,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum EmbeddingRolePolicy {
55 Symmetric,
56 Asymmetric,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(untagged)]
61pub enum PoolingStrategy {
62 Named(PoolingStrategyName),
63 Custom {
64 custom: String,
65 implementation_revision: String,
66 },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum PoolingStrategyName {
72 Mean,
73 Cls,
74 LastToken,
75 WeightedMean,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum OutputDtype {
81 F32,
82 F16,
83 Bf16,
84 I8,
85 U8,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum EmbeddingDistanceMetric {
91 Cosine,
92 Dot,
93 Euclidean,
94 Manhattan,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum PrefixPolicy {
100 None,
101 Required,
102 QueryRecommended,
103 Custom,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct EmbeddingSpaceIdentity {
109 pub schema_version: u16,
110 pub provider: String,
111 pub model_id: String,
112 pub model_artifact_sha256: String,
113 pub model_graph_sha256: String,
114 pub tokenizer_sha256: String,
115 pub prompt_template_sha256: String,
116 pub pooling_strategy: PoolingStrategy,
117 pub normalize: bool,
118 pub truncation_tokens: u32,
119 pub output_dimension: u32,
120 pub output_dtype: OutputDtype,
121 pub runtime_revision: String,
122 pub distance_metric: EmbeddingDistanceMetric,
123 pub role_policy: EmbeddingRolePolicy,
124 pub prefix_policy: PrefixPolicy,
125 pub prefix_policy_version: u32,
126}
127
128const EMBEDDING_SPACE_DOMAIN: &[u8] = b"ruvector.embedding-space.v1\0";
129
130pub const EMBEDDING_SPACE_FORMAT_REVISION: u32 = 1;
148
149pub fn build_revision() -> &'static str {
159 env!("CARGO_PKG_VERSION")
160}
161
162fn runtime_revision_for(provider_class: &str) -> String {
169 format!("{provider_class}/space-rev-{EMBEDDING_SPACE_FORMAT_REVISION}")
170}
171
172impl EmbeddingSpaceIdentity {
173 pub fn validate(&self) -> Result<()> {
174 if self.schema_version != 1 {
175 return Err(RuvectorError::InvalidParameter(format!(
176 "unsupported embedding identity schema version {}",
177 self.schema_version
178 )));
179 }
180 if self.output_dimension == 0
181 || self.truncation_tokens == 0
182 || self.prefix_policy_version == 0
183 {
184 return Err(RuvectorError::InvalidParameter(
185 "embedding identity dimensions, truncation, and policy version must be positive"
186 .into(),
187 ));
188 }
189 for (name, value) in [
190 ("provider", self.provider.as_str()),
191 ("model_id", self.model_id.as_str()),
192 ("runtime_revision", self.runtime_revision.as_str()),
193 ] {
194 if value.is_empty() || value.nfc().collect::<String>() != value {
195 return Err(RuvectorError::InvalidParameter(format!(
196 "embedding identity {name} must be non-empty NFC"
197 )));
198 }
199 }
200 for (name, value) in [
201 ("model_artifact_sha256", self.model_artifact_sha256.as_str()),
202 ("model_graph_sha256", self.model_graph_sha256.as_str()),
203 ("tokenizer_sha256", self.tokenizer_sha256.as_str()),
204 (
205 "prompt_template_sha256",
206 self.prompt_template_sha256.as_str(),
207 ),
208 ] {
209 if value.len() != 64
210 || !value
211 .bytes()
212 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
213 {
214 return Err(RuvectorError::InvalidParameter(format!(
215 "embedding identity {name} must be lowercase SHA-256 hex"
216 )));
217 }
218 }
219 Ok(())
220 }
221
222 pub fn canonical_json(&self) -> Result<String> {
225 self.validate()?;
226 fn canonical(value: &serde_json::Value, out: &mut String) -> Result<()> {
227 match value {
228 serde_json::Value::Null => out.push_str("null"),
229 serde_json::Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
230 serde_json::Value::Number(v) => out.push_str(&v.to_string()),
231 serde_json::Value::String(v) => out.push_str(
232 &serde_json::to_string(v)
233 .map_err(|e| RuvectorError::SerializationError(e.to_string()))?,
234 ),
235 serde_json::Value::Array(values) => {
236 out.push('[');
237 for (index, item) in values.iter().enumerate() {
238 if index > 0 {
239 out.push(',');
240 }
241 canonical(item, out)?;
242 }
243 out.push(']');
244 }
245 serde_json::Value::Object(values) => {
246 out.push('{');
247 let mut keys: Vec<_> = values.keys().collect();
248 keys.sort();
249 for (index, key) in keys.iter().enumerate() {
250 if index > 0 {
251 out.push(',');
252 }
253 out.push_str(
254 &serde_json::to_string(key)
255 .map_err(|e| RuvectorError::SerializationError(e.to_string()))?,
256 );
257 out.push(':');
258 canonical(&values[*key], out)?;
259 }
260 out.push('}');
261 }
262 }
263 Ok(())
264 }
265 let value = serde_json::to_value(self)
266 .map_err(|e| RuvectorError::SerializationError(e.to_string()))?;
267 let mut output = String::new();
268 canonical(&value, &mut output)?;
269 Ok(output)
270 }
271
272 pub fn embedding_space_id(&self) -> Result<String> {
273 let mut hash = Sha256::new();
274 hash.update(EMBEDDING_SPACE_DOMAIN);
275 hash.update(self.canonical_json()?.as_bytes());
276 Ok(format!("{:x}", hash.finalize()))
277 }
278}
279
280fn sha256_hex(bytes: &[u8]) -> String {
281 format!("{:x}", Sha256::digest(bytes))
282}
283
284pub fn embedding_cache_key(
285 identity: &EmbeddingSpaceIdentity,
286 role: EmbeddingRole,
287 text: &str,
288) -> Result<String> {
289 let text_hash = sha256_hex(text.as_bytes());
290 let role = match role {
291 EmbeddingRole::Query => "query",
292 EmbeddingRole::Passage => "passage",
293 };
294 Ok(format!(
295 "{}:{role}:{text_hash}",
296 identity.embedding_space_id()?
297 ))
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub struct PromptTemplates {
307 pub query: &'static str,
308 pub passage: &'static str,
309}
310
311impl PromptTemplates {
312 pub fn sha256(&self) -> String {
314 sha256_hex(format!("query={}\0passage={}", self.query, self.passage).as_bytes())
315 }
316
317 pub fn apply(&self, role: EmbeddingRole, text: &str) -> String {
320 let template = match role {
321 EmbeddingRole::Query => self.query,
322 EmbeddingRole::Passage => self.passage,
323 };
324 template.replace("{text}", text)
325 }
326}
327
328struct RegistryEntry {
331 aliases: &'static [&'static str],
332 output_dimension: u32,
333 role_policy: EmbeddingRolePolicy,
334 prefix_policy: PrefixPolicy,
335 templates: PromptTemplates,
336}
337
338const SYMMETRIC_TEMPLATES: PromptTemplates = PromptTemplates {
339 query: "{text}",
340 passage: "{text}",
341};
342const BGE_TEMPLATES: PromptTemplates = PromptTemplates {
343 query: "Represent this sentence for searching relevant passages: {text}",
344 passage: "{text}",
345};
346const E5_TEMPLATES: PromptTemplates = PromptTemplates {
347 query: "query: {text}",
348 passage: "passage: {text}",
349};
350const QWEN3_TEMPLATES: PromptTemplates = PromptTemplates {
351 query: "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:{text}",
352 passage: "{text}",
353};
354
355static MODEL_REGISTRY: &[RegistryEntry] = &[
360 RegistryEntry {
361 aliases: &[
362 "sentence-transformers/all-MiniLM-L6-v2",
363 "all-MiniLM-L6-v2",
364 "all-minilm-l6-v2",
365 ],
366 output_dimension: 384,
367 role_policy: EmbeddingRolePolicy::Symmetric,
368 prefix_policy: PrefixPolicy::None,
369 templates: SYMMETRIC_TEMPLATES,
370 },
371 RegistryEntry {
372 aliases: &[
373 "sentence-transformers/all-mpnet-base-v2",
374 "all-mpnet-base-v2",
375 ],
376 output_dimension: 768,
377 role_policy: EmbeddingRolePolicy::Symmetric,
378 prefix_policy: PrefixPolicy::None,
379 templates: SYMMETRIC_TEMPLATES,
380 },
381 RegistryEntry {
382 aliases: &[
383 "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
384 "paraphrase-multilingual-MiniLM-L12-v2",
385 "paraphrase-multilingual-minilm-l12-v2",
386 ],
387 output_dimension: 384,
388 role_policy: EmbeddingRolePolicy::Symmetric,
389 prefix_policy: PrefixPolicy::None,
390 templates: SYMMETRIC_TEMPLATES,
391 },
392 RegistryEntry {
393 aliases: &["BAAI/bge-small-en-v1.5", "bge-small-en-v1.5"],
394 output_dimension: 384,
395 role_policy: EmbeddingRolePolicy::Asymmetric,
396 prefix_policy: PrefixPolicy::QueryRecommended,
397 templates: BGE_TEMPLATES,
398 },
399 RegistryEntry {
400 aliases: &["BAAI/bge-base-en-v1.5", "bge-base-en-v1.5"],
401 output_dimension: 768,
402 role_policy: EmbeddingRolePolicy::Asymmetric,
403 prefix_policy: PrefixPolicy::QueryRecommended,
404 templates: BGE_TEMPLATES,
405 },
406 RegistryEntry {
407 aliases: &["BAAI/bge-large-en-v1.5", "bge-large-en-v1.5"],
408 output_dimension: 1024,
409 role_policy: EmbeddingRolePolicy::Asymmetric,
410 prefix_policy: PrefixPolicy::QueryRecommended,
411 templates: BGE_TEMPLATES,
412 },
413 RegistryEntry {
414 aliases: &["intfloat/e5-small-v2", "e5-small-v2"],
415 output_dimension: 384,
416 role_policy: EmbeddingRolePolicy::Asymmetric,
417 prefix_policy: PrefixPolicy::Required,
418 templates: E5_TEMPLATES,
419 },
420 RegistryEntry {
421 aliases: &["intfloat/e5-base-v2", "e5-base-v2"],
422 output_dimension: 768,
423 role_policy: EmbeddingRolePolicy::Asymmetric,
424 prefix_policy: PrefixPolicy::Required,
425 templates: E5_TEMPLATES,
426 },
427 RegistryEntry {
428 aliases: &["intfloat/e5-large-v2", "e5-large-v2"],
429 output_dimension: 1024,
430 role_policy: EmbeddingRolePolicy::Asymmetric,
431 prefix_policy: PrefixPolicy::Required,
432 templates: E5_TEMPLATES,
433 },
434 RegistryEntry {
435 aliases: &["intfloat/multilingual-e5-small", "multilingual-e5-small"],
436 output_dimension: 384,
437 role_policy: EmbeddingRolePolicy::Asymmetric,
438 prefix_policy: PrefixPolicy::Required,
439 templates: E5_TEMPLATES,
440 },
441 RegistryEntry {
442 aliases: &["intfloat/multilingual-e5-base", "multilingual-e5-base"],
443 output_dimension: 768,
444 role_policy: EmbeddingRolePolicy::Asymmetric,
445 prefix_policy: PrefixPolicy::Required,
446 templates: E5_TEMPLATES,
447 },
448 RegistryEntry {
449 aliases: &["intfloat/multilingual-e5-large", "multilingual-e5-large"],
450 output_dimension: 1024,
451 role_policy: EmbeddingRolePolicy::Asymmetric,
452 prefix_policy: PrefixPolicy::Required,
453 templates: E5_TEMPLATES,
454 },
455 RegistryEntry {
456 aliases: &["Qwen/Qwen3-Embedding-0.6B", "qwen3-embedding-0.6b"],
457 output_dimension: 1024,
458 role_policy: EmbeddingRolePolicy::Asymmetric,
459 prefix_policy: PrefixPolicy::Required,
460 templates: QWEN3_TEMPLATES,
461 },
462 RegistryEntry {
463 aliases: &["Qwen/Qwen3-Embedding-4B", "qwen3-embedding-4b"],
464 output_dimension: 2560,
465 role_policy: EmbeddingRolePolicy::Asymmetric,
466 prefix_policy: PrefixPolicy::Required,
467 templates: QWEN3_TEMPLATES,
468 },
469];
470
471fn registry_entry(model_id: &str) -> Option<&'static RegistryEntry> {
472 MODEL_REGISTRY
473 .iter()
474 .find(|entry| entry.aliases.contains(&model_id))
475}
476
477pub fn registry_prompt_templates(model_id: &str) -> Result<PromptTemplates> {
482 registry_entry(model_id)
483 .map(|entry| entry.templates)
484 .ok_or_else(|| {
485 RuvectorError::InvalidParameter(format!(
486 "no registered prompt templates for embedding model '{model_id}'"
487 ))
488 })
489}
490
491pub fn apply_prompt_template(
499 identity: &EmbeddingSpaceIdentity,
500 role: EmbeddingRole,
501 text: &str,
502) -> Result<String> {
503 match identity.prefix_policy {
504 PrefixPolicy::None => Ok(text.to_owned()),
505 PrefixPolicy::Custom => Err(RuvectorError::InvalidParameter(format!(
506 "model '{}' declares PrefixPolicy::Custom; ruvector has no template for it, so \
507 embedding would silently drop the model's required prompt",
508 identity.model_id
509 ))),
510 PrefixPolicy::Required | PrefixPolicy::QueryRecommended => {
511 let templates = registry_prompt_templates(&identity.model_id)?;
512 if templates.sha256() != identity.prompt_template_sha256 {
513 return Err(RuvectorError::InvalidParameter(format!(
514 "prompt template hash for '{}' disagrees with the registry fixture; the \
515 attested identity does not describe the templates that would be applied",
516 identity.model_id
517 )));
518 }
519 Ok(templates.apply(role, text))
520 }
521 }
522}
523
524pub fn default_space_identity(
532 provider: &str,
533 model_id: &str,
534 dimension: usize,
535) -> EmbeddingSpaceIdentity {
536 let fixture = format!("{provider}\0{model_id}\0v1");
537 EmbeddingSpaceIdentity {
538 schema_version: 1,
539 provider: provider.into(),
540 model_id: model_id.into(),
541 model_artifact_sha256: sha256_hex(format!("{fixture}\0artifact").as_bytes()),
542 model_graph_sha256: sha256_hex(format!("{fixture}\0graph").as_bytes()),
543 tokenizer_sha256: sha256_hex(format!("{fixture}\0tokenizer").as_bytes()),
544 prompt_template_sha256: sha256_hex(
545 format!("{fixture}\0query={{text}}\0passage={{text}}").as_bytes(),
546 ),
547 pooling_strategy: PoolingStrategy::Named(PoolingStrategyName::Mean),
548 normalize: true,
549 truncation_tokens: 512,
550 output_dimension: dimension as u32,
551 output_dtype: OutputDtype::F32,
552 runtime_revision: runtime_revision_for("ruvector-core"),
553 distance_metric: EmbeddingDistanceMetric::Cosine,
554 role_policy: EmbeddingRolePolicy::Symmetric,
555 prefix_policy: PrefixPolicy::None,
556 prefix_policy_version: 1,
557 }
558}
559
560pub fn registry_identity(
568 provider: &str,
569 model_id: &str,
570 dimension: usize,
571) -> Result<EmbeddingSpaceIdentity> {
572 let entry = registry_entry(model_id).ok_or_else(|| {
573 RuvectorError::InvalidParameter(format!(
574 "unknown embedding model '{model_id}' requires an explicit complete \
575 EmbeddingSpaceIdentity"
576 ))
577 })?;
578 if entry.output_dimension as usize != dimension {
579 return Err(RuvectorError::InvalidParameter(format!(
580 "embedding model '{model_id}' is registered at {} dimensions, not {dimension}",
581 entry.output_dimension
582 )));
583 }
584 let mut identity = default_space_identity(provider, model_id, dimension);
585 identity.role_policy = entry.role_policy;
586 identity.prefix_policy = entry.prefix_policy;
587 identity.prompt_template_sha256 = entry.templates.sha256();
588 Ok(identity)
589}
590
591static LEGACY_EMBED_CALLS: AtomicU64 = AtomicU64::new(0);
592
593pub fn legacy_embed_call_count() -> u64 {
594 LEGACY_EMBED_CALLS.load(Ordering::Relaxed)
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub struct EmbeddingSpaceAccess {
601 pub text_embedding: bool,
602 pub corpus_mutation: bool,
603 pub vector_read: bool,
604 pub inspect: bool,
605 pub verify: bool,
606 pub export: bool,
607}
608
609impl EmbeddingSpaceAccess {
610 pub fn for_identities(
611 stored: &EmbeddingSpaceIdentity,
612 active: &EmbeddingSpaceIdentity,
613 ) -> Result<Self> {
614 let compatible = stored.embedding_space_id()? == active.embedding_space_id()?;
615 Ok(Self {
616 text_embedding: compatible,
617 corpus_mutation: compatible,
618 vector_read: true,
619 inspect: true,
620 verify: true,
621 export: true,
622 })
623 }
624}
625
626pub struct SymmetricEmbeddingAdapter<F>
628where
629 F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
630{
631 embed_fn: F,
632 identity: EmbeddingSpaceIdentity,
633 name: String,
634}
635
636impl<F> SymmetricEmbeddingAdapter<F>
637where
638 F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
639{
640 pub fn new(
641 name: impl Into<String>,
642 identity: EmbeddingSpaceIdentity,
643 embed_fn: F,
644 ) -> Result<Self> {
645 identity.validate()?;
646 if identity.role_policy != EmbeddingRolePolicy::Symmetric {
647 return Err(RuvectorError::InvalidParameter(
648 "SymmetricEmbeddingAdapter requires role_policy=symmetric".into(),
649 ));
650 }
651 Ok(Self {
652 embed_fn,
653 identity,
654 name: name.into(),
655 })
656 }
657}
658
659impl<F> EmbeddingProvider for SymmetricEmbeddingAdapter<F>
660where
661 F: Fn(&str) -> Result<Vec<f32>> + Send + Sync,
662{
663 fn embed_for(&self, _role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
664 (self.embed_fn)(text)
665 }
666
667 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
668 &self.identity
669 }
670
671 fn dimensions(&self) -> usize {
672 self.identity.output_dimension as usize
673 }
674
675 fn name(&self) -> &str {
676 &self.name
677 }
678}
679
680pub trait EmbeddingProvider: Send + Sync {
682 fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>>;
683
684 fn embedding_space(&self) -> &EmbeddingSpaceIdentity;
685
686 fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
687 self.embed_for(EmbeddingRole::Query, text)
688 }
689
690 fn embed_passage(&self, text: &str) -> Result<Vec<f32>> {
691 self.embed_for(EmbeddingRole::Passage, text)
692 }
693
694 #[deprecated(note = "use embed_query or embed_passage")]
695 fn embed(&self, text: &str) -> Result<Vec<f32>> {
696 LEGACY_EMBED_CALLS.fetch_add(1, Ordering::Relaxed);
697 self.embed_for(EmbeddingRole::Passage, text)
698 }
699
700 fn embed_batch_for(&self, role: EmbeddingRole, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
701 texts
702 .iter()
703 .map(|text| self.embed_for(role, text))
704 .collect()
705 }
706
707 fn embed_query_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
708 self.embed_batch_for(EmbeddingRole::Query, texts)
709 }
710
711 fn embed_passage_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
712 self.embed_batch_for(EmbeddingRole::Passage, texts)
713 }
714
715 fn dimensions(&self) -> usize;
717
718 fn name(&self) -> &str;
720}
721
722#[derive(Debug, Clone)]
733pub struct HashEmbedding {
734 dimensions: usize,
735 identity: EmbeddingSpaceIdentity,
736}
737
738impl HashEmbedding {
739 pub fn new(dimensions: usize) -> Self {
741 Self {
742 dimensions,
743 identity: default_space_identity("ruvector-hash", "hash-v1", dimensions),
744 }
745 }
746}
747
748impl EmbeddingProvider for HashEmbedding {
749 fn embed_for(&self, _role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
750 let mut embedding = vec![0.0; self.dimensions];
751 let bytes = text.as_bytes();
752
753 for (i, byte) in bytes.iter().enumerate() {
754 embedding[i % self.dimensions] += (*byte as f32) / 255.0;
755 }
756
757 let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
759 if norm > 0.0 {
760 for val in &mut embedding {
761 *val /= norm;
762 }
763 }
764
765 Ok(embedding)
766 }
767
768 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
769 &self.identity
770 }
771
772 fn dimensions(&self) -> usize {
773 self.dimensions
774 }
775
776 fn name(&self) -> &str {
777 "HashEmbedding (placeholder)"
778 }
779}
780
781#[cfg(feature = "real-embeddings")]
794pub mod candle {
795 use super::*;
796
797 pub struct CandleEmbedding {
814 dimensions: usize,
815 model_id: String,
816 identity: EmbeddingSpaceIdentity,
817 }
818
819 impl CandleEmbedding {
820 pub fn from_pretrained(model_id: &str, _use_gpu: bool) -> Result<Self> {
840 Err(RuvectorError::ModelLoadError(format!(
841 "Candle embedding support is a stub. Please:\n\
842 1. Use ApiEmbedding for production (recommended)\n\
843 2. Or implement CandleEmbedding for model: {}\n\
844 3. See docs for ONNX Runtime integration examples",
845 model_id
846 )))
847 }
848 }
849
850 impl EmbeddingProvider for CandleEmbedding {
851 fn embed_for(&self, _role: EmbeddingRole, _text: &str) -> Result<Vec<f32>> {
852 Err(RuvectorError::ModelInferenceError(
853 "Candle embedding not implemented - use ApiEmbedding instead".to_string(),
854 ))
855 }
856
857 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
858 &self.identity
859 }
860
861 fn dimensions(&self) -> usize {
862 self.dimensions
863 }
864
865 fn name(&self) -> &str {
866 "CandleEmbedding (stub - not implemented)"
867 }
868 }
869}
870
871#[cfg(feature = "real-embeddings")]
872pub use candle::CandleEmbedding;
873
874#[cfg(feature = "api-embeddings")]
888#[derive(Clone)]
889pub struct ApiEmbedding {
890 api_key: String,
891 endpoint: String,
892 model: String,
893 dimensions: usize,
894 identity: EmbeddingSpaceIdentity,
895 client: reqwest::blocking::Client,
896}
897
898#[cfg(feature = "api-embeddings")]
899impl ApiEmbedding {
900 pub fn new(
908 api_key: String,
909 endpoint: String,
910 model: String,
911 dimensions: usize,
912 identity: EmbeddingSpaceIdentity,
913 ) -> Result<Self> {
914 identity.validate()?;
915 if identity.model_id != model || identity.output_dimension as usize != dimensions {
916 return Err(RuvectorError::InvalidParameter(
917 "remote provider identity must match the requested model and dimension".into(),
918 ));
919 }
920 for role in [EmbeddingRole::Query, EmbeddingRole::Passage] {
926 apply_prompt_template(&identity, role, "probe")?;
927 }
928 Ok(Self {
929 api_key,
930 endpoint,
931 model,
932 dimensions,
933 identity,
934 client: reqwest::blocking::Client::new(),
935 })
936 }
937
938 pub fn openai(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
945 let dimensions = match model {
946 "text-embedding-3-large" => 3072,
947 _ => 1536, };
949
950 Self::new(
951 api_key.to_string(),
952 "https://api.openai.com/v1/embeddings".to_string(),
953 model.to_string(),
954 dimensions,
955 identity,
956 )
957 }
958
959 pub fn cohere(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
965 Self::new(
966 api_key.to_string(),
967 "https://api.cohere.ai/v1/embed".to_string(),
968 model.to_string(),
969 1024,
970 identity,
971 )
972 }
973
974 pub fn voyage(api_key: &str, model: &str, identity: EmbeddingSpaceIdentity) -> Result<Self> {
980 let dimensions = if model.contains("large") { 1536 } else { 1024 };
981
982 Self::new(
983 api_key.to_string(),
984 "https://api.voyageai.com/v1/embeddings".to_string(),
985 model.to_string(),
986 dimensions,
987 identity,
988 )
989 }
990}
991
992#[cfg(feature = "api-embeddings")]
993impl EmbeddingProvider for ApiEmbedding {
994 fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
995 let prepared = apply_prompt_template(&self.identity, role, text)?;
1001 let mut request_body = serde_json::json!({
1002 "input": prepared,
1003 "model": self.model,
1004 });
1005 if self.identity.role_policy == EmbeddingRolePolicy::Asymmetric {
1006 let input_type = match role {
1007 EmbeddingRole::Query => "search_query",
1008 EmbeddingRole::Passage => "search_document",
1009 };
1010 request_body["input_type"] = serde_json::Value::String(input_type.into());
1011 }
1012
1013 let response = self
1014 .client
1015 .post(&self.endpoint)
1016 .header("Authorization", format!("Bearer {}", self.api_key))
1017 .header("Content-Type", "application/json")
1018 .json(&request_body)
1019 .send()
1020 .map_err(|e| {
1021 RuvectorError::ModelInferenceError(format!("API request failed: {}", e))
1022 })?;
1023
1024 if !response.status().is_success() {
1025 let status = response.status();
1026 let error_text = response
1027 .text()
1028 .unwrap_or_else(|_| "Unknown error".to_string());
1029 return Err(RuvectorError::ModelInferenceError(format!(
1030 "API returned error {}: {}",
1031 status, error_text
1032 )));
1033 }
1034
1035 let response_json: serde_json::Value = response.json().map_err(|e| {
1036 RuvectorError::ModelInferenceError(format!("Failed to parse response: {}", e))
1037 })?;
1038
1039 let embedding = if let Some(data) = response_json.get("data") {
1041 data.as_array()
1043 .and_then(|arr| arr.first())
1044 .and_then(|obj| obj.get("embedding"))
1045 .and_then(|emb| emb.as_array())
1046 .ok_or_else(|| {
1047 RuvectorError::ModelInferenceError("Invalid OpenAI response format".to_string())
1048 })?
1049 } else if let Some(embeddings) = response_json.get("embeddings") {
1050 embeddings
1052 .as_array()
1053 .and_then(|arr| arr.first())
1054 .and_then(|emb| emb.as_array())
1055 .ok_or_else(|| {
1056 RuvectorError::ModelInferenceError("Invalid Cohere response format".to_string())
1057 })?
1058 } else {
1059 return Err(RuvectorError::ModelInferenceError(
1060 "Unknown API response format".to_string(),
1061 ));
1062 };
1063
1064 let embedding_vec: Result<Vec<f32>> = embedding
1065 .iter()
1066 .map(|v| {
1067 v.as_f64().map(|f| f as f32).ok_or_else(|| {
1068 RuvectorError::ModelInferenceError("Invalid embedding value".to_string())
1069 })
1070 })
1071 .collect();
1072
1073 embedding_vec
1074 }
1075
1076 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1077 &self.identity
1078 }
1079
1080 fn dimensions(&self) -> usize {
1081 self.dimensions
1082 }
1083
1084 fn name(&self) -> &str {
1085 "ApiEmbedding"
1086 }
1087}
1088
1089#[cfg(feature = "onnx-embeddings")]
1120pub mod onnx {
1121 use super::*;
1122 use crate::error::RuvectorError;
1123 use ort::session::Session;
1124 use ort::value::{Tensor, ValueType};
1125 use parking_lot::RwLock;
1126 use std::path::PathBuf;
1127 use tokenizers::Tokenizer;
1128
1129 pub struct OnnxEmbedding {
1131 session: RwLock<Session>,
1132 tokenizer: RwLock<Tokenizer>,
1133 dimensions: usize,
1134 model_id: String,
1135 identity: EmbeddingSpaceIdentity,
1136 #[allow(dead_code)]
1137 max_length: usize,
1138 }
1139
1140 impl OnnxEmbedding {
1141 pub fn from_pretrained(model_id: &str) -> Result<Self> {
1153 let api = hf_hub::api::sync::Api::new().map_err(|e| {
1154 RuvectorError::ModelLoadError(format!("Failed to create HuggingFace API: {}", e))
1155 })?;
1156
1157 let repo = api.model(model_id.to_string());
1158
1159 let model_path = repo
1161 .get("model.onnx")
1162 .or_else(|_| {
1163 repo.get("onnx/model.onnx")
1165 })
1166 .map_err(|e| {
1167 RuvectorError::ModelLoadError(format!(
1168 "Failed to download ONNX model from {}: {}. \
1169 Make sure the model has an ONNX export available.",
1170 model_id, e
1171 ))
1172 })?;
1173
1174 let tokenizer_path = repo.get("tokenizer.json").map_err(|e| {
1175 RuvectorError::ModelLoadError(format!(
1176 "Failed to download tokenizer from {}: {}",
1177 model_id, e
1178 ))
1179 })?;
1180
1181 Self::from_files(&model_path, &tokenizer_path, model_id)
1182 }
1183
1184 pub fn from_files(
1191 model_path: &PathBuf,
1192 tokenizer_path: &PathBuf,
1193 model_id: &str,
1194 ) -> Result<Self> {
1195 let _ = ort::init().commit();
1197
1198 let session = Session::builder()
1200 .map_err(|e| {
1201 RuvectorError::ModelLoadError(format!(
1202 "Failed to create session builder: {}",
1203 e
1204 ))
1205 })?
1206 .with_intra_threads(4)
1207 .map_err(|e| {
1208 RuvectorError::ModelLoadError(format!("Failed to set thread count: {}", e))
1209 })?
1210 .commit_from_file(model_path)
1211 .map_err(|e| {
1212 RuvectorError::ModelLoadError(format!("Failed to load ONNX model: {}", e))
1213 })?;
1214
1215 let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
1217 RuvectorError::ModelLoadError(format!("Failed to load tokenizer: {}", e))
1218 })?;
1219
1220 let dimensions = Self::infer_dimensions(&session, model_id)?;
1222
1223 let max_length = 512;
1225
1226 tracing::info!(
1227 model_id,
1228 dimensions,
1229 build_revision = build_revision(),
1230 "Loaded ONNX embedding model"
1231 );
1232
1233 let mut identity = registry_identity("ruvector-onnx", model_id, dimensions)?;
1242 let model_bytes = std::fs::read(model_path)?;
1243 let tokenizer_bytes = std::fs::read(tokenizer_path)?;
1244 identity.model_artifact_sha256 = sha256_hex(&model_bytes);
1245 identity.model_graph_sha256 = identity.model_artifact_sha256.clone();
1246 identity.tokenizer_sha256 = sha256_hex(&tokenizer_bytes);
1247 identity.validate()?;
1248
1249 Ok(Self {
1250 session: RwLock::new(session),
1251 tokenizer: RwLock::new(tokenizer),
1252 dimensions,
1253 model_id: model_id.to_string(),
1254 identity,
1255 max_length,
1256 })
1257 }
1258
1259 fn infer_dimensions(session: &Session, model_id: &str) -> Result<usize> {
1260 let dimensions = match model_id {
1262 id if id.contains("all-MiniLM-L6") => 384,
1263 id if id.contains("all-mpnet-base") => 768,
1264 id if id.contains("bge-small") => 384,
1265 id if id.contains("bge-base") => 768,
1266 id if id.contains("bge-large") => 1024,
1267 id if id.contains("e5-small") => 384,
1268 id if id.contains("e5-base") => 768,
1269 id if id.contains("e5-large") => 1024,
1270 _ => {
1271 if let Some(output) = session.outputs().first() {
1273 if let ValueType::Tensor { shape, .. } = output.dtype() {
1274 let dims: Vec<i64> = shape.iter().copied().collect();
1275 if dims.len() >= 2 {
1276 let last_dim = dims[dims.len() - 1];
1277 if last_dim > 0 {
1278 return Ok(last_dim as usize);
1279 }
1280 }
1281 }
1282 }
1283 384
1285 }
1286 };
1287
1288 Ok(dimensions)
1289 }
1290
1291 pub fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
1293 self.embed_passage_batch(texts)
1294 }
1295
1296 fn mean_pooling(
1297 token_embeddings: &[f32],
1298 attention_mask: &[i64],
1299 seq_len: usize,
1300 hidden_size: usize,
1301 ) -> Vec<f32> {
1302 let mut pooled = vec![0.0f32; hidden_size];
1303 let mut mask_sum = 0.0f32;
1304
1305 for i in 0..seq_len {
1306 let mask = attention_mask[i] as f32;
1307 mask_sum += mask;
1308 for j in 0..hidden_size {
1309 pooled[j] += token_embeddings[i * hidden_size + j] * mask;
1310 }
1311 }
1312
1313 if mask_sum > 0.0 {
1315 for val in &mut pooled {
1316 *val /= mask_sum;
1317 }
1318 }
1319
1320 let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
1322 if norm > 0.0 {
1323 for val in &mut pooled {
1324 *val /= norm;
1325 }
1326 }
1327
1328 pooled
1329 }
1330 }
1331
1332 impl EmbeddingProvider for OnnxEmbedding {
1333 fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
1334 let prepared = apply_prompt_template(&self.identity, role, text)?;
1338 let encoding = {
1340 let tokenizer = self.tokenizer.read();
1341 tokenizer.encode(prepared, true).map_err(|e| {
1342 RuvectorError::ModelInferenceError(format!("Tokenization failed: {}", e))
1343 })?
1344 };
1345
1346 let input_ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
1348 let attention_mask: Vec<i64> = encoding
1349 .get_attention_mask()
1350 .iter()
1351 .map(|&x| x as i64)
1352 .collect();
1353 let token_type_ids: Vec<i64> =
1354 encoding.get_type_ids().iter().map(|&x| x as i64).collect();
1355
1356 let seq_len = input_ids.len();
1357
1358 let input_ids_tensor =
1361 Tensor::<i64>::from_array(([1, seq_len], input_ids.clone().into_boxed_slice()))
1362 .map_err(|e| {
1363 RuvectorError::ModelInferenceError(format!(
1364 "Failed to create input_ids tensor: {}",
1365 e
1366 ))
1367 })?;
1368
1369 let attention_mask_tensor = Tensor::<i64>::from_array((
1370 [1, seq_len],
1371 attention_mask.clone().into_boxed_slice(),
1372 ))
1373 .map_err(|e| {
1374 RuvectorError::ModelInferenceError(format!(
1375 "Failed to create attention_mask tensor: {}",
1376 e
1377 ))
1378 })?;
1379
1380 let token_type_ids_tensor =
1381 Tensor::<i64>::from_array(([1, seq_len], token_type_ids.into_boxed_slice()))
1382 .map_err(|e| {
1383 RuvectorError::ModelInferenceError(format!(
1384 "Failed to create token_type_ids tensor: {}",
1385 e
1386 ))
1387 })?;
1388
1389 let (output_data, output_shape_vec) = {
1392 let mut session = self.session.write();
1393 let outputs = session
1394 .run(ort::inputs![
1395 "input_ids" => input_ids_tensor,
1396 "attention_mask" => attention_mask_tensor,
1397 "token_type_ids" => token_type_ids_tensor,
1398 ])
1399 .map_err(|e| {
1400 RuvectorError::ModelInferenceError(format!("ONNX inference failed: {}", e))
1401 })?;
1402
1403 let output_value = &outputs[0];
1406
1407 let output_array = output_value.try_extract_array::<f32>().map_err(|e| {
1409 RuvectorError::ModelInferenceError(format!(
1410 "Failed to extract output tensor: {}",
1411 e
1412 ))
1413 })?;
1414
1415 let output_shape_vec: Vec<usize> = output_array.shape().to_vec();
1416 let output_data_vec: Vec<f32> = output_array.iter().copied().collect();
1417
1418 (output_data_vec, output_shape_vec)
1419 };
1420
1421 let embedding = if output_shape_vec.len() == 3 {
1423 let hidden_size = output_shape_vec[2];
1425 Self::mean_pooling(&output_data, &attention_mask, seq_len, hidden_size)
1426 } else if output_shape_vec.len() == 2 {
1427 let mut emb = output_data;
1429 let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
1431 if norm > 0.0 {
1432 for val in &mut emb {
1433 *val /= norm;
1434 }
1435 }
1436 emb
1437 } else {
1438 return Err(RuvectorError::ModelInferenceError(format!(
1439 "Unexpected output shape: {:?}",
1440 output_shape_vec
1441 )));
1442 };
1443
1444 Ok(embedding)
1445 }
1446
1447 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1448 &self.identity
1449 }
1450
1451 fn dimensions(&self) -> usize {
1452 self.dimensions
1453 }
1454
1455 fn name(&self) -> &str {
1456 &self.model_id
1457 }
1458 }
1459
1460 impl std::fmt::Debug for OnnxEmbedding {
1461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1462 f.debug_struct("OnnxEmbedding")
1463 .field("model_id", &self.model_id)
1464 .field("dimensions", &self.dimensions)
1465 .field("max_length", &self.max_length)
1466 .finish()
1467 }
1468 }
1469}
1470
1471#[cfg(feature = "onnx-embeddings")]
1472pub use onnx::OnnxEmbedding;
1473
1474#[cfg(feature = "lattice-embeddings")]
1561pub mod lattice_native {
1562 use super::*;
1563 use lattice_embed::{
1564 EmbeddingModel as LatticeEmbeddingModel, EmbeddingService, NativeEmbeddingService,
1565 };
1566 use std::sync::mpsc;
1567 use std::sync::Mutex;
1568 use std::thread;
1569
1570 enum EmbedKind {
1572 Query,
1573 Passage,
1574 }
1575
1576 struct EmbedRequest {
1579 kind: EmbedKind,
1580 text: String,
1581 reply_tx: mpsc::Sender<std::result::Result<Vec<f32>, String>>,
1582 }
1583
1584 pub struct LatticeEmbedding {
1619 model: LatticeEmbeddingModel,
1620 model_id: &'static str,
1621 dimensions: usize,
1622 identity: EmbeddingSpaceIdentity,
1623 request_tx: Mutex<mpsc::Sender<EmbedRequest>>,
1624 _worker: thread::JoinHandle<()>,
1629 }
1630
1631 impl LatticeEmbedding {
1632 pub fn from_pretrained(model_id: &str) -> Result<Self> {
1660 let model: LatticeEmbeddingModel = model_id.parse().map_err(|e: String| {
1661 RuvectorError::ModelLoadError(format!(
1662 "unknown lattice-embed model id '{model_id}': {e}"
1663 ))
1664 })?;
1665 Self::with_model(model)
1666 }
1667
1668 pub fn from_pretrained_with_identity(
1670 model_id: &str,
1671 identity: EmbeddingSpaceIdentity,
1672 ) -> Result<Self> {
1673 let model: LatticeEmbeddingModel = model_id.parse().map_err(|e: String| {
1674 RuvectorError::ModelLoadError(format!(
1675 "unknown lattice-embed model id '{model_id}': {e}"
1676 ))
1677 })?;
1678 Self::with_model_and_identity(model, identity)
1679 }
1680
1681 pub fn with_model(model: LatticeEmbeddingModel) -> Result<Self> {
1687 let identity =
1688 registry_identity("ruvector-lattice", model.model_id(), model.dimensions())?;
1689 Self::with_model_and_identity(model, identity)
1690 }
1691
1692 fn assert_lattice_owns_matching_prefixes(
1709 model: LatticeEmbeddingModel,
1710 identity: &EmbeddingSpaceIdentity,
1711 ) -> Result<()> {
1712 let templates = registry_prompt_templates(&identity.model_id)?;
1713 const PROBE: &str = "\u{1}probe\u{1}";
1714 for (role, lattice_instruction, template_side) in [
1715 (
1716 EmbeddingRole::Query,
1717 model.query_instruction(),
1718 templates.query,
1719 ),
1720 (
1721 EmbeddingRole::Passage,
1722 model.document_instruction(),
1723 templates.passage,
1724 ),
1725 ] {
1726 let lattice_applies = format!("{}{PROBE}", lattice_instruction.unwrap_or_default());
1727 let registry_claims = templates.apply(role, PROBE);
1728 if lattice_applies != registry_claims {
1729 return Err(RuvectorError::InvalidParameter(format!(
1730 "prefix ownership divergence for '{}' on the {role:?} side: \
1731 lattice-embed applies {lattice_applies:?} but ruvector's registry \
1732 template {template_side:?} attests {registry_claims:?}. The \
1733 prompt_template_sha256 in this identity would describe a prompt that \
1734 is never applied; update the ruvector registry entry to match \
1735 lattice-embed (and re-embed, since the space identity changes).",
1736 identity.model_id
1737 )));
1738 }
1739 }
1740 Ok(())
1741 }
1742
1743 pub fn with_model_and_identity(
1746 model: LatticeEmbeddingModel,
1747 identity: EmbeddingSpaceIdentity,
1748 ) -> Result<Self> {
1749 if !model.is_local() {
1750 return Err(RuvectorError::ModelLoadError(format!(
1751 "'{model}' cannot be loaded natively: lattice-embed's \
1752 NativeEmbeddingService only supports models it can run \
1753 on-device. Remote/API-only models (e.g. the OpenAI \
1754 text-embedding-* family) are not supported by LatticeEmbedding."
1755 )));
1756 }
1757 identity.validate()?;
1758 if identity.model_id != model.model_id()
1759 || identity.output_dimension as usize != model.dimensions()
1760 {
1761 return Err(RuvectorError::InvalidParameter(format!(
1762 "lattice identity disagrees with loaded model: expected {} ({}D)",
1763 model.model_id(),
1764 model.dimensions()
1765 )));
1766 }
1767 let expected = registry_identity(
1768 "lattice-policy-fixture",
1769 model.model_id(),
1770 model.dimensions(),
1771 )?;
1772 if identity.role_policy != expected.role_policy
1773 || identity.prefix_policy != expected.prefix_policy
1774 || identity.prompt_template_sha256 != expected.prompt_template_sha256
1775 {
1776 return Err(RuvectorError::InvalidParameter(
1777 "lattice identity disagrees with exact role/prompt registry fixture".into(),
1778 ));
1779 }
1780 Self::assert_lattice_owns_matching_prefixes(model, &identity)?;
1781
1782 let runtime = tokio::runtime::Builder::new_current_thread()
1783 .enable_all()
1784 .build()
1785 .map_err(|e| {
1786 RuvectorError::ModelLoadError(format!(
1787 "failed to build tokio runtime for LatticeEmbedding: {e}"
1788 ))
1789 })?;
1790 let service = NativeEmbeddingService::with_model(model);
1791
1792 let (request_tx, request_rx) = mpsc::channel::<EmbedRequest>();
1793 let worker = thread::Builder::new()
1794 .name("lattice-embed-worker".to_string())
1795 .spawn(move || {
1796 for request in request_rx {
1800 let outcome = runtime.block_on(async {
1801 match request.kind {
1802 EmbedKind::Query => {
1803 service.embed_query(&[request.text], model).await
1804 }
1805 EmbedKind::Passage => {
1806 service.embed_passage(&[request.text], model).await
1807 }
1808 }
1809 });
1810 let mapped =
1811 outcome
1812 .map_err(|e| e.to_string())
1813 .and_then(|mut embeddings| {
1814 embeddings.pop().ok_or_else(|| {
1815 "lattice-embed returned no embedding".to_string()
1816 })
1817 });
1818 let _ = request.reply_tx.send(mapped);
1821 }
1822 })
1823 .map_err(|e| {
1824 RuvectorError::ModelLoadError(format!(
1825 "failed to spawn LatticeEmbedding worker thread: {e}"
1826 ))
1827 })?;
1828
1829 Ok(Self {
1830 model,
1831 model_id: model.model_id(),
1832 dimensions: model.dimensions(),
1833 identity,
1834 request_tx: Mutex::new(request_tx),
1835 _worker: worker,
1836 })
1837 }
1838
1839 pub fn dimensions(&self) -> usize {
1841 self.dimensions
1842 }
1843
1844 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
1859 self.send_request(EmbedKind::Query, text)
1860 }
1861
1862 fn send_request(&self, kind: EmbedKind, text: &str) -> Result<Vec<f32>> {
1866 let (reply_tx, reply_rx) = mpsc::channel();
1867 let request = EmbedRequest {
1868 kind,
1869 text: text.to_string(),
1870 reply_tx,
1871 };
1872
1873 self.request_tx
1874 .lock()
1875 .map_err(|_| {
1876 RuvectorError::ModelInferenceError(
1877 "lattice-embed embedding worker request channel poisoned".to_string(),
1878 )
1879 })?
1880 .send(request)
1881 .map_err(|_| {
1882 RuvectorError::ModelInferenceError(
1883 "lattice-embed embedding worker unavailable".to_string(),
1884 )
1885 })?;
1886
1887 reply_rx
1888 .recv()
1889 .map_err(|_| {
1890 RuvectorError::ModelInferenceError(
1891 "lattice-embed embedding worker unavailable".to_string(),
1892 )
1893 })?
1894 .map_err(|e| {
1895 RuvectorError::ModelInferenceError(format!(
1896 "lattice-embed embedding failed: {e}"
1897 ))
1898 })
1899 }
1900 }
1901
1902 impl EmbeddingProvider for LatticeEmbedding {
1903 fn embed_for(&self, role: EmbeddingRole, text: &str) -> Result<Vec<f32>> {
1910 self.send_request(
1911 match role {
1912 EmbeddingRole::Query => EmbedKind::Query,
1913 EmbeddingRole::Passage => EmbedKind::Passage,
1914 },
1915 text,
1916 )
1917 }
1918
1919 fn embedding_space(&self) -> &EmbeddingSpaceIdentity {
1920 &self.identity
1921 }
1922
1923 fn dimensions(&self) -> usize {
1924 self.dimensions
1925 }
1926
1927 fn name(&self) -> &str {
1928 self.model_id
1929 }
1930 }
1931
1932 #[cfg(test)]
1933 mod tests {
1934 use super::*;
1935
1936 fn test_identity(model: LatticeEmbeddingModel) -> EmbeddingSpaceIdentity {
1937 registry_identity("lattice-test", model.model_id(), model.dimensions()).unwrap()
1938 }
1939
1940 #[test]
1941 fn from_pretrained_rejects_remote_only_models() {
1942 assert!(
1948 LatticeEmbedding::from_pretrained("text-embedding-3-small").is_err(),
1949 "remote-only model 'text-embedding-3-small' must be rejected at construction"
1950 );
1951 assert!(
1952 LatticeEmbedding::from_pretrained("openai").is_err(),
1953 "remote-only model alias 'openai' must be rejected at construction"
1954 );
1955 }
1956
1957 #[test]
1961 fn legacy_entry_points_use_the_registry_fixture() {
1962 let provider = LatticeEmbedding::from_pretrained("bge-small-en-v1.5")
1963 .expect("registered model must load through the legacy entry point");
1964 let identity = provider.embedding_space();
1965 assert_eq!(identity.model_id, "BAAI/bge-small-en-v1.5");
1966 assert_eq!(identity.role_policy, EmbeddingRolePolicy::Asymmetric);
1967 assert_eq!(identity.prefix_policy, PrefixPolicy::QueryRecommended);
1968
1969 let model: LatticeEmbeddingModel = "all-minilm-l6-v2".parse().unwrap();
1970 let by_model = LatticeEmbedding::with_model(model)
1971 .expect("with_model must load a registered model");
1972 assert_eq!(
1973 by_model.embedding_space().role_policy,
1974 EmbeddingRolePolicy::Symmetric
1975 );
1976
1977 assert!(
1978 LatticeEmbedding::from_pretrained("text-embedding-3-small").is_err(),
1979 "a model with no registry fixture must not get a guessed identity"
1980 );
1981 }
1982
1983 #[test]
1990 fn from_pretrained_accepts_bge_small_alias_surface() {
1991 for alias in [
1992 "bge-small-en-v1.5",
1993 "bge-small-en",
1994 "bge-small",
1995 "small",
1996 "BAAI/bge-small-en-v1.5",
1997 "BGE_SMALL_EN_V1.5",
1998 ] {
1999 let model: LatticeEmbeddingModel = alias.parse().unwrap();
2000 let provider =
2001 LatticeEmbedding::from_pretrained_with_identity(alias, test_identity(model))
2002 .unwrap_or_else(|e| {
2003 panic!("alias '{alias}' must resolve to bge-small: {e}")
2004 });
2005 assert_eq!(
2006 provider.dimensions(),
2007 384,
2008 "alias '{alias}' resolved to the wrong dimensionality"
2009 );
2010 assert_eq!(
2011 provider.name(),
2012 "BAAI/bge-small-en-v1.5",
2013 "alias '{alias}' resolved to a different model than 'bge-small-en-v1.5'"
2014 );
2015 }
2016 }
2017
2018 #[test]
2030 fn lattice_prefix_ownership_matches_registry_for_e5_and_qwen3() {
2031 const PROBE: &str = "\u{1}probe\u{1}";
2032 for alias in [
2033 "multilingual-e5-small",
2034 "qwen3-embedding-0.6b",
2035 "bge-small-en-v1.5",
2036 "all-minilm-l6-v2",
2037 ] {
2038 let Ok(model) = alias.parse::<LatticeEmbeddingModel>() else {
2039 continue; };
2041 let templates = registry_prompt_templates(model.model_id()).unwrap_or_else(|e| {
2042 panic!("lattice model '{alias}' must have a ruvector registry entry: {e}")
2043 });
2044
2045 let query_agrees =
2048 format!("{}{PROBE}", model.query_instruction().unwrap_or_default())
2049 == templates.apply(EmbeddingRole::Query, PROBE);
2050 let passage_agrees = format!(
2051 "{}{PROBE}",
2052 model.document_instruction().unwrap_or_default()
2053 ) == templates.apply(EmbeddingRole::Passage, PROBE);
2054 let agrees = query_agrees && passage_agrees;
2055
2056 let built = LatticeEmbedding::from_pretrained(alias);
2057 assert_eq!(
2058 built.is_ok(),
2059 agrees,
2060 "prefix ownership for '{alias}': lattice-embed applies \
2061 query={:?} passage={:?}, ruvector's registry claims query={:?} \
2062 passage={:?}. Construction must succeed iff those agree, so that \
2063 prompt_template_sha256 never attests a prompt lattice-embed will not \
2064 apply. Constructor said: {:?}",
2065 model.query_instruction(),
2066 model.document_instruction(),
2067 templates.query,
2068 templates.passage,
2069 built.err().map(|e| e.to_string()),
2070 );
2071 }
2072 }
2073
2074 #[tokio::test]
2080 async fn embed_from_inside_async_runtime_does_not_panic() {
2081 let model: LatticeEmbeddingModel = "bge-small-en-v1.5".parse().unwrap();
2082 let provider = LatticeEmbedding::from_pretrained_with_identity(
2083 "bge-small-en-v1.5",
2084 test_identity(model),
2085 )
2086 .expect("bge-small-en-v1.5 is a native local model");
2087
2088 let doc = provider
2089 .embed("a nested-runtime regression test")
2090 .expect("embed must not panic or error from inside a Tokio runtime");
2091 assert_eq!(doc.len(), provider.dimensions());
2092
2093 let query = provider
2094 .embed_query("a nested-runtime regression test")
2095 .expect("embed_query must not panic or error from inside a Tokio runtime");
2096 assert_eq!(query.len(), provider.dimensions());
2097 }
2098
2099 #[test]
2123 fn cross_provider_query_prefix_contract() {
2124 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2125 "../../../fixtures/lattice-embed/query-prefixes.json"
2126 ))
2127 .expect("fixtures/lattice-embed/query-prefixes.json must be valid JSON");
2128
2129 let models = fixture["models"]
2130 .as_object()
2131 .expect("fixture must have a top-level 'models' object");
2132 assert!(
2133 !models.is_empty(),
2134 "fixture 'models' must not be empty -- an empty fixture would make this \
2135 contract test vacuously pass"
2136 );
2137 assert!(
2138 models.contains_key("bge-small"),
2139 "fixture must cover 'bge-small' -- the model #662 was about"
2140 );
2141 assert!(
2142 models.contains_key("minilm"),
2143 "fixture must cover 'minilm' as the symmetric control case"
2144 );
2145
2146 for (alias, expected) in models {
2147 let model: LatticeEmbeddingModel = alias.parse().unwrap_or_else(|e| {
2148 panic!(
2149 "fixture alias '{alias}' must be a valid lattice_embed::EmbeddingModel: {e}"
2150 )
2151 });
2152
2153 let expected_query_prefix = expected["query_prefix"].as_str();
2154 assert_eq!(
2155 model.query_instruction(),
2156 expected_query_prefix,
2157 "query prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
2158 query_instruction() returned {:?} but fixtures/lattice-embed/\
2159 query-prefixes.json expects {:?}. If lattice-embed intentionally changed \
2160 this model's convention, update the fixture AND the TS sibling test in \
2161 npm/packages/ruvector-extensions/tests/lattice-prefix-contract.test.ts \
2162 together.",
2163 model.query_instruction(),
2164 expected_query_prefix
2165 );
2166
2167 let expected_passage_prefix = expected["passage_prefix"].as_str();
2168 assert_eq!(
2169 model.document_instruction(),
2170 expected_passage_prefix,
2171 "passage prefix mismatch for '{alias}': lattice_embed::EmbeddingModel::\
2172 document_instruction() returned {:?} but the fixture expects {:?}",
2173 model.document_instruction(),
2174 expected_passage_prefix
2175 );
2176 }
2177 }
2178 }
2179}
2180
2181#[cfg(feature = "lattice-embeddings")]
2182pub use lattice_native::LatticeEmbedding;
2183
2184pub type BoxedEmbeddingProvider = Arc<dyn EmbeddingProvider>;
2186
2187#[cfg(test)]
2188mod tests {
2189 use super::*;
2190
2191 #[test]
2192 fn query_template_change_creates_a_new_space_and_read_only_access() {
2193 let stored = default_space_identity("fixture", "same-model", 384);
2194 let mut active = stored.clone();
2195 active.prompt_template_sha256 = sha256_hex(b"changed query template");
2196 active.prefix_policy_version += 1;
2197
2198 assert_ne!(
2199 stored.embedding_space_id().unwrap(),
2200 active.embedding_space_id().unwrap()
2201 );
2202 let access = EmbeddingSpaceAccess::for_identities(&stored, &active).unwrap();
2203 assert!(!access.text_embedding);
2204 assert!(!access.corpus_mutation);
2205 assert!(access.vector_read && access.inspect && access.verify && access.export);
2206 assert_ne!(
2207 embedding_cache_key(&stored, EmbeddingRole::Query, "same query").unwrap(),
2208 embedding_cache_key(&active, EmbeddingRole::Query, "same query").unwrap(),
2209 );
2210 }
2211
2212 #[test]
2213 fn canonical_identity_matches_cross_runtime_fixture() {
2214 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2215 "../../../schemas/fixtures/embedding-space-identity-v1.json"
2216 ))
2217 .unwrap();
2218 for variant in ["base", "changed_query_template"] {
2219 let identity: EmbeddingSpaceIdentity =
2220 serde_json::from_value(fixture[variant]["identity"].clone()).unwrap();
2221 assert_eq!(
2222 identity.embedding_space_id().unwrap(),
2223 fixture[variant]["embedding_space_id"].as_str().unwrap()
2224 );
2225 }
2226 }
2227
2228 #[test]
2229 fn cache_key_is_role_aware() {
2230 let identity = default_space_identity("fixture", "same-model", 4);
2231 assert_ne!(
2232 embedding_cache_key(&identity, EmbeddingRole::Query, "text").unwrap(),
2233 embedding_cache_key(&identity, EmbeddingRole::Passage, "text").unwrap(),
2234 );
2235 }
2236
2237 #[test]
2238 fn trait_object_preserves_explicit_roles_and_legacy_metric() {
2239 let provider: Arc<dyn EmbeddingProvider> = Arc::new(HashEmbedding::new(8));
2240 assert_eq!(
2241 provider.embed_query("same").unwrap(),
2242 provider.embed_passage("same").unwrap()
2243 );
2244 let before = legacy_embed_call_count();
2245 #[allow(deprecated)]
2246 let _ = provider.embed("legacy").unwrap();
2247 assert_eq!(legacy_embed_call_count(), before + 1);
2248 }
2249
2250 #[test]
2251 fn test_hash_embedding() {
2252 let provider = HashEmbedding::new(128);
2253
2254 let emb1 = provider.embed("hello world").unwrap();
2255 let emb2 = provider.embed("hello world").unwrap();
2256
2257 assert_eq!(emb1.len(), 128);
2258 assert_eq!(emb1, emb2, "Same text should produce same embedding");
2259
2260 let norm: f32 = emb1.iter().map(|x| x * x).sum::<f32>().sqrt();
2262 assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
2263 }
2264
2265 #[test]
2266 fn test_hash_embedding_different_text() {
2267 let provider = HashEmbedding::new(128);
2268
2269 let emb1 = provider.embed("hello").unwrap();
2270 let emb2 = provider.embed("world").unwrap();
2271
2272 assert_ne!(
2273 emb1, emb2,
2274 "Different text should produce different embeddings"
2275 );
2276 }
2277
2278 #[cfg(feature = "real-embeddings")]
2279 #[test]
2280 #[ignore] fn test_candle_embedding() {
2282 let provider =
2283 CandleEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2", false)
2284 .unwrap();
2285
2286 let embedding = provider.embed("hello world").unwrap();
2287 assert_eq!(embedding.len(), 384);
2288
2289 let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2291 assert!((norm - 1.0).abs() < 1e-5, "Embedding should be normalized");
2292 }
2293
2294 #[test]
2295 #[cfg(feature = "api-embeddings")]
2296 #[ignore] fn test_api_embedding_openai() {
2298 let api_key = std::env::var("OPENAI_API_KEY").unwrap();
2299 let identity = default_space_identity("test-api", "text-embedding-3-small", 1536);
2300 let provider = ApiEmbedding::openai(&api_key, "text-embedding-3-small", identity).unwrap();
2301
2302 let embedding = provider.embed("hello world").unwrap();
2303 assert_eq!(embedding.len(), 1536);
2304 }
2305
2306 #[cfg(feature = "onnx-embeddings")]
2307 mod onnx_tests {
2308 use super::*;
2309
2310 #[test]
2311 #[ignore] fn test_onnx_embedding_minilm() {
2313 let provider =
2314 OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2315
2316 let embedding = provider.embed("hello world").unwrap();
2317 assert_eq!(embedding.len(), 384);
2318
2319 let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2321 assert!(
2322 (norm - 1.0).abs() < 1e-4,
2323 "Embedding should be normalized, got norm={}",
2324 norm
2325 );
2326 }
2327
2328 #[test]
2329 #[ignore] fn test_onnx_semantic_similarity() {
2331 let provider =
2332 OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2333
2334 let emb_dog = provider.embed("dog").unwrap();
2335 let emb_cat = provider.embed("cat").unwrap();
2336 let emb_car = provider.embed("car").unwrap();
2337
2338 let sim_dog_cat: f32 = emb_dog.iter().zip(&emb_cat).map(|(a, b)| a * b).sum();
2340 let sim_dog_car: f32 = emb_dog.iter().zip(&emb_car).map(|(a, b)| a * b).sum();
2341
2342 assert!(
2344 sim_dog_cat > sim_dog_car,
2345 "Expected dog-cat similarity ({}) > dog-car similarity ({})",
2346 sim_dog_cat,
2347 sim_dog_car
2348 );
2349 }
2350
2351 #[test]
2352 #[ignore] fn test_onnx_batch_embedding() {
2354 let provider =
2355 OnnxEmbedding::from_pretrained("sentence-transformers/all-MiniLM-L6-v2").unwrap();
2356
2357 let texts = vec!["hello world", "goodbye world", "rust programming"];
2358 let embeddings = provider.embed_batch(&texts).unwrap();
2359
2360 assert_eq!(embeddings.len(), 3);
2361 for emb in &embeddings {
2362 assert_eq!(emb.len(), 384);
2363 }
2364 }
2365 }
2366
2367 #[cfg(feature = "lattice-embeddings")]
2368 mod lattice_tests {
2369 use super::*;
2370 use crate::embeddings::LatticeEmbedding;
2371 use lattice_embed::EmbeddingModel as LatticeEmbeddingModel;
2372
2373 fn test_provider(alias: &str) -> LatticeEmbedding {
2374 let model: LatticeEmbeddingModel = alias.parse().unwrap();
2375 let identity =
2376 registry_identity("lattice-test", model.model_id(), model.dimensions()).unwrap();
2377 LatticeEmbedding::from_pretrained_with_identity(alias, identity).unwrap()
2378 }
2379
2380 #[test]
2387 fn test_lattice_from_pretrained_model_id_mapping() {
2388 let by_display_name = test_provider("bge-small-en-v1.5");
2389 assert_eq!(by_display_name.dimensions(), 384);
2390 assert_eq!(EmbeddingProvider::dimensions(&by_display_name), 384);
2391
2392 let by_hf_id = test_provider("BAAI/bge-small-en-v1.5");
2393 assert_eq!(by_hf_id.dimensions(), 384);
2394
2395 let unknown = LatticeEmbedding::from_pretrained("not-a-real-model");
2396 assert!(
2397 unknown.is_err(),
2398 "unknown model id should error, not default"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_lattice_from_pretrained_minilm_mapping() {
2404 let by_short = test_provider("all-minilm-l6-v2");
2405 assert_eq!(by_short.dimensions(), 384);
2406
2407 let by_hf_id = test_provider("sentence-transformers/all-MiniLM-L6-v2");
2408 assert_eq!(by_hf_id.dimensions(), 384);
2409 }
2410
2411 #[test]
2416 #[ignore]
2417 fn test_lattice_embedding_real() {
2418 let provider = test_provider("bge-small-en-v1.5");
2419
2420 let embedding = provider.embed("hello world").unwrap();
2421 assert_eq!(embedding.len(), 384);
2422 assert!(embedding.iter().all(|v| v.is_finite()));
2423
2424 let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
2425 assert!(
2426 (norm - 1.0).abs() < 1e-3,
2427 "embedding should be L2-normalized, got norm={norm}"
2428 );
2429 }
2430 }
2431}