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/// Drop hits below this fraction of the top cosine. Deliberately mild:
22/// measured cosine curves decay smoothly (no tiers, no cliff), and near the
23/// noise floor relevant and irrelevant records interleave — so this bounds
24/// the deep tail (`-l 500` returning 500 rows of monotonic noise), it does
25/// not judge relevance.
26const TAIL_CUTOFF: f64 = 0.7;
27
28/// Apply [`TAIL_CUTOFF`] relative to the best hit. Skipped when the top score
29/// isn't positive — a floor computed from a negative top would drop even the
30/// top itself.
31fn bound_tail<T>(hits: &mut Vec<(f64, T)>) {
32 let Some(top) = hits.first().map(|h| h.0).filter(|t| *t > 0.0) else {
33 return;
34 };
35 let floor = top * TAIL_CUTOFF;
36 hits.retain(|(s, _)| *s >= floor);
37}
38
39/// Embed the query and each record, rank by cosine. Returns `(score, record)`
40/// pairs, best first — the caller formats them exactly like fuzzy hits, so
41/// `--json` / `--ndjson` work identically in both modes.
42///
43/// The model load is deliberately synchronous, not overlapped on a thread:
44/// ONNX Runtime's C++ one-time init segfaults if the process exits mid-load
45/// (a thread dropped on a fuzzy-only exit), and joining always would erase
46/// the overlap. The exact-match skip in the CLI avoids the cost where it
47/// matters instead.
48// The three record filters (kind/parent/returns) plus the embedding knobs add
49// up; grouping the filters into one struct shared with `search::search` is the
50// tidy-up, worth doing when the next filter lands.
51#[allow(clippy::too_many_arguments)]
52pub fn search<'a>(
53 query: &str,
54 records: &'a [SchemaRecord],
55 kind: Option<Kind>,
56 parent: Option<&str>,
57 returns: Option<&str>,
58 limit: usize,
59 model: Option<&str>,
60 refresh: bool,
61) -> Vec<(f64, &'a SchemaRecord)> {
62 let embedder = default_embedder(model);
63 // On the good path (onnx) this is verbose-only noise; a fall back to the
64 // hash embedder means weaker results, so surface that unconditionally.
65 if embedder.kind() == "onnx" {
66 crate::detail!("semantic search via onnx embeddings");
67 } else {
68 crate::status!(
69 "embedding model unavailable — semantic results will be weaker (-v for why)"
70 );
71 }
72
73 // Per-record vectors are the expensive part; cache them keyed by schema
74 // content + embedder + model, so editing the schema (or switching model)
75 // changes the key and re-embeds automatically. `--refresh` forces a miss.
76 let cache_path = cache::path(records, embedder.kind(), model);
77 let cached = if refresh {
78 None
79 } else {
80 cache_path
81 .as_deref()
82 .and_then(|p| cache::load(p, records.len()))
83 };
84 let vectors = match cached {
85 Some(v) => {
86 if let Some(p) = cache_path.as_deref() {
87 crate::detail!("vector cache hit: {}", crate::paths::display(p));
88 cache::touch(p); // LRU: mark this schema as recently used
89 }
90 v
91 }
92 None => {
93 if let Some(p) = cache_path.as_deref() {
94 crate::detail!("vector cache miss: {}", crate::paths::display(p));
95 }
96 use rayon::prelude::*;
97 use std::io::IsTerminal;
98 use std::sync::atomic::{AtomicUsize, Ordering};
99
100 let total = records.len();
101 crate::status!("embedding {total} records (one-time; may take a minute)…");
102 let done = AtomicUsize::new(0);
103
104 // One embedder per worker thread, built on first use and reused for
105 // every record that thread handles. rayon's `map_init` rebuilt it per
106 // job-split (≈ once per record), reloading the ONNX session tens of
107 // thousands of times on a large schema; a `thread_local` caps it at
108 // one per worker. `model` is constant for the process, so the workers
109 // all resolve the same embedder kind (no mixed onnx/hash vectors), and
110 // caching on "already built?" alone is safe. Order-preserving
111 // `collect` keeps vectors index-aligned with `records`.
112 use std::cell::RefCell;
113 thread_local! {
114 static EMBEDDER: RefCell<Option<Box<dyn Embedder>>> = const { RefCell::new(None) };
115 }
116 let v: Vec<Vec<f32>> = std::thread::scope(|scope| {
117 let show_progress =
118 std::io::stderr().is_terminal() && total > 500 && !crate::logging::is_quiet();
119 if show_progress {
120 scope.spawn(|| loop {
121 std::thread::sleep(std::time::Duration::from_millis(300));
122 let d = done.load(Ordering::Relaxed);
123 eprint!("\rgqls: embedded {d}/{total}… ");
124 if d >= total {
125 eprintln!();
126 break;
127 }
128 });
129 }
130 records
131 .par_iter()
132 .map(|r| {
133 let out = EMBEDDER.with(|cell| {
134 let mut slot = cell.borrow_mut();
135 let emb = slot.get_or_insert_with(|| default_embedder(model));
136 compress_matryoshka_vector(&emb.embed(&record_text(r)))
137 });
138 done.fetch_add(1, Ordering::Relaxed);
139 out
140 })
141 .collect()
142 });
143 if let Some(p) = cache_path.as_deref() {
144 cache::store(p, &v);
145 cache::prune(cache::max_files()); // evict least-recently-used
146 }
147 v
148 }
149 };
150
151 // warm() calls with an empty query and limit 0 purely to populate the cache
152 // above — there's nothing to rank, so skip the query embed + cosine pass.
153 if query.is_empty() && limit == 0 {
154 return Vec::new();
155 }
156
157 let query_vec = compress_matryoshka_vector(&embedder.embed(query));
158 let returns = returns.map(crate::search::glob::Pattern::new);
159 let mut hits: Vec<(f64, &SchemaRecord)> = records
160 .iter()
161 .zip(&vectors)
162 .filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
163 .filter(|(r, _)| crate::search::matches_returns(r, returns.as_ref()))
164 .filter(|(r, _)| {
165 parent.is_none_or(|p| {
166 r.parent
167 .as_deref()
168 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
169 })
170 })
171 .map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
172 .collect();
173
174 hits.sort_by(|a, b| b.0.total_cmp(&a.0));
175 bound_tail(&mut hits);
176 hits.truncate(limit);
177 hits
178}
179
180/// The natural-language signal a semantic query matches against: the path plus
181/// the human description and the type.
182fn record_text(r: &SchemaRecord) -> String {
183 let mut s = r.path.clone();
184 if let Some(d) = &r.description {
185 s.push_str(" — ");
186 s.push_str(d);
187 }
188 if let Some(t) = &r.type_ref {
189 s.push_str(" : ");
190 s.push_str(t);
191 }
192 s
193}
194
195/// Delete all cached embedding vector files; returns how many were removed.
196pub fn clear_cache() -> usize {
197 cache::clear()
198}
199
200/// Whether the schema's real (ONNX) vectors are cached, so the default combine
201/// path can run without a foreground embed. Deliberately ignores hash-fallback
202/// vectors: a run re-selects the embedder and keys the cache on *its* kind, so
203/// treating a stale `hash` cache as "warm" while the run picks `onnx` would
204/// trigger exactly the surprise foreground embed this gate exists to avoid.
205/// Checked without loading a model.
206pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
207 cache::exists(records, "onnx", model)
208}
209
210/// Embed + cache every record's vector without running a real query — pre-warms
211/// the cache so the first search is instant. Returns the record count.
212pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
213 let _ = search("", records, None, None, None, 0, model, refresh);
214 records.len()
215}
216
217#[cfg(test)]
218mod tests {
219 use super::bound_tail;
220
221 #[test]
222 fn bounds_the_weak_tail_relative_to_the_top() {
223 let mut hits = vec![(1.0, "a"), (0.8, "b"), (0.6, "c")];
224 bound_tail(&mut hits);
225 assert_eq!(hits.len(), 2);
226 }
227
228 #[test]
229 fn keeps_everything_when_the_top_is_not_positive() {
230 let mut hits = vec![(-0.1, "a"), (-0.5, "b")];
231 bound_tail(&mut hits);
232 assert_eq!(hits.len(), 2);
233 }
234}