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