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.
24///
25/// The model load is deliberately synchronous, not overlapped on a thread:
26/// ONNX Runtime's C++ one-time init segfaults if the process exits mid-load
27/// (a thread dropped on a fuzzy-only exit), and joining always would erase
28/// the overlap. The exact-match skip in the CLI avoids the cost where it
29/// matters instead.
30pub fn search<'a>(
31 query: &str,
32 records: &'a [SchemaRecord],
33 kind: Option<Kind>,
34 parent: Option<&str>,
35 limit: usize,
36 model: Option<&str>,
37 refresh: bool,
38) -> Vec<(f64, &'a SchemaRecord)> {
39 let embedder = default_embedder(model);
40 // On the good path (onnx) this is verbose-only noise; a fall back to the
41 // hash embedder means weaker results, so surface that unconditionally.
42 if embedder.kind() == "onnx" {
43 crate::detail!("semantic search via onnx embeddings");
44 } else {
45 crate::status!(
46 "semantic search via {} embeddings — model unavailable, results are weaker (-v for why)",
47 embedder.kind()
48 );
49 }
50
51 // Per-record vectors are the expensive part; cache them keyed by schema
52 // content + embedder + model, so editing the schema (or switching model)
53 // changes the key and re-embeds automatically. `--refresh` forces a miss.
54 let cache_path = cache::path(records, embedder.kind(), model);
55 let cached = if refresh {
56 None
57 } else {
58 cache_path
59 .as_deref()
60 .and_then(|p| cache::load(p, records.len()))
61 };
62 let vectors = match cached {
63 Some(v) => {
64 if let Some(p) = cache_path.as_deref() {
65 crate::detail!("vector cache hit: {}", p.display());
66 cache::touch(p); // LRU: mark this schema as recently used
67 }
68 v
69 }
70 None => {
71 if let Some(p) = cache_path.as_deref() {
72 crate::detail!("vector cache miss: {}", p.display());
73 }
74 use rayon::prelude::*;
75 use std::io::IsTerminal;
76 use std::sync::atomic::{AtomicUsize, Ordering};
77
78 let total = records.len();
79 crate::status!(
80 "embedding {total} records (one-time, then cached; a large schema can take ~a minute)…"
81 );
82 let done = AtomicUsize::new(0);
83
84 // One embedder per worker thread, built on first use and reused for
85 // every record that thread handles. rayon's `map_init` rebuilt it per
86 // job-split (≈ once per record), reloading the ONNX session tens of
87 // thousands of times on a large schema; a `thread_local` caps it at
88 // one per worker. `model` is constant for the process, so the workers
89 // all resolve the same embedder kind (no mixed onnx/hash vectors), and
90 // caching on "already built?" alone is safe. Order-preserving
91 // `collect` keeps vectors index-aligned with `records`.
92 use std::cell::RefCell;
93 thread_local! {
94 static EMBEDDER: RefCell<Option<Box<dyn Embedder>>> = const { RefCell::new(None) };
95 }
96 let v: Vec<Vec<f32>> = std::thread::scope(|scope| {
97 let show_progress =
98 std::io::stderr().is_terminal() && total > 500 && !crate::logging::is_quiet();
99 if show_progress {
100 scope.spawn(|| loop {
101 std::thread::sleep(std::time::Duration::from_millis(300));
102 let d = done.load(Ordering::Relaxed);
103 eprint!("\rgqls: embedded {d}/{total}… ");
104 if d >= total {
105 eprintln!();
106 break;
107 }
108 });
109 }
110 records
111 .par_iter()
112 .map(|r| {
113 let out = EMBEDDER.with(|cell| {
114 let mut slot = cell.borrow_mut();
115 let emb = slot.get_or_insert_with(|| default_embedder(model));
116 compress_matryoshka_vector(&emb.embed(&record_text(r)))
117 });
118 done.fetch_add(1, Ordering::Relaxed);
119 out
120 })
121 .collect()
122 });
123 if let Some(p) = cache_path.as_deref() {
124 cache::store(p, &v);
125 cache::prune(cache::max_files()); // evict least-recently-used
126 }
127 v
128 }
129 };
130
131 // warm() calls with an empty query and limit 0 purely to populate the cache
132 // above — there's nothing to rank, so skip the query embed + cosine pass.
133 if query.is_empty() && limit == 0 {
134 return Vec::new();
135 }
136
137 let query_vec = compress_matryoshka_vector(&embedder.embed(query));
138 let mut hits: Vec<(f64, &SchemaRecord)> = records
139 .iter()
140 .zip(&vectors)
141 .filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
142 .filter(|(r, _)| {
143 parent.is_none_or(|p| {
144 r.parent
145 .as_deref()
146 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
147 })
148 })
149 .map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
150 .collect();
151
152 hits.sort_by(|a, b| b.0.total_cmp(&a.0));
153 hits.truncate(limit);
154 hits
155}
156
157/// The natural-language signal a semantic query matches against: the path plus
158/// the human description and the type.
159fn record_text(r: &SchemaRecord) -> String {
160 let mut s = r.path.clone();
161 if let Some(d) = &r.description {
162 s.push_str(" — ");
163 s.push_str(d);
164 }
165 if let Some(t) = &r.type_ref {
166 s.push_str(" : ");
167 s.push_str(t);
168 }
169 s
170}
171
172/// Delete all cached embedding vector files; returns how many were removed.
173pub fn clear_cache() -> usize {
174 cache::clear()
175}
176
177/// Whether the schema's real (ONNX) vectors are cached, so the default combine
178/// path can run without a foreground embed. Deliberately ignores hash-fallback
179/// vectors: a run re-selects the embedder and keys the cache on *its* kind, so
180/// treating a stale `hash` cache as "warm" while the run picks `onnx` would
181/// trigger exactly the surprise foreground embed this gate exists to avoid.
182/// Checked without loading a model.
183pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
184 cache::exists(records, "onnx", model)
185}
186
187/// Embed + cache every record's vector without running a real query — pre-warms
188/// the cache so the first search is instant. Returns the record count.
189pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
190 let _ = search("", records, None, None, 0, model, refresh);
191 records.len()
192}