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 node.
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
155            // Title / id token hits + multi-token coverage bonus
156            let title_l = node.title.to_lowercase();
157            let id_l = node.id.to_lowercase();
158            let fts_body = self
159                .get_fts_content(&node.id)
160                .ok()
161                .flatten()
162                .unwrap_or_default()
163                .to_lowercase();
164            let mut coverage = 0usize;
165            for t in tokens {
166                let mut hit_tok = false;
167                if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
168                    score += 2.0;
169                    reasons.push(format!("title:{t}"));
170                    hit_tok = true;
171                }
172                if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
173                    score += 1.5;
174                    reasons.push(format!("id:{t}"));
175                    hit_tok = true;
176                }
177                if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
178                    score += 0.75;
179                    hit_tok = true;
180                }
181                if hit_tok {
182                    coverage += 1;
183                }
184            }
185            if tokens.len() > 1 && coverage > 1 {
186                let bonus = 1.5 * (coverage as f32);
187                score += bonus;
188                reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
189            }
190
191            // Tag boosts
192            let tags = self.get_tags_for(&node.id)?;
193            for tag in &tags {
194                let tag_l = tag.to_lowercase();
195                for t in tokens {
196                    if tag_l == *t || tag_l.contains(t.as_str()) {
197                        score += 3.0;
198                        reasons.push(format!("tag:{tag}"));
199                    }
200                }
201            }
202
203            // Alias boosts
204            let aliases = self.get_aliases_for(&node.id)?;
205            for alias in &aliases {
206                let a = alias.to_lowercase();
207                for t in tokens {
208                    if a == *t || a.contains(t.as_str()) {
209                        score += 2.5;
210                        reasons.push(format!("alias:{alias}"));
211                    }
212                }
213            }
214
215            // Type boost
216            for (ty, mult) in &opts.type_boosts {
217                if node.node_type == *ty {
218                    score *= mult;
219                    reasons.push(format!("type:{}×{mult}", ty.as_str()));
220                }
221            }
222
223            hits.push(RankedHit {
224                node,
225                score,
226                reasons,
227            });
228        }
229
230        // Also pull pure tag/alias matches that FTS may have missed (short queries).
231        self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
232
233        hits.retain(|h| opts.allows(&h.node.node_type));
234
235        hits.sort_by(|a, b| {
236            b.score
237                .partial_cmp(&a.score)
238                .unwrap_or(std::cmp::Ordering::Equal)
239        });
240        // Dedup by id keeping best score
241        let mut seen = std::collections::HashSet::new();
242        hits.retain(|h| seen.insert(h.node.id.clone()));
243        hits.truncate(opts.limit);
244        Ok(hits)
245    }
246
247    /// Tags attached to a node (empty if none).
248    pub fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
249        let mut stmt = self
250            .conn
251            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
252        let rows = stmt.query_map([node_id], |row| row.get(0))?;
253        let mut v = Vec::new();
254        for r in rows {
255            v.push(r?);
256        }
257        Ok(v)
258    }
259
260    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
261        let mut stmt = self
262            .conn
263            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
264        let rows = stmt.query_map([node_id], |row| row.get(0))?;
265        let mut v = Vec::new();
266        for r in rows {
267            v.push(r?);
268        }
269        Ok(v)
270    }
271
272    fn augment_with_tag_alias_hits(
273        &self,
274        tokens: &[String],
275        hits: &mut Vec<RankedHit>,
276        opts: &QueryOptions,
277    ) -> Result<()> {
278        let existing: std::collections::HashSet<String> =
279            hits.iter().map(|h| h.node.id.clone()).collect();
280
281        for t in tokens {
282            // Tag exact
283            let mut stmt = self.conn.prepare(
284                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
285                        n.content_hash, n.created_at, n.updated_at
286                 FROM node_tags t
287                 JOIN nodes n ON n.id = t.node_id
288                 WHERE lower(t.tag) = ?1
289                 LIMIT 20",
290            )?;
291            let rows = stmt.query_map([t.as_str()], |row| {
292                let type_str: String = row.get(1)?;
293                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
294                let symbol_hash_raw: Option<i64> = row.get(4)?;
295                Ok(Node {
296                    id: row.get(0)?,
297                    node_type,
298                    title: row.get(2)?,
299                    file_path: row.get(3)?,
300                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
301                    summary: row.get(5)?,
302                    content_hash: row.get(6)?,
303                    created_at: row.get(7)?,
304                    updated_at: row.get(8)?,
305                })
306            })?;
307            for r in rows {
308                let node = r?;
309                if existing.contains(&node.id) || !opts.allows(&node.node_type) {
310                    continue;
311                }
312                hits.push(RankedHit {
313                    node,
314                    score: 3.5,
315                    reasons: vec![format!("tag-exact:{t}")],
316                });
317            }
318
319            // Alias exact
320            let mut stmt = self.conn.prepare(
321                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
322                        n.content_hash, n.created_at, n.updated_at
323                 FROM node_aliases a
324                 JOIN nodes n ON n.id = a.node_id
325                 WHERE a.alias = ?1
326                 LIMIT 20",
327            )?;
328            let rows = stmt.query_map([t.as_str()], |row| {
329                let type_str: String = row.get(1)?;
330                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
331                let symbol_hash_raw: Option<i64> = row.get(4)?;
332                Ok(Node {
333                    id: row.get(0)?,
334                    node_type,
335                    title: row.get(2)?,
336                    file_path: row.get(3)?,
337                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
338                    summary: row.get(5)?,
339                    content_hash: row.get(6)?,
340                    created_at: row.get(7)?,
341                    updated_at: row.get(8)?,
342                })
343            })?;
344            for r in rows {
345                let node = r?;
346                if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
347                {
348                    continue;
349                }
350                hits.push(RankedHit {
351                    node,
352                    score: 3.0,
353                    reasons: vec![format!("alias-exact:{t}")],
354                });
355            }
356        }
357        Ok(())
358    }
359
360    /// List unresolved WikiLink / symbol targets.
361    pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
362        let mut stmt = self.conn.prepare(
363            "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
364        )?;
365        let rows = stmt.query_map([], |row| {
366            Ok(PendingLink {
367                source_id: row.get(0)?,
368                raw_target: row.get(1)?,
369                relation_type: row.get(2)?,
370                created_at: row.get(3)?,
371            })
372        })?;
373        let mut out = Vec::new();
374        for r in rows {
375            out.push(r?);
376        }
377        Ok(out)
378    }
379
380    /// Count nodes grouped by `node_type`.
381    pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
382        let mut stmt = self
383            .conn
384            .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
385        let rows = stmt.query_map([], |row| {
386            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
387        })?;
388        let mut out = Vec::new();
389        for r in rows {
390            out.push(r?);
391        }
392        Ok(out)
393    }
394}
395
396/// Unresolved link kept for later resolution.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct PendingLink {
399    /// Source node id that referenced the target.
400    pub source_id: String,
401    /// Raw WikiLink / `symbol:…` target text.
402    pub raw_target: String,
403    /// Intended relation type (`relates_to`, `anchors`, …).
404    pub relation_type: String,
405    /// When the pending link was recorded (unix seconds).
406    pub created_at: i64,
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::types::NodeType;
413
414    #[test]
415    fn tag_boost_ranks_higher() {
416        let db = Database::open_in_memory().unwrap();
417        let n1 = Node {
418            id: "docs/a".into(),
419            node_type: NodeType::Concept,
420            title: "Something Else".into(),
421            file_path: None,
422            symbol_hash: None,
423            summary: Some("mentions raft in body".into()),
424            content_hash: None,
425            created_at: 1,
426            updated_at: 1,
427        };
428        let n2 = Node {
429            id: "docs/b".into(),
430            node_type: NodeType::Concept,
431            title: "Protocol".into(),
432            file_path: None,
433            symbol_hash: None,
434            summary: Some("tagged note".into()),
435            content_hash: None,
436            created_at: 1,
437            updated_at: 1,
438        };
439        db.insert_node(&n1).unwrap();
440        db.insert_node(&n2).unwrap();
441        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
442        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
443        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
444
445        let hits = db
446            .search_ranked("raft", &QueryOptions::default())
447            .unwrap();
448        assert!(hits.len() >= 2);
449        // Tag match should beat pure body mention when scores are close, or at least appear
450        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
451        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
452        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
453    }
454}