Skip to main content

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