1use crate::embed::Embedder;
11use std::path::Path;
12use std::sync::Arc;
13use tract_onnx::prelude::*;
14
15type RunnableOnnxModel = Arc<TypedRunnableModel>;
16
17pub struct OnnxEmbedder {
19 model: RunnableOnnxModel,
20 tokenizer: tokenizers::Tokenizer,
21 input_count: usize,
22 input_order: Vec<usize>,
26 dim: usize,
27}
28
29fn classify_input_name(name: &str) -> Option<usize> {
32 let lower = name.to_ascii_lowercase();
33 if lower.contains("input_id") {
34 Some(0)
35 } else if lower.contains("attention") || lower.contains("mask") {
36 Some(1)
37 } else if lower.contains("token_type") || lower.contains("segment") {
38 Some(2)
39 } else {
40 None
41 }
42}
43
44fn resolve_input_order(names: &[String]) -> Result<Vec<usize>, String> {
61 let classified: Vec<Option<usize>> = names.iter().map(|n| classify_input_name(n)).collect();
62 if classified.iter().all(Option::is_none) {
63 return Ok((0..names.len()).collect());
64 }
65 let Some(order) = classified.into_iter().collect::<Option<Vec<usize>>>() else {
66 return Err(format!(
67 "ONNX sentence model input names {names:?} are only partially recognizable; \
68 refusing an ambiguous tensor binding (expected names containing \
69 input_ids / attention_mask / token_type_ids, or none of them)"
70 ));
71 };
72 let mut seen = vec![false; names.len()];
73 for &slot in &order {
74 if slot >= names.len() || seen.get(slot).copied().unwrap_or(true) {
75 return Err(format!(
76 "ONNX sentence model input names {names:?} do not form exactly one each of \
77 input_ids / attention_mask{}",
78 if names.len() == 3 { " / token_type_ids" } else { "" }
79 ));
80 }
81 if let Some(flag) = seen.get_mut(slot) {
82 *flag = true;
83 }
84 }
85 Ok(order)
86}
87
88impl OnnxEmbedder {
89 pub fn load(path: &Path, tokenizer_path: &Path, dim: usize) -> Result<Self, String> {
91 if dim == 0 {
92 return Err("ONNX embedding dimension must be greater than zero".to_owned());
93 }
94 let model = tract_onnx::onnx()
95 .model_for_path(path)
96 .map_err(|error| error.to_string())?
97 .into_optimized()
98 .map_err(|error| error.to_string())?;
99 let input_outlets = model.input_outlets().map_err(|error| error.to_string())?.to_vec();
100 let input_count = input_outlets.len();
101 if !(2..=3).contains(&input_count) {
102 return Err(format!(
103 "ONNX sentence model must have 2 or 3 inputs, found {input_count}"
104 ));
105 }
106 let input_names: Vec<String> = input_outlets
107 .iter()
108 .map(|outlet| model.node(outlet.node).name.clone())
109 .collect();
110 let input_order = resolve_input_order(&input_names)?;
111 let model = model.into_runnable().map_err(|error| error.to_string())?;
112 let tokenizer =
113 tokenizers::Tokenizer::from_file(tokenizer_path).map_err(|error| error.to_string())?;
114 Ok(Self {
115 model,
116 tokenizer,
117 input_count,
118 input_order,
119 dim,
120 })
121 }
122
123 fn infer(&self, text: &str) -> Result<Vec<f32>, String> {
124 let encoding = self
125 .tokenizer
126 .encode(text, true)
127 .map_err(|error| error.to_string())?;
128 let ids: Vec<i64> = encoding
129 .get_ids()
130 .iter()
131 .take(512)
132 .map(|id| i64::from(*id))
133 .collect();
134 if ids.is_empty() {
135 return Ok(vec![0.0; self.dim]);
136 }
137 let len = ids.len();
138 let input =
139 tract_ndarray::Array2::from_shape_vec((1, len), ids).map_err(|error| error.to_string())?;
140 let mask_values: Vec<i64> = encoding
141 .get_attention_mask()
142 .iter()
143 .take(len)
144 .map(|value| i64::from(*value))
145 .collect();
146 let mask = tract_ndarray::Array2::from_shape_vec((1, len), mask_values.clone())
147 .map_err(|error| error.to_string())?;
148 let inputs = {
149 let mut semantic: Vec<TValue> = vec![Tensor::from(input).into(), Tensor::from(mask).into()];
153 if self.input_count == 3 {
154 let token_types: Vec<i64> = encoding
155 .get_type_ids()
156 .iter()
157 .take(len)
158 .map(|value| i64::from(*value))
159 .collect();
160 let token_types = tract_ndarray::Array2::from_shape_vec((1, len), token_types)
161 .map_err(|error| error.to_string())?;
162 semantic.push(Tensor::from(token_types).into());
163 }
164 self.input_order
165 .iter()
166 .map(|&slot| {
167 semantic
168 .get(slot)
169 .cloned()
170 .ok_or_else(|| format!("ONNX input slot {slot} out of range"))
171 })
172 .collect::<Result<TVec<TValue>, String>>()?
173 };
174 let outputs = self.model.run(inputs).map_err(|error| error.to_string())?;
175 let output = outputs
176 .first()
177 .ok_or_else(|| "ONNX model returned no output".to_owned())?;
178 let view = output
179 .to_plain_array_view::<f32>()
180 .map_err(|error| error.to_string())?;
181 let mut vector = pool_output(view, &mask_values, self.dim)?;
182 let norm = vector
187 .iter()
188 .map(|value| f64::from(*value) * f64::from(*value))
189 .sum::<f64>()
190 .sqrt();
191 if norm > 0.0 {
192 for value in &mut vector {
193 #[allow(clippy::cast_possible_truncation)]
196 {
197 *value = (f64::from(*value) / norm) as f32;
198 }
199 }
200 }
201 Ok(vector)
202 }
203}
204
205fn pool_output(
206 output: tract_ndarray::ArrayViewD<'_, f32>,
207 attention_mask: &[i64],
208 expected_dim: usize,
209) -> Result<Vec<f32>, String> {
210 let shape = output.shape();
211 let vector = match shape {
212 [width] if *width == expected_dim => output.iter().copied().collect(),
213 [1, width] if *width == expected_dim => output.iter().copied().collect(),
214 [1, tokens, width] if *width == expected_dim && *tokens == attention_mask.len() => {
215 let mut pooled = vec![0.0f32; expected_dim];
216 let mut weight = 0.0f32;
217 for (token, mask) in attention_mask.iter().enumerate() {
218 if *mask <= 0 {
219 continue;
220 }
221 let token_weight = *mask as f32;
222 weight += token_weight;
223 for (feature, value) in pooled.iter_mut().enumerate() {
224 let index = tract_ndarray::IxDyn(&[0, token, feature]);
225 let embedding = output
226 .get(index)
227 .ok_or_else(|| "ONNX output index escaped validated shape".to_owned())?;
228 *value += *embedding * token_weight;
229 }
230 }
231 if weight == 0.0 {
232 return Err("ONNX attention mask contains no active tokens".to_owned());
233 }
234 for value in &mut pooled {
235 *value /= weight;
236 }
237 pooled
238 }
239 _ => {
240 return Err(format!(
241 "ONNX output shape {shape:?} is incompatible with embedding dimension {expected_dim} and token count {}",
242 attention_mask.len()
243 ));
244 }
245 };
246 if vector.iter().any(|value| !value.is_finite()) {
247 return Err("ONNX output contains non-finite values".to_owned());
248 }
249 Ok(vector)
250}
251
252impl Embedder for OnnxEmbedder {
253 fn dim(&self) -> usize {
254 self.dim
255 }
256
257 fn embed(&self, text: &str) -> Vec<f32> {
258 self.infer(text)
259 .unwrap_or_else(|error| fallback_hash_embedding(self.dim, text, &error))
260 }
261
262 fn try_embed(&self, text: &str) -> Result<Vec<f32>, String> {
263 Ok(self
271 .infer(text)
272 .unwrap_or_else(|error| fallback_hash_embedding(self.dim, text, &error)))
273 }
274}
275
276fn fallback_hash_embedding(dim: usize, text: &str, error: &str) -> Vec<f32> {
283 tracing::warn!(
284 error = %error,
285 dim,
286 "ONNX inference failed; falling back to deterministic HashEmbedder \
287 (non-zero so the breaker does not treat outages as false duplicates)"
288 );
289 crate::HashEmbedder::new(dim).embed(text)
290}
291
292#[cfg(test)]
293mod tests {
294 #![allow(clippy::indexing_slicing, clippy::unwrap_used)]
295
296 use super::*;
297
298 #[test]
299 fn token_embeddings_use_masked_mean_pooling() {
300 let output = tract_ndarray::Array3::from_shape_vec((1, 3, 2), vec![1.0, 0.0, 1.0, 2.0, 100.0, 100.0])
301 .unwrap()
302 .into_dyn();
303 let mut vector = pool_output(output.view(), &[1, 1, 0], 2).unwrap();
304 let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
305 for value in &mut vector {
306 *value /= norm;
307 }
308 let expected = 1.0 / 2.0f32.sqrt();
309 assert!((vector[0] - expected).abs() < 1e-6);
310 assert!((vector[1] - expected).abs() < 1e-6);
311 }
312
313 #[test]
314 fn pooled_export_requires_exact_embedding_width() {
315 let output = tract_ndarray::Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0])
316 .unwrap()
317 .into_dyn();
318 let error = pool_output(output.view(), &[1], 2).unwrap_err();
319 assert!(error.contains("output shape"));
320 }
321
322 #[test]
323 fn token_output_requires_matching_attention_mask() {
324 let output = tract_ndarray::Array3::zeros((1, 2, 3)).into_dyn();
325 let error = pool_output(output.view(), &[1], 3).unwrap_err();
326 assert!(error.contains("token count 1"));
327 }
328
329 fn names(list: &[&str]) -> Vec<String> {
330 list.iter().map(|s| (*s).to_owned()).collect()
331 }
332
333 #[test]
340 fn input_order_resolves_names_in_any_order() {
341 assert_eq!(
342 resolve_input_order(&names(&["input_ids", "attention_mask", "token_type_ids"])).unwrap(),
343 vec![0, 1, 2]
344 );
345 assert_eq!(
346 resolve_input_order(&names(&["attention_mask", "input_ids", "token_type_ids"])).unwrap(),
347 vec![1, 0, 2]
348 );
349 assert_eq!(
350 resolve_input_order(&names(&["input_ids", "attention_mask"])).unwrap(),
351 vec![0, 1]
352 );
353 assert_eq!(
356 resolve_input_order(&names(&["input_ids.cast", "ATTENTION_MASK_0"])).unwrap(),
357 vec![0, 1]
358 );
359 }
360
361 #[test]
362 fn input_order_falls_back_to_position_for_anonymized_exports() {
363 assert_eq!(
364 resolve_input_order(&names(&["input.1", "input.3", "input.5"])).unwrap(),
365 vec![0, 1, 2]
366 );
367 }
368
369 #[test]
370 fn input_order_refuses_ambiguous_name_sets() {
371 assert!(resolve_input_order(&names(&["input_ids", "input.3"]))
373 .unwrap_err()
374 .contains("partially recognizable"));
375 assert!(resolve_input_order(&names(&["attention_mask", "mask_two"]))
377 .unwrap_err()
378 .contains("exactly one each"));
379 assert!(resolve_input_order(&names(&["input_ids", "token_type_ids"]))
381 .unwrap_err()
382 .contains("exactly one each"));
383 }
384}