mod cache;
mod embed;
mod mrl;
use crate::model::{Kind, SchemaRecord};
use embed::{default_embedder, Embedder};
use mrl::{compress_matryoshka_vector, cosine_similarity};
pub fn search<'a>(
query: &str,
records: &'a [SchemaRecord],
kind: Option<Kind>,
parent: Option<&str>,
limit: usize,
model: Option<&str>,
refresh: bool,
) -> Vec<(f64, &'a SchemaRecord)> {
let embedder = default_embedder(model);
if embedder.kind() == "onnx" {
crate::detail!("semantic search via onnx embeddings");
} else {
crate::status!(
"semantic search via {} embeddings — model unavailable, results are weaker (-v for why)",
embedder.kind()
);
}
let cache_path = cache::path(records, embedder.kind(), model);
let cached = if refresh {
None
} else {
cache_path
.as_deref()
.and_then(|p| cache::load(p, records.len()))
};
let vectors = match cached {
Some(v) => {
if let Some(p) = cache_path.as_deref() {
crate::detail!("vector cache hit: {}", p.display());
cache::touch(p); }
v
}
None => {
if let Some(p) = cache_path.as_deref() {
crate::detail!("vector cache miss: {}", p.display());
}
use rayon::prelude::*;
use std::io::IsTerminal;
use std::sync::atomic::{AtomicUsize, Ordering};
let total = records.len();
crate::status!(
"embedding {total} records (one-time, then cached; a large schema can take ~a minute)…"
);
let done = AtomicUsize::new(0);
use std::cell::RefCell;
thread_local! {
static EMBEDDER: RefCell<Option<Box<dyn Embedder>>> = const { RefCell::new(None) };
}
let v: Vec<Vec<f32>> = std::thread::scope(|scope| {
let show_progress =
std::io::stderr().is_terminal() && total > 500 && !crate::logging::is_quiet();
if show_progress {
scope.spawn(|| loop {
std::thread::sleep(std::time::Duration::from_millis(300));
let d = done.load(Ordering::Relaxed);
eprint!("\rgqls: embedded {d}/{total}… ");
if d >= total {
eprintln!();
break;
}
});
}
records
.par_iter()
.map(|r| {
let out = EMBEDDER.with(|cell| {
let mut slot = cell.borrow_mut();
let emb = slot.get_or_insert_with(|| default_embedder(model));
compress_matryoshka_vector(&emb.embed(&record_text(r)))
});
done.fetch_add(1, Ordering::Relaxed);
out
})
.collect()
});
if let Some(p) = cache_path.as_deref() {
cache::store(p, &v);
cache::prune(cache::max_files()); }
v
}
};
if query.is_empty() && limit == 0 {
return Vec::new();
}
let query_vec = compress_matryoshka_vector(&embedder.embed(query));
let mut hits: Vec<(f64, &SchemaRecord)> = records
.iter()
.zip(&vectors)
.filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
.filter(|(r, _)| {
parent.is_none_or(|p| {
r.parent
.as_deref()
.is_some_and(|rp| rp.eq_ignore_ascii_case(p))
})
})
.map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
.collect();
hits.sort_by(|a, b| b.0.total_cmp(&a.0));
hits.truncate(limit);
hits
}
fn record_text(r: &SchemaRecord) -> String {
let mut s = r.path.clone();
if let Some(d) = &r.description {
s.push_str(" — ");
s.push_str(d);
}
if let Some(t) = &r.type_ref {
s.push_str(" : ");
s.push_str(t);
}
s
}
pub fn clear_cache() -> usize {
cache::clear()
}
pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
cache::exists(records, "onnx", model)
}
pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
let _ = search("", records, None, None, 0, model, refresh);
records.len()
}