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/// 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    // warm() calls with an empty query and limit 0 purely to populate the cache
125    // above — there's nothing to rank, so skip the query embed + cosine pass.
126    if query.is_empty() && limit == 0 {
127        return Vec::new();
128    }
129
130    let query_vec = compress_matryoshka_vector(&embedder.embed(query));
131    let mut hits: Vec<(f64, &SchemaRecord)> = records
132        .iter()
133        .zip(&vectors)
134        .filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
135        .map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
136        .collect();
137
138    hits.sort_by(|a, b| b.0.total_cmp(&a.0));
139    hits.truncate(limit);
140    hits
141}
142
143/// The natural-language signal a semantic query matches against: the path plus
144/// the human description and the type.
145fn record_text(r: &SchemaRecord) -> String {
146    let mut s = r.path.clone();
147    if let Some(d) = &r.description {
148        s.push_str(" — ");
149        s.push_str(d);
150    }
151    if let Some(t) = &r.type_ref {
152        s.push_str(" : ");
153        s.push_str(t);
154    }
155    s
156}
157
158/// Delete all cached embedding vector files; returns how many were removed.
159pub fn clear_cache() -> usize {
160    cache::clear()
161}
162
163/// Whether the schema's real (ONNX) vectors are cached, so the default combine
164/// path can run without a foreground embed. Deliberately ignores hash-fallback
165/// vectors: a run re-selects the embedder and keys the cache on *its* kind, so
166/// treating a stale `hash` cache as "warm" while the run picks `onnx` would
167/// trigger exactly the surprise foreground embed this gate exists to avoid.
168/// Checked without loading a model.
169pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
170    cache::exists(records, "onnx", model)
171}
172
173/// Embed + cache every record's vector without running a real query — pre-warms
174/// the cache so the first search is instant. Returns the record count.
175pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
176    let _ = search("", records, None, 0, model, refresh);
177    records.len()
178}