Skip to main content

grit_core/
search.rs

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