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