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