use ort::session::Session;
use ort::value::Tensor;
use serde::Deserialize;
use std::error::Error;
use std::path::Path;
use tokenizers::Tokenizer;
type Res<T> = Result<T, Box<dyn Error + Send + Sync>>;
#[derive(Deserialize)]
struct FacetsCfg {
facets: Vec<String>,
vocab_size: usize,
}
#[derive(Debug, Clone)]
pub struct FacetTerm {
pub token: String,
pub term: String,
pub weight: f32,
}
pub struct SpladeProjector {
session: Session,
tok: Tokenizer,
facets: Vec<String>,
vocab_size: usize,
bpe: bool,
}
const TRIG_STOP: &[&str] = &[
"the", "a", "an", "of", "to", "in", "on", "for", "and", "or", "is", "are", "was", "were", "be",
"been", "this", "that", "these", "those", "with", "by", "from", "as", "at", "it", "its", "their",
"our", "your", "we", "they", "them", "then", "than", "so", "such", "can", "may", "will", "would",
"could", "should", "not", "no", "into", "over", "under", "more", "most", "many", "much", "few",
"some", "any", "all", "each", "using", "use", "used",
];
const FACET_STOP: &[&str] = &[
"and", "the", "of", "to", "in", "on", "for", "is", "are", "was", "were", "be", "been", "this",
"that", "these", "those", "not", "all", "any", "based", "item", "source", "record", "note",
"summary", "review", "section", "abstract", "its", "our", "their", "with", "by", "from", "as",
"at", "we", "they", "it", "a", "an", "or", "new",
];
fn word_span_at(pieces: &[String], offsets: &[(usize, usize)], pos: usize, text: &str) -> Option<(usize, usize, String)> {
if pos >= pieces.len() {
return None;
}
let mut s = pos;
let mut e = pos;
while s > 0 && pieces[s].starts_with("##") {
s -= 1;
}
while e + 1 < pieces.len() && pieces[e + 1].starts_with("##") {
e += 1;
}
let (a, b) = (offsets.get(s)?.0, offsets.get(e)?.1);
if b <= a {
return None;
}
Some((a, b, text.get(a..b)?.to_string()))
}
fn slug_ascii(s: &str) -> String {
let mut out = String::new();
let mut dash = false;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
dash = false;
} else if !out.is_empty() && !dash {
out.push('-');
dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn slug_unicode(s: &str) -> String {
let s = s.strip_prefix('\u{2581}').unwrap_or(s); let mut out = String::new();
let mut dash = false;
for ch in s.chars() {
if ch.is_alphanumeric() {
out.extend(ch.to_lowercase());
dash = false;
} else if !out.is_empty() && !dash {
out.push('-');
dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
impl SpladeProjector {
pub fn load(dir: &Path, multilingual: bool) -> Res<SpladeProjector> {
let cfg: FacetsCfg = serde_json::from_slice(&std::fs::read(dir.join("facets.json"))?)?;
let session = Session::builder()?.commit_from_file(dir.join("splade.onnx"))?;
let mut tok = Tokenizer::from_file(dir.join("tokenizer/tokenizer.json"))?;
tok.with_truncation(Some(tokenizers::TruncationParams {
max_length: 64,
..Default::default()
}))?;
Ok(SpladeProjector { session, tok, facets: cfg.facets, vocab_size: cfg.vocab_size, bpe: multilingual })
}
pub fn facets(&self) -> &[String] {
&self.facets
}
pub fn project(&mut self, text: &str, top_k: usize, min_weight: f32) -> Res<Vec<FacetTerm>> {
let enc = self.tok.encode(text, true)?;
let ids: Vec<i64> = enc.get_ids().iter().map(|&x| x as i64).collect();
let t = ids.len();
let attn: Vec<i64> = vec![1; t];
let id_t = Tensor::from_array(([1usize, t], ids))?;
let at_t = Tensor::from_array(([1usize, t], attn))?;
let outputs = self
.session
.run(ort::inputs!["input_ids" => id_t, "attention_mask" => at_t])?;
let (_shape, vecs) = outputs["vecs"].try_extract_tensor::<f32>()?; let src: Option<&[i64]> = outputs.get("src").and_then(|v| v.try_extract_tensor::<i64>().ok()).map(|(_, s)| s);
let in_pieces: Vec<String> = enc.get_tokens().to_vec();
let offsets: Vec<(usize, usize)> = enc.get_offsets().to_vec();
let v = self.vocab_size;
let mut out = Vec::new();
for (f, facet) in self.facets.iter().enumerate() {
let base = f * v;
let mut hits: Vec<(usize, f32)> = Vec::new();
for id in 0..v {
let w = vecs[base + id];
if w > min_weight {
hits.push((id, w));
}
}
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut seen = std::collections::HashSet::new();
let mut n = 0;
for (id, w) in hits {
let wp = match self.tok.id_to_token(id as u32) {
Some(s) => s,
None => continue,
};
let s = if self.bpe {
if !wp.starts_with('\u{2581}') || wp.starts_with('<') || wp.starts_with('[') {
continue;
}
slug_unicode(&wp)
} else {
if wp.starts_with("##")
|| wp.starts_with('[')
|| wp.chars().filter(|c| c.is_ascii_alphabetic()).count() < 3
|| FACET_STOP.contains(&wp.as_str())
{
continue;
}
let mut chosen = slug_ascii(&wp);
if let Some(src) = src {
let pos = *src.get(base + id).unwrap_or(&-1);
if pos >= 0 {
if let Some((_, _, word)) = word_span_at(&in_pieces, &offsets, pos as usize, text) {
let wl = word.to_lowercase();
let clean = word.chars().count() >= 3
&& word.chars().all(|c| c.is_ascii_alphabetic())
&& !TRIG_STOP.contains(&wl.as_str());
let prefix: String = wp.chars().take(3).flat_map(|c| c.to_lowercase()).collect();
if clean && wl.contains(&prefix) {
chosen = slug_ascii(&word);
}
}
}
}
chosen
};
if s.len() < 2 || !seen.insert(s.clone()) {
continue;
}
out.push(FacetTerm {
token: format!("{facet}/{s}"),
term: wp.trim_start_matches('\u{2581}').to_string(),
weight: (w * 1000.0).round() / 1000.0,
});
n += 1;
if n >= top_k {
break;
}
}
}
Ok(out)
}
}