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;
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    eprintln!("gqls: semantic search via {} embeddings", embedder.kind());
34
35    // Per-record vectors are the expensive part; cache them keyed by schema
36    // content + embedder + model, so editing the schema (or switching model)
37    // changes the key and re-embeds automatically. `--refresh` forces a miss.
38    let cache_path = cache::path(records, embedder.kind(), model);
39    let cached = if refresh {
40        None
41    } else {
42        cache_path
43            .as_deref()
44            .and_then(|p| cache::load(p, records.len()))
45    };
46    let vectors = match cached {
47        Some(v) => {
48            if let Some(p) = cache_path.as_deref() {
49                cache::touch(p); // LRU: mark this schema as recently used
50            }
51            v
52        }
53        None => {
54            use rayon::prelude::*;
55            use std::io::IsTerminal;
56            use std::sync::atomic::{AtomicUsize, Ordering};
57
58            let total = records.len();
59            eprintln!(
60                "gqls: embedding {total} records (one-time, then cached; a large schema can take ~a minute)…"
61            );
62            let done = AtomicUsize::new(0);
63
64            // Per-worker embedder/session: the session is behind a Mutex, so a
65            // shared one would serialize the parallel pass. The model is a cached
66            // file, so every worker resolves the same kind as the main embedder
67            // above (no mixed onnx/hash vectors). Order-preserving `collect`, so
68            // vectors stay index-aligned with `records`.
69            let v: Vec<Vec<f32>> = std::thread::scope(|scope| {
70                let show_progress = std::io::stderr().is_terminal() && total > 500;
71                if show_progress {
72                    scope.spawn(|| loop {
73                        std::thread::sleep(std::time::Duration::from_millis(300));
74                        let d = done.load(Ordering::Relaxed);
75                        eprint!("\rgqls: embedded {d}/{total}…    ");
76                        if d >= total {
77                            eprintln!();
78                            break;
79                        }
80                    });
81                }
82                records
83                    .par_iter()
84                    .map_init(
85                        || default_embedder(model),
86                        |emb, r| {
87                            let out = compress_matryoshka_vector(&emb.embed(&record_text(r)));
88                            done.fetch_add(1, Ordering::Relaxed);
89                            out
90                        },
91                    )
92                    .collect()
93            });
94            if let Some(p) = cache_path.as_deref() {
95                cache::store(p, &v);
96                cache::prune(cache::max_files()); // evict least-recently-used
97            }
98            v
99        }
100    };
101
102    let query_vec = compress_matryoshka_vector(&embedder.embed(query));
103    let mut hits: Vec<(f64, &SchemaRecord)> = records
104        .iter()
105        .zip(&vectors)
106        .filter(|(r, _)| kind.is_none_or(|k| r.kind == k))
107        .map(|(r, v)| (cosine_similarity(&query_vec, v) as f64, r))
108        .collect();
109
110    hits.sort_by(|a, b| b.0.total_cmp(&a.0));
111    hits.truncate(limit);
112    hits
113}
114
115/// The natural-language signal a semantic query matches against: the path plus
116/// the human description and the type.
117fn record_text(r: &SchemaRecord) -> String {
118    let mut s = r.path.clone();
119    if let Some(d) = &r.description {
120        s.push_str(" — ");
121        s.push_str(d);
122    }
123    if let Some(t) = &r.type_ref {
124        s.push_str(" : ");
125        s.push_str(t);
126    }
127    s
128}
129
130/// Delete all cached embedding vector files; returns how many were removed.
131pub fn clear_cache() -> usize {
132    cache::clear()
133}
134
135/// Whether the schema's vectors are already cached (either embedder kind), so a
136/// semantic search would be warm. Checked without loading a model.
137pub fn is_cached(records: &[SchemaRecord], model: Option<&str>) -> bool {
138    cache::exists(records, "onnx", model) || cache::exists(records, "hash", model)
139}
140
141/// Embed + cache every record's vector without running a real query — pre-warms
142/// the cache so the first search is instant. Returns the record count.
143pub fn warm(records: &[SchemaRecord], model: Option<&str>, refresh: bool) -> usize {
144    let _ = search("", records, None, 0, model, refresh);
145    records.len()
146}