gqls/semantic/mod.rs
1//! Semantic (embedding) search over schema records.
2//!
3//! Embeds `path + description + type` per record and the query with a local
4//! `all-MiniLM-L6-v2` model (pipeline borrowed from ae), compresses to 64-d
5//! Matryoshka vectors, and ranks by cosine. Falls back to a deterministic hash
6//! embedder when the model can't be fetched, so it always runs.
7//!
8//! v0 embeds every record per invocation (fine at schema scale). A persistent
9//! embedding cache keyed by schema hash — like ae's SQLite store — is the
10//! follow-up for very large / federated schemas; the embed and rank steps are
11//! kept separable here so that cache drops in cleanly.
12
13mod cache;
14mod embed;
15mod mrl;
16
17use crate::model::{Kind, SchemaRecord};
18use embed::{default_embedder, Embedder};
19use mrl::{compress_matryoshka_vector, cosine_similarity};
20
21/// Embed the query and each record, rank by cosine. Returns `(score, record)`
22/// pairs, best first — the caller formats them exactly like fuzzy hits, so
23/// `--json` / `--ndjson` work identically in both modes.
24pub fn search<'a>(
25 query: &str,
26 records: &'a [SchemaRecord],
27 kind: Option<Kind>,
28 limit: usize,
29 model: Option<&str>,
30 refresh: bool,
31) -> Vec<(f64, &'a SchemaRecord)> {
32 let embedder = default_embedder(model);
33 // On the good path (onnx) this is verbose-only noise; a fall back to the
34 // hash embedder means weaker results, so surface that unconditionally.
35 if embedder.kind() == "onnx" {
36 crate::detail!("semantic search via onnx embeddings");
37 } else {
38 crate::status!(
39 "semantic search via {} embeddings — model unavailable, results are weaker (-v for why)",
40 embedder.kind()
41 );
42 }
43
44 // Per-record vectors are the expensive part; cache them keyed by schema
45 // content + embedder + model, so editing the schema (or switching model)
46 // changes the key and re-embeds automatically. `--refresh` forces a miss.
47 let cache_path = cache::path(records, embedder.kind(), model);
48 let cached = if refresh {
49 None
50 } else {
51 cache_path
52 .as_deref()
53 .and_then(|p| cache::load(p, records.len()))
54 };
55 let vectors = match cached {
56 Some(v) => {
57 if let Some(p) = cache_path.as_deref() {
58 crate::detail!("vector cache hit: {}", p.display());
59 cache::touch(p); // LRU: mark this schema as recently used
60 }
61 v
62 }
63 None => {
64 if let Some(p) = cache_path.as_deref() {
65 crate::detail!("vector cache miss: {}", p.display());
66 }
67 use rayon::prelude::*;
68 use std::io::IsTerminal;
69 use std::sync::atomic::{AtomicUsize, Ordering};
70
71 let total = records.len();
72 crate::status!(
73 "embedding {total} records (one-time, then cached; a large schema can take ~a minute)…"
74 );
75 let done = AtomicUsize::new(0);
76
77 // One embedder per worker thread, built on first use and reused for
78 // every record that thread handles. rayon's `map_init` rebuilt it per
79 // job-split (≈ once per record), reloading the ONNX session tens of
80 // thousands of times on a large schema; a `thread_local` caps it at
81 // one per worker. `model` is constant for the process, so the workers
82 // all resolve the same embedder kind (no mixed onnx/hash vectors), and
83 // caching on "already built?" alone is safe. Order-preserving
84 // `collect` keeps vectors index-aligned with `records`.
85 use std::cell::RefCell;
86 thread_local! {
87 static EMBEDDER: RefCell<Option<Box<dyn Embedder>>> = const { RefCell::new(None) };
88 }
89 let v: Vec<Vec<f32>> = std::thread::scope(|scope| {
90 let show_progress =
91 std::io::stderr().is_terminal() && total > 500 && !crate::logging::is_quiet();
92 if show_progress {
93 scope.spawn(|| loop {
94 std::thread::sleep(std::time::Duration::from_millis(300));
95 let d = done.load(Ordering::Relaxed);
96 eprint!("\rgqls: embedded {d}/{total}… ");
97 if d >= total {
98 eprintln!();
99 break;
100 }
101 });
102 }
103 records
104 .par_iter()
105 .map(|r| {
106 let out = EMBEDDER.with(|cell| {
107 let mut slot = cell.borrow_mut();
108 let emb = slot.get_or_insert_with(|| default_embedder(model));
109 compress_matryoshka_vector(&emb.embed(&record_text(r)))
110 });
111 done.fetch_add(1, Ordering::Relaxed);
112 out
113 })
114 .collect()
115 });
116 if let Some(p) = cache_path.as_deref() {
117 cache::store(p, &v);
118 cache::prune(cache::max_files()); // evict least-recently-used
119 }
120 v
121 }
122 };
123
124 let query_vec = compress_matryoshka_vector(&embedder.embed(query));
125 let mut hits: Vec<(f64, &SchemaRecord)> = records
126 .iter()
127 .zip(&vectors)
128 .filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
129 .map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
130 .collect();
131
132 hits.sort_by(|a, b| b.0.total_cmp(&a.0));
133 hits.truncate(limit);
134 hits
135}
136
137/// The natural-language signal a semantic query matches against: the path plus
138/// the human description and the type.
139fn record_text(r: &SchemaRecord) -> String {
140 let mut s = r.path.clone();
141 if let Some(d) = &r.description {
142 s.push_str(" — ");
143 s.push_str(d);
144 }
145 if let Some(t) = &r.type_ref {
146 s.push_str(" : ");
147 s.push_str(t);
148 }
149 s
150}
151
152/// Delete all cached embedding vector files; returns how many were removed.
153pub fn clear_cache() -> usize {
154 cache::clear()
155}
156
157/// Whether the schema's vectors are already cached (either embedder kind), so a
158/// semantic search would be warm. Checked without loading a model.
159pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
160 cache::exists(records, "onnx", model) || cache::exists(records, "hash", model)
161}
162
163/// Embed + cache every record's vector without running a real query — pre-warms
164/// the cache so the first search is instant. Returns the record count.
165pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
166 let _ = search("", records, None, 0, model, refresh);
167 records.len()
168}