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    /// Tags attached to a node (empty if none).
247    pub fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
248        let mut stmt = self
249            .conn
250            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
251        let rows = stmt.query_map([node_id], |row| row.get(0))?;
252        let mut v = Vec::new();
253        for r in rows {
254            v.push(r?);
255        }
256        Ok(v)
257    }
258
259    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
260        let mut stmt = self
261            .conn
262            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
263        let rows = stmt.query_map([node_id], |row| row.get(0))?;
264        let mut v = Vec::new();
265        for r in rows {
266            v.push(r?);
267        }
268        Ok(v)
269    }
270
271    fn augment_with_tag_alias_hits(
272        &self,
273        tokens: &[String],
274        hits: &mut Vec<RankedHit>,
275        opts: &QueryOptions,
276    ) -> Result<()> {
277        let existing: std::collections::HashSet<String> =
278            hits.iter().map(|h| h.node.id.clone()).collect();
279
280        for t in tokens {
281            // Tag exact
282            let mut stmt = self.conn.prepare(
283                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
284                        n.content_hash, n.created_at, n.updated_at
285                 FROM node_tags t
286                 JOIN nodes n ON n.id = t.node_id
287                 WHERE lower(t.tag) = ?1
288                 LIMIT 20",
289            )?;
290            let rows = stmt.query_map([t.as_str()], |row| {
291                let type_str: String = row.get(1)?;
292                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
293                let symbol_hash_raw: Option<i64> = row.get(4)?;
294                Ok(Node {
295                    id: row.get(0)?,
296                    node_type,
297                    title: row.get(2)?,
298                    file_path: row.get(3)?,
299                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
300                    summary: row.get(5)?,
301                    content_hash: row.get(6)?,
302                    created_at: row.get(7)?,
303                    updated_at: row.get(8)?,
304                })
305            })?;
306            for r in rows {
307                let node = r?;
308                if existing.contains(&node.id) || !opts.allows(&node.node_type) {
309                    continue;
310                }
311                hits.push(RankedHit {
312                    node,
313                    score: 3.5,
314                    reasons: vec![format!("tag-exact:{t}")],
315                });
316            }
317
318            // Alias exact
319            let mut stmt = self.conn.prepare(
320                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
321                        n.content_hash, n.created_at, n.updated_at
322                 FROM node_aliases a
323                 JOIN nodes n ON n.id = a.node_id
324                 WHERE a.alias = ?1
325                 LIMIT 20",
326            )?;
327            let rows = stmt.query_map([t.as_str()], |row| {
328                let type_str: String = row.get(1)?;
329                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
330                let symbol_hash_raw: Option<i64> = row.get(4)?;
331                Ok(Node {
332                    id: row.get(0)?,
333                    node_type,
334                    title: row.get(2)?,
335                    file_path: row.get(3)?,
336                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
337                    summary: row.get(5)?,
338                    content_hash: row.get(6)?,
339                    created_at: row.get(7)?,
340                    updated_at: row.get(8)?,
341                })
342            })?;
343            for r in rows {
344                let node = r?;
345                if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
346                {
347                    continue;
348                }
349                hits.push(RankedHit {
350                    node,
351                    score: 3.0,
352                    reasons: vec![format!("alias-exact:{t}")],
353                });
354            }
355        }
356        Ok(())
357    }
358
359    /// List unresolved WikiLink / symbol targets.
360    pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
361        let mut stmt = self.conn.prepare(
362            "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
363        )?;
364        let rows = stmt.query_map([], |row| {
365            Ok(PendingLink {
366                source_id: row.get(0)?,
367                raw_target: row.get(1)?,
368                relation_type: row.get(2)?,
369                created_at: row.get(3)?,
370            })
371        })?;
372        let mut out = Vec::new();
373        for r in rows {
374            out.push(r?);
375        }
376        Ok(out)
377    }
378
379    /// Count nodes grouped by `node_type`.
380    pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
381        let mut stmt = self
382            .conn
383            .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
384        let rows = stmt.query_map([], |row| {
385            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
386        })?;
387        let mut out = Vec::new();
388        for r in rows {
389            out.push(r?);
390        }
391        Ok(out)
392    }
393}
394
395/// Unresolved link kept for later resolution.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct PendingLink {
398    /// Source node id that referenced the target.
399    pub source_id: String,
400    /// Raw WikiLink / `symbol:…` target text.
401    pub raw_target: String,
402    /// Intended relation type (`relates_to`, `anchors`, …).
403    pub relation_type: String,
404    /// When the pending link was recorded (unix seconds).
405    pub created_at: i64,
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::types::NodeType;
412
413    #[test]
414    fn tag_boost_ranks_higher() {
415        let db = Database::open_in_memory().unwrap();
416        let n1 = Node {
417            id: "docs/a".into(),
418            node_type: NodeType::Concept,
419            title: "Something Else".into(),
420            file_path: None,
421            symbol_hash: None,
422            summary: Some("mentions raft in body".into()),
423            content_hash: None,
424            created_at: 1,
425            updated_at: 1,
426        };
427        let n2 = Node {
428            id: "docs/b".into(),
429            node_type: NodeType::Concept,
430            title: "Protocol".into(),
431            file_path: None,
432            symbol_hash: None,
433            summary: Some("tagged note".into()),
434            content_hash: None,
435            created_at: 1,
436            updated_at: 1,
437        };
438        db.insert_node(&n1).unwrap();
439        db.insert_node(&n2).unwrap();
440        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
441        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
442        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
443
444        let hits = db
445            .search_ranked("raft", &QueryOptions::default())
446            .unwrap();
447        assert!(hits.len() >= 2);
448        // Tag match should beat pure body mention when scores are close, or at least appear
449        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
450        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
451        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
452    }
453}