Skip to main content

grit_core/
search.rs

1//! Hybrid retrieval: three legs — FTS5 BM25, sqlite-vec cosine, graph
2//! expansion from seed nodes — fused with Reciprocal Rank Fusion, returned
3//! with provenance and trimmed to a caller-supplied budget so Layer 3 can ask
4//! for "context that fits".
5
6use std::collections::HashMap;
7
8use rusqlite::{Connection, params};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::Grit;
13use crate::clock::TimestampMs;
14use crate::error::Result;
15use crate::model::{EDGE_COLS, EPISODE_COLS, Edge, Episode, NODE_COLS, Node, collect};
16use crate::query::ids_json;
17use crate::vecext::f32s_as_bytes;
18
19/// RRF constant; the standard k=60 from the original TREC paper.
20const RRF_K: f64 = 60.0;
21/// How many candidates each leg contributes before fusion.
22const LEG_LIMIT: i64 = 50;
23/// How many top fused node hits seed the graph-expansion leg.
24const EXPANSION_SEEDS: usize = 5;
25/// Rough chars-per-token for [`Budget::approx_tokens`].
26const CHARS_PER_TOKEN: usize = 4;
27
28/// How much context to return (Layer 3 asks for "context that fits").
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum Budget {
31    /// At most this many hits.
32    Items(usize),
33    /// Stop once the accumulated text is roughly this many tokens
34    /// (chars / 4 heuristic; no tokenizer dependency in a storage engine).
35    ApproxTokens(usize),
36}
37
38impl Budget {
39    /// Budget of at most `n` hits.
40    pub fn items(n: usize) -> Self {
41        Budget::Items(n)
42    }
43
44    /// Budget of roughly `n` tokens of text.
45    pub fn approx_tokens(n: usize) -> Self {
46        Budget::ApproxTokens(n)
47    }
48
49    fn max_items(self) -> usize {
50        match self {
51            Budget::Items(n) => n,
52            // One token is at least one char; never fetch more than this.
53            Budget::ApproxTokens(n) => n.max(1),
54        }
55    }
56}
57
58/// A hybrid search request. Build with [`Query::text`], refine with the
59/// builder methods, execute with [`Grit::search`].
60///
61/// # Example
62/// ```no_run
63/// # use grit_core::{Budget, Grit, Options, Query};
64/// # let g = Grit::open("memory.db", Options::new("laptop"))?;
65/// let hits = g.search(
66///     Query::text("exactness").group("algebra").budget(Budget::items(20)),
67/// )?;
68/// # Ok::<(), grit_core::Error>(())
69/// ```
70#[derive(Debug, Clone)]
71pub struct Query {
72    text: String,
73    vector: Option<Vec<f32>>,
74    group_id: Option<String>,
75    as_of: Option<TimestampMs>,
76    as_at: Option<TimestampMs>,
77    budget: Budget,
78    targets: Option<Vec<SearchKind>>,
79}
80
81/// Result kinds a [`Query`] may be restricted to (see [`Query::targets`]).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum SearchKind {
84    /// Entity nodes.
85    Node,
86    /// Fact edges.
87    Edge,
88    /// Provenance episodes.
89    Episode,
90}
91
92impl Query {
93    /// Full-text query over node names/summaries, edge facts, episode content.
94    pub fn text(text: impl Into<String>) -> Self {
95        Self {
96            text: text.into(),
97            vector: None,
98            group_id: None,
99            as_of: None,
100            as_at: None,
101            budget: Budget::Items(20),
102            targets: None,
103        }
104    }
105
106    /// Restrict results to the given kinds. The fused ranking is computed
107    /// over ALL kinds (graph expansion still works), then filtered BEFORE
108    /// the budget is spent — so `targets(&[SearchKind::Edge])` with a
109    /// budget of 3 returns the top 3 edges, not whatever edges survive a
110    /// mixed top-3 cut.
111    pub fn targets(mut self, kinds: &[SearchKind]) -> Self {
112        self.targets = Some(kinds.to_vec());
113        self
114    }
115
116    /// Add a query embedding (computed by the caller — grit never embeds) to
117    /// enable the vector legs.
118    pub fn vector(mut self, embedding: Vec<f32>) -> Self {
119        self.vector = Some(embedding);
120        self
121    }
122
123    /// Restrict to a namespace.
124    pub fn group(mut self, group_id: impl Into<String>) -> Self {
125        self.group_id = Some(group_id.into());
126        self
127    }
128
129    /// Event-time instant: only return facts true at `t`.
130    pub fn as_of(mut self, t: TimestampMs) -> Self {
131        self.as_of = Some(t);
132        self
133    }
134
135    /// System-time instant: only return facts believed at `t` (time travel).
136    pub fn as_at(mut self, t: TimestampMs) -> Self {
137        self.as_at = Some(t);
138        self
139    }
140
141    /// Cap the result size.
142    pub fn budget(mut self, budget: Budget) -> Self {
143        self.budget = budget;
144        self
145    }
146}
147
148/// What a search hit points at.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(tag = "type", rename_all = "snake_case")]
151pub enum SearchTarget {
152    /// An entity node.
153    Node(Node),
154    /// A fact edge.
155    Edge(Edge),
156    /// A provenance episode.
157    Episode(Episode),
158}
159
160/// One fused search result with provenance.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct SearchHit {
163    /// The matched node/edge/episode.
164    pub target: SearchTarget,
165    /// RRF fusion score (higher is better; comparable within one search only).
166    pub score: f64,
167    /// Episodes this fact traces to (empty for episode hits — they *are* the
168    /// provenance).
169    pub episodes: Vec<Uuid>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173enum CandidateKind {
174    Node,
175    Edge,
176    Episode,
177}
178
179impl Grit {
180    /// Run hybrid retrieval: BM25 + vector cosine + graph expansion, RRF-fused,
181    /// validity-filtered at the query's `as_of`/`as_at`, trimmed to budget.
182    pub fn search(&self, query: Query) -> Result<Vec<SearchHit>> {
183        let now = self.now_ms();
184        let as_of = query.as_of.unwrap_or(now);
185        let as_at = query.as_at.unwrap_or(now);
186        let group = query.group_id.as_deref();
187        let match_expr = fts_match_expr(&query.text);
188
189        let conn = self.read();
190        // One read transaction: every leg, the expansion, and the fetch
191        // phase see the same committed snapshot.
192        let tx = conn.unchecked_transaction()?;
193
194        let mut legs: Vec<Vec<(CandidateKind, Uuid)>> = Vec::new();
195        if let Some(expr) = &match_expr {
196            legs.push(tag(
197                CandidateKind::Node,
198                fts_nodes(&tx, expr, group, as_at)?,
199            ));
200            legs.push(tag(
201                CandidateKind::Edge,
202                fts_edges(&tx, expr, group, as_at, as_of)?,
203            ));
204            legs.push(tag(CandidateKind::Episode, fts_episodes(&tx, expr, group)?));
205        }
206        if let Some(vector) = &query.vector
207            && vec_tables_exist(&tx)?
208        {
209            legs.push(tag(CandidateKind::Node, vec_leg(&tx, "vec_nodes", vector)?));
210            legs.push(tag(CandidateKind::Edge, vec_leg(&tx, "vec_edges", vector)?));
211        }
212
213        // Leg 3: expand one hop from the best node candidates so far; related
214        // entities surface even when they don't match the text themselves.
215        let mut fused = rrf(&legs);
216        let seed_nodes: Vec<Uuid> = fused
217            .iter()
218            .filter(|((kind, _), _)| *kind == CandidateKind::Node)
219            .take(EXPANSION_SEEDS)
220            .map(|((_, id), _)| *id)
221            .collect();
222        if !seed_nodes.is_empty() {
223            legs.push(tag(
224                CandidateKind::Node,
225                expansion_leg(&tx, &seed_nodes, group, as_at, as_of)?,
226            ));
227            fused = rrf(&legs);
228        }
229
230        // Fetch rows in fused order, validity-filtered, until the budget is
231        // spent. Provenance comes along for free via `mentions`.
232        let mut hits = Vec::new();
233        let mut spent_chars = 0usize;
234        for ((kind, id), score) in fused {
235            if let Some(targets) = &query.targets {
236                let kind = match kind {
237                    CandidateKind::Node => SearchKind::Node,
238                    CandidateKind::Edge => SearchKind::Edge,
239                    CandidateKind::Episode => SearchKind::Episode,
240                };
241                if !targets.contains(&kind) {
242                    continue;
243                }
244            }
245            if hits.len() >= query.budget.max_items() {
246                break;
247            }
248            if let Budget::ApproxTokens(tokens) = query.budget
249                && spent_chars / CHARS_PER_TOKEN >= tokens
250            {
251                break;
252            }
253            let target = match kind {
254                CandidateKind::Node => fetch_node(&tx, id, group, as_at)?.map(SearchTarget::Node),
255                CandidateKind::Edge => {
256                    fetch_edge(&tx, id, group, as_at, as_of)?.map(SearchTarget::Edge)
257                }
258                CandidateKind::Episode => fetch_episode(&tx, id, group)?.map(SearchTarget::Episode),
259            };
260            let Some(target) = target else { continue };
261            spent_chars += target_chars(&target);
262            let episodes = match kind {
263                CandidateKind::Episode => Vec::new(),
264                _ => episode_ids_for(&tx, id)?,
265            };
266            hits.push(SearchHit {
267                target,
268                score,
269                episodes,
270            });
271        }
272        Ok(hits)
273    }
274}
275
276/// Sanitize free text into an FTS5 MATCH expression: each whitespace token is
277/// double-quoted (quotes doubled inside), joined with implicit AND. Returns
278/// `None` for effectively-empty input.
279fn fts_match_expr(text: &str) -> Option<String> {
280    let tokens: Vec<String> = text
281        .split_whitespace()
282        .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
283        .collect();
284    if tokens.is_empty() {
285        None
286    } else {
287        Some(tokens.join(" "))
288    }
289}
290
291fn tag(kind: CandidateKind, ids: Vec<Uuid>) -> Vec<(CandidateKind, Uuid)> {
292    ids.into_iter().map(|id| (kind, id)).collect()
293}
294
295/// Reciprocal Rank Fusion across legs; stable order (score desc, then id).
296fn rrf(legs: &[Vec<(CandidateKind, Uuid)>]) -> Vec<((CandidateKind, Uuid), f64)> {
297    let mut scores: HashMap<(CandidateKind, Uuid), f64> = HashMap::new();
298    for leg in legs {
299        for (rank, key) in leg.iter().enumerate() {
300            *scores.entry(*key).or_insert(0.0) += 1.0 / (RRF_K + rank as f64 + 1.0);
301        }
302    }
303    let mut fused: Vec<_> = scores.into_iter().collect();
304    fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.1.cmp(&b.0.1)));
305    fused
306}
307
308/// Malformed uuids in our own tables are corruption, surfaced loudly — a
309/// silent drop would make rows invisible with no signal.
310fn parse_ids(rows: Vec<String>) -> Result<Vec<Uuid>> {
311    rows.iter()
312        .map(|s| {
313            Uuid::parse_str(s).map_err(|_| crate::Error::Corrupt(format!("bad uuid in index: {s}")))
314        })
315        .collect()
316}
317
318fn fts_nodes(
319    conn: &Connection,
320    expr: &str,
321    group: Option<&str>,
322    as_at: TimestampMs,
323) -> Result<Vec<Uuid>> {
324    let mut stmt = conn.prepare_cached(
325        "SELECT n.id FROM nodes_fts AS f
326         JOIN nodes AS n ON n.rowid = f.rowid
327         WHERE nodes_fts MATCH ?1
328           AND (?2 IS NULL OR n.group_id = ?2)
329           AND n.created_at <= ?3 AND (n.expired_at IS NULL OR n.expired_at > ?3)
330         ORDER BY f.rank LIMIT ?4",
331    )?;
332    let rows = stmt.query_map(params![expr, group, as_at, LEG_LIMIT], |r| r.get(0))?;
333    parse_ids(collect(rows)?)
334}
335
336fn fts_edges(
337    conn: &Connection,
338    expr: &str,
339    group: Option<&str>,
340    as_at: TimestampMs,
341    as_of: TimestampMs,
342) -> Result<Vec<Uuid>> {
343    let mut stmt = conn.prepare_cached(
344        "SELECT e.id FROM edges_fts AS f
345         JOIN edges AS e ON e.rowid = f.rowid
346         WHERE edges_fts MATCH ?1
347           AND (?2 IS NULL OR e.group_id = ?2)
348           AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
349           AND (e.valid_at IS NULL OR e.valid_at <= ?4)
350           AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
351                           WHERE iv.edge_id = e.id
352                             AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
353         ORDER BY f.rank LIMIT ?5",
354    )?;
355    let rows = stmt.query_map(params![expr, group, as_at, as_of, LEG_LIMIT], |r| r.get(0))?;
356    parse_ids(collect(rows)?)
357}
358
359fn fts_episodes(conn: &Connection, expr: &str, group: Option<&str>) -> Result<Vec<Uuid>> {
360    let mut stmt = conn.prepare_cached(
361        "SELECT e.id FROM episodes_fts AS f
362         JOIN episodes AS e ON e.rowid = f.rowid
363         WHERE episodes_fts MATCH ?1
364           AND (?2 IS NULL OR e.group_id = ?2)
365         ORDER BY f.rank LIMIT ?3",
366    )?;
367    let rows = stmt.query_map(params![expr, group, LEG_LIMIT], |r| r.get(0))?;
368    parse_ids(collect(rows)?)
369}
370
371fn vec_tables_exist(conn: &Connection) -> Result<bool> {
372    let n: i64 = conn.query_row(
373        "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('vec_nodes', 'vec_edges')",
374        [],
375        |r| r.get(0),
376    )?;
377    Ok(n == 2)
378}
379
380fn vec_leg(conn: &Connection, table: &str, vector: &[f32]) -> Result<Vec<Uuid>> {
381    let registered_dim: Option<i64> = conn
382        .query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
383            r.get(0)
384        })
385        .map(Some)
386        .or_else(|e| match e {
387            rusqlite::Error::QueryReturnedNoRows => Ok(None),
388            other => Err(other),
389        })?;
390    if registered_dim != Some(vector.len() as i64) {
391        // Wrong-dim query vectors contribute nothing rather than erroring the
392        // whole search; the caller may be mid-migration between models.
393        return Ok(Vec::new());
394    }
395    // `table` is one of two compile-time names; the vector is a bound blob.
396    let mut stmt = conn.prepare_cached(&format!(
397        "SELECT id FROM {table} WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance"
398    ))?;
399    let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT], |r| {
400        r.get::<_, String>(0)
401    })?;
402    parse_ids(collect(rows)?)
403}
404
405fn expansion_leg(
406    conn: &Connection,
407    seeds: &[Uuid],
408    group: Option<&str>,
409    as_at: TimestampMs,
410    as_of: TimestampMs,
411) -> Result<Vec<Uuid>> {
412    let mut stmt = conn.prepare_cached(
413        "SELECT DISTINCT CASE WHEN e.src = s.value THEN e.dst ELSE e.src END
414         FROM json_each(?1) AS s
415         JOIN edges AS e ON e.src = s.value OR e.dst = s.value
416         WHERE (?2 IS NULL OR e.group_id = ?2)
417           AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
418           AND (e.valid_at IS NULL OR e.valid_at <= ?4)
419           AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
420                           WHERE iv.edge_id = e.id
421                             AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
422         LIMIT ?5",
423    )?;
424    let rows = stmt.query_map(
425        params![ids_json(seeds.iter()), group, as_at, as_of, LEG_LIMIT],
426        |r| r.get(0),
427    )?;
428    parse_ids(collect(rows)?)
429}
430
431fn fetch_node(
432    conn: &Connection,
433    id: Uuid,
434    group: Option<&str>,
435    as_at: TimestampMs,
436) -> Result<Option<Node>> {
437    let mut stmt = conn.prepare_cached(&format!(
438        "SELECT {NODE_COLS} FROM nodes
439         WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
440           AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)"
441    ))?;
442    let mut rows = collect(stmt.query_map(params![id.to_string(), group, as_at], Node::from_row)?)?;
443    Ok(rows.pop())
444}
445
446fn fetch_edge(
447    conn: &Connection,
448    id: Uuid,
449    group: Option<&str>,
450    as_at: TimestampMs,
451    as_of: TimestampMs,
452) -> Result<Option<Edge>> {
453    // The EXISTS guards keep ghost edges (an endpoint purged or merged away)
454    // out of results.
455    let mut stmt = conn.prepare_cached(&format!(
456        "SELECT {EDGE_COLS} FROM edges
457         WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
458           AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)
459           AND (valid_at IS NULL OR valid_at <= ?4)
460           AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
461                           WHERE iv.edge_id = edges.id
462                             AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
463           AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.src
464                       AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))
465           AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.dst
466                       AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))"
467    ))?;
468    let mut rows =
469        collect(stmt.query_map(params![id.to_string(), group, as_at, as_of], Edge::from_row)?)?;
470    Ok(rows.pop())
471}
472
473fn fetch_episode(conn: &Connection, id: Uuid, group: Option<&str>) -> Result<Option<Episode>> {
474    let mut stmt = conn.prepare_cached(&format!(
475        "SELECT {EPISODE_COLS} FROM episodes
476         WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)"
477    ))?;
478    let mut rows = collect(stmt.query_map(params![id.to_string(), group], Episode::from_row)?)?;
479    Ok(rows.pop())
480}
481
482fn episode_ids_for(conn: &Connection, target: Uuid) -> Result<Vec<Uuid>> {
483    // Alias-aware: mention rows keep original target ids across merges.
484    crate::query::episodes_mentioning(conn, &target.to_string())
485}
486
487fn target_chars(target: &SearchTarget) -> usize {
488    match target {
489        SearchTarget::Node(n) => n.name.len() + n.summary.len(),
490        SearchTarget::Edge(e) => e.fact.len(),
491        SearchTarget::Episode(e) => e.content.len(),
492    }
493}