Skip to main content

rustbrain_core/
query.rs

1//! Ranked hybrid query: FTS5 BM25 + tag / alias / title boosts.
2//!
3//! This is not neural retrieval. Scores are useful for ordering within a single
4//! brain (and for cross-workspace merge) but are not calibrated probabilities.
5
6use crate::error::Result;
7use crate::fts::prepare_search_query;
8use crate::storage::Database;
9use crate::types::{Node, NodeType};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13/// A single ranked search hit.
14///
15/// `reasons` is a debug-oriented list of score components (e.g. `fts:…`, `tag:raft`)
16/// intended for CLI `--scores` and UI tooltips — not a stable public schema.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RankedHit {
19    /// Matching node.
20    pub node: Node,
21    /// Higher is better. Combines inverted BM25 with additive boosts.
22    pub score: f32,
23    /// Why this hit was selected (for debugging / UI).
24    pub reasons: Vec<String>,
25}
26
27/// Options for [`Database::search_ranked`] / [`crate::Brain::query_ranked`].
28#[derive(Debug, Clone)]
29pub struct QueryOptions {
30    /// Maximum hits to return after ranking and deduplication.
31    pub limit: usize,
32    /// Multiplicative boosts applied when `node.node_type` matches.
33    ///
34    /// Defaults slightly prefer goals/ADRs/edge cases over raw symbols.
35    pub type_boosts: Vec<(NodeType, f32)>,
36    /// If non-empty, only include these node types.
37    pub include_types: Vec<NodeType>,
38    /// Exclude these node types after ranking (applied after include filter).
39    pub exclude_types: Vec<NodeType>,
40    /// Convenience: exclude [`NodeType::Symbol`] (typical for human CLI search).
41    pub no_symbols: bool,
42}
43
44impl Default for QueryOptions {
45    fn default() -> Self {
46        Self {
47            limit: 50,
48            type_boosts: vec![
49                (NodeType::Goal, 1.15),
50                (NodeType::Adr, 1.1),
51                (NodeType::EdgeCase, 1.1),
52                (NodeType::Concept, 1.05),
53                (NodeType::Symbol, 0.95),
54            ],
55            include_types: Vec::new(),
56            exclude_types: Vec::new(),
57            no_symbols: false,
58        }
59    }
60}
61
62impl QueryOptions {
63    /// Human-oriented defaults: exclude pure code symbols unless asked otherwise.
64    pub fn human() -> Self {
65        Self {
66            no_symbols: true,
67            ..Self::default()
68        }
69    }
70
71    fn allows(&self, ty: &NodeType) -> bool {
72        if self.no_symbols && *ty == NodeType::Symbol {
73            return false;
74        }
75        if !self.include_types.is_empty() && !self.include_types.contains(ty) {
76            return false;
77        }
78        if self.exclude_types.contains(ty) {
79            return false;
80        }
81        true
82    }
83}
84
85impl Database {
86    /// Ranked search combining FTS5 BM25 with tag, alias, title, and id boosts.
87    ///
88    /// Steps:
89    /// 1. Prepare the query (tokenize, drop stopwords, OR multi-token MATCH)
90    /// 2. Collect BM25-ordered candidates (oversample by `2 × limit`)
91    /// 3. Apply additive boosts for title/id/tag/alias token hits + multi-token coverage
92    /// 4. Apply multiplicative type priors from [`QueryOptions::type_boosts`]
93    /// 5. Augment with pure tag/alias exact matches FTS may have missed
94    /// 6. Sort by score, dedupe by id, truncate to `limit`
95    ///
96    /// # Errors
97    ///
98    /// Returns [`crate::BrainError::FtsQuery`] for empty input after tokenization.
99    pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
100        let prepared = prepare_search_query(query)?;
101        let tokens = &prepared.tokens;
102
103        let mut stmt = self.conn.prepare(
104            "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
105                    n.content_hash, n.created_at, n.updated_at,
106                    bm25(node_fts) AS bm25_score
107             FROM node_fts f
108             JOIN nodes n ON n.id = f.node_id
109             WHERE node_fts MATCH ?1
110             ORDER BY bm25_score
111             LIMIT ?2",
112        )?;
113
114        let rows = stmt.query_map(params![prepared.fts_match, opts.limit as i64 * 2], |row| {
115            let type_str: String = row.get(1)?;
116            let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
117            let symbol_hash_raw: Option<i64> = row.get(4)?;
118            let bm25: f64 = row.get(9)?;
119            Ok((
120                Node {
121                    id: row.get(0)?,
122                    node_type,
123                    title: row.get(2)?,
124                    file_path: row.get(3)?,
125                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
126                    summary: row.get(5)?,
127                    content_hash: row.get(6)?,
128                    created_at: row.get(7)?,
129                    updated_at: row.get(8)?,
130                },
131                bm25 as f32,
132            ))
133        })?;
134
135        let mut hits: Vec<RankedHit> = Vec::new();
136        for r in rows {
137            let (node, bm25) = r?;
138            if !opts.allows(&node.node_type) {
139                continue;
140            }
141            // bm25() in SQLite FTS5: more relevant → more negative. Invert.
142            let fts_score = (-bm25).max(0.01);
143            let mut score = fts_score;
144            let mut reasons = vec![format!("fts:{fts_score:.3}")];
145            if prepared.used_or {
146                reasons.push("fts:or".into());
147            }
148            // Hub boost for root README node.
149            if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
150                score *= 1.2;
151                reasons.push("hub:readme".into());
152            }
153
154            // Title / id token hits + multi-token coverage bonus
155            let title_l = node.title.to_lowercase();
156            let id_l = node.id.to_lowercase();
157            let fts_body = self
158                .get_fts_content(&node.id)
159                .ok()
160                .flatten()
161                .unwrap_or_default()
162                .to_lowercase();
163            let mut coverage = 0usize;
164            for t in tokens {
165                let mut hit_tok = false;
166                if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
167                    score += 2.0;
168                    reasons.push(format!("title:{t}"));
169                    hit_tok = true;
170                }
171                if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
172                    score += 1.5;
173                    reasons.push(format!("id:{t}"));
174                    hit_tok = true;
175                }
176                if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
177                    score += 0.75;
178                    hit_tok = true;
179                }
180                if hit_tok {
181                    coverage += 1;
182                }
183            }
184            if tokens.len() > 1 && coverage > 1 {
185                let bonus = 1.5 * (coverage as f32);
186                score += bonus;
187                reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
188            }
189
190            // Tag boosts
191            let tags = self.get_tags_for(&node.id)?;
192            for tag in &tags {
193                let tag_l = tag.to_lowercase();
194                for t in tokens {
195                    if tag_l == *t || tag_l.contains(t.as_str()) {
196                        score += 3.0;
197                        reasons.push(format!("tag:{tag}"));
198                    }
199                }
200            }
201
202            // Alias boosts
203            let aliases = self.get_aliases_for(&node.id)?;
204            for alias in &aliases {
205                let a = alias.to_lowercase();
206                for t in tokens {
207                    if a == *t || a.contains(t.as_str()) {
208                        score += 2.5;
209                        reasons.push(format!("alias:{alias}"));
210                    }
211                }
212            }
213
214            // Type boost
215            for (ty, mult) in &opts.type_boosts {
216                if node.node_type == *ty {
217                    score *= mult;
218                    reasons.push(format!("type:{}×{mult}", ty.as_str()));
219                }
220            }
221
222            hits.push(RankedHit {
223                node,
224                score,
225                reasons,
226            });
227        }
228
229        // Also pull pure tag/alias matches that FTS may have missed (short queries).
230        self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
231
232        hits.retain(|h| opts.allows(&h.node.node_type));
233
234        hits.sort_by(|a, b| {
235            b.score
236                .partial_cmp(&a.score)
237                .unwrap_or(std::cmp::Ordering::Equal)
238        });
239        // Dedup by id keeping best score
240        let mut seen = std::collections::HashSet::new();
241        hits.retain(|h| seen.insert(h.node.id.clone()));
242        hits.truncate(opts.limit);
243        Ok(hits)
244    }
245
246    fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
247        let mut stmt = self
248            .conn
249            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
250        let rows = stmt.query_map([node_id], |row| row.get(0))?;
251        let mut v = Vec::new();
252        for r in rows {
253            v.push(r?);
254        }
255        Ok(v)
256    }
257
258    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
259        let mut stmt = self
260            .conn
261            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
262        let rows = stmt.query_map([node_id], |row| row.get(0))?;
263        let mut v = Vec::new();
264        for r in rows {
265            v.push(r?);
266        }
267        Ok(v)
268    }
269
270    fn augment_with_tag_alias_hits(
271        &self,
272        tokens: &[String],
273        hits: &mut Vec<RankedHit>,
274        opts: &QueryOptions,
275    ) -> Result<()> {
276        let existing: std::collections::HashSet<String> =
277            hits.iter().map(|h| h.node.id.clone()).collect();
278
279        for t in tokens {
280            // Tag exact
281            let mut stmt = self.conn.prepare(
282                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
283                        n.content_hash, n.created_at, n.updated_at
284                 FROM node_tags t
285                 JOIN nodes n ON n.id = t.node_id
286                 WHERE lower(t.tag) = ?1
287                 LIMIT 20",
288            )?;
289            let rows = stmt.query_map([t.as_str()], |row| {
290                let type_str: String = row.get(1)?;
291                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
292                let symbol_hash_raw: Option<i64> = row.get(4)?;
293                Ok(Node {
294                    id: row.get(0)?,
295                    node_type,
296                    title: row.get(2)?,
297                    file_path: row.get(3)?,
298                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
299                    summary: row.get(5)?,
300                    content_hash: row.get(6)?,
301                    created_at: row.get(7)?,
302                    updated_at: row.get(8)?,
303                })
304            })?;
305            for r in rows {
306                let node = r?;
307                if existing.contains(&node.id) || !opts.allows(&node.node_type) {
308                    continue;
309                }
310                hits.push(RankedHit {
311                    node,
312                    score: 3.5,
313                    reasons: vec![format!("tag-exact:{t}")],
314                });
315            }
316
317            // Alias exact
318            let mut stmt = self.conn.prepare(
319                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
320                        n.content_hash, n.created_at, n.updated_at
321                 FROM node_aliases a
322                 JOIN nodes n ON n.id = a.node_id
323                 WHERE a.alias = ?1
324                 LIMIT 20",
325            )?;
326            let rows = stmt.query_map([t.as_str()], |row| {
327                let type_str: String = row.get(1)?;
328                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
329                let symbol_hash_raw: Option<i64> = row.get(4)?;
330                Ok(Node {
331                    id: row.get(0)?,
332                    node_type,
333                    title: row.get(2)?,
334                    file_path: row.get(3)?,
335                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
336                    summary: row.get(5)?,
337                    content_hash: row.get(6)?,
338                    created_at: row.get(7)?,
339                    updated_at: row.get(8)?,
340                })
341            })?;
342            for r in rows {
343                let node = r?;
344                if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
345                {
346                    continue;
347                }
348                hits.push(RankedHit {
349                    node,
350                    score: 3.0,
351                    reasons: vec![format!("alias-exact:{t}")],
352                });
353            }
354        }
355        Ok(())
356    }
357
358    /// List unresolved WikiLink / symbol targets.
359    pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
360        let mut stmt = self.conn.prepare(
361            "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
362        )?;
363        let rows = stmt.query_map([], |row| {
364            Ok(PendingLink {
365                source_id: row.get(0)?,
366                raw_target: row.get(1)?,
367                relation_type: row.get(2)?,
368                created_at: row.get(3)?,
369            })
370        })?;
371        let mut out = Vec::new();
372        for r in rows {
373            out.push(r?);
374        }
375        Ok(out)
376    }
377
378    /// Count nodes grouped by `node_type`.
379    pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
380        let mut stmt = self
381            .conn
382            .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
383        let rows = stmt.query_map([], |row| {
384            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
385        })?;
386        let mut out = Vec::new();
387        for r in rows {
388            out.push(r?);
389        }
390        Ok(out)
391    }
392}
393
394/// Unresolved link kept for later resolution.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct PendingLink {
397    /// Source node id that referenced the target.
398    pub source_id: String,
399    /// Raw WikiLink / `symbol:…` target text.
400    pub raw_target: String,
401    /// Intended relation type (`relates_to`, `anchors`, …).
402    pub relation_type: String,
403    /// When the pending link was recorded (unix seconds).
404    pub created_at: i64,
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::types::NodeType;
411
412    #[test]
413    fn tag_boost_ranks_higher() {
414        let db = Database::open_in_memory().unwrap();
415        let n1 = Node {
416            id: "docs/a".into(),
417            node_type: NodeType::Concept,
418            title: "Something Else".into(),
419            file_path: None,
420            symbol_hash: None,
421            summary: Some("mentions raft in body".into()),
422            content_hash: None,
423            created_at: 1,
424            updated_at: 1,
425        };
426        let n2 = Node {
427            id: "docs/b".into(),
428            node_type: NodeType::Concept,
429            title: "Protocol".into(),
430            file_path: None,
431            symbol_hash: None,
432            summary: Some("tagged note".into()),
433            content_hash: None,
434            created_at: 1,
435            updated_at: 1,
436        };
437        db.insert_node(&n1).unwrap();
438        db.insert_node(&n2).unwrap();
439        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
440        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
441        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
442
443        let hits = db
444            .search_ranked("raft", &QueryOptions::default())
445            .unwrap();
446        assert!(hits.len() >= 2);
447        // Tag match should beat pure body mention when scores are close, or at least appear
448        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
449        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
450        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
451    }
452}