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