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::escape_fts5_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}
37
38impl Default for QueryOptions {
39    fn default() -> Self {
40        Self {
41            limit: 50,
42            type_boosts: vec![
43                (NodeType::Goal, 1.15),
44                (NodeType::Adr, 1.1),
45                (NodeType::EdgeCase, 1.1),
46                (NodeType::Symbol, 0.95),
47            ],
48        }
49    }
50}
51
52impl Database {
53    /// Ranked search combining FTS5 BM25 with tag, alias, title, and id boosts.
54    ///
55    /// Steps:
56    /// 1. Escape the user query for FTS5 `MATCH`
57    /// 2. Collect BM25-ordered candidates (oversample by `2 × limit`)
58    /// 3. Apply additive boosts for title/id/tag/alias token hits
59    /// 4. Apply multiplicative type priors from [`QueryOptions::type_boosts`]
60    /// 5. Augment with pure tag/alias exact matches FTS may have missed
61    /// 6. Sort by score, dedupe by id, truncate to `limit`
62    ///
63    /// # Errors
64    ///
65    /// Returns [`crate::BrainError::FtsQuery`] for empty input after tokenization.
66    pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
67        let tokens: Vec<String> = query
68            .split_whitespace()
69            .filter(|t| !t.is_empty())
70            .map(|t| t.to_lowercase())
71            .collect();
72        if tokens.is_empty() {
73            return Err(crate::error::BrainError::FtsQuery(
74                "query must contain at least one non-whitespace token".into(),
75            ));
76        }
77
78        let escaped = escape_fts5_query(query)?;
79        let mut stmt = self.conn.prepare(
80            "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
81                    n.content_hash, n.created_at, n.updated_at,
82                    bm25(node_fts) AS bm25_score
83             FROM node_fts f
84             JOIN nodes n ON n.id = f.node_id
85             WHERE node_fts MATCH ?1
86             ORDER BY bm25_score
87             LIMIT ?2",
88        )?;
89
90        let rows = stmt.query_map(params![escaped, opts.limit as i64 * 2], |row| {
91            let type_str: String = row.get(1)?;
92            let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
93            let symbol_hash_raw: Option<i64> = row.get(4)?;
94            let bm25: f64 = row.get(9)?;
95            Ok((
96                Node {
97                    id: row.get(0)?,
98                    node_type,
99                    title: row.get(2)?,
100                    file_path: row.get(3)?,
101                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
102                    summary: row.get(5)?,
103                    content_hash: row.get(6)?,
104                    created_at: row.get(7)?,
105                    updated_at: row.get(8)?,
106                },
107                bm25 as f32,
108            ))
109        })?;
110
111        let mut hits: Vec<RankedHit> = Vec::new();
112        for r in rows {
113            let (node, bm25) = r?;
114            // bm25() in SQLite FTS5: more relevant → more negative. Invert.
115            let fts_score = (-bm25).max(0.01);
116            let mut score = fts_score;
117            let mut reasons = vec![format!("fts:{fts_score:.3}")];
118
119            // Title token hits
120            let title_l = node.title.to_lowercase();
121            let id_l = node.id.to_lowercase();
122            for t in &tokens {
123                if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t) {
124                    score += 2.0;
125                    reasons.push(format!("title:{t}"));
126                }
127                if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.ends_with(t) {
128                    score += 1.5;
129                    reasons.push(format!("id:{t}"));
130                }
131            }
132
133            // Tag boosts
134            let tags = self.get_tags_for(&node.id)?;
135            for tag in &tags {
136                let tag_l = tag.to_lowercase();
137                for t in &tokens {
138                    if tag_l == *t || tag_l.contains(t) {
139                        score += 3.0;
140                        reasons.push(format!("tag:{tag}"));
141                    }
142                }
143            }
144
145            // Alias boosts
146            let aliases = self.get_aliases_for(&node.id)?;
147            for alias in &aliases {
148                let a = alias.to_lowercase();
149                for t in &tokens {
150                    if a == *t || a.contains(t) {
151                        score += 2.5;
152                        reasons.push(format!("alias:{alias}"));
153                    }
154                }
155            }
156
157            // Type boost
158            for (ty, mult) in &opts.type_boosts {
159                if node.node_type == *ty {
160                    score *= mult;
161                    reasons.push(format!("type:{}×{mult}", ty.as_str()));
162                }
163            }
164
165            hits.push(RankedHit {
166                node,
167                score,
168                reasons,
169            });
170        }
171
172        // Also pull pure tag/alias matches that FTS may have missed (short queries).
173        self.augment_with_tag_alias_hits(&tokens, &mut hits)?;
174
175        hits.sort_by(|a, b| {
176            b.score
177                .partial_cmp(&a.score)
178                .unwrap_or(std::cmp::Ordering::Equal)
179        });
180        // Dedup by id keeping best score
181        let mut seen = std::collections::HashSet::new();
182        hits.retain(|h| seen.insert(h.node.id.clone()));
183        hits.truncate(opts.limit);
184        Ok(hits)
185    }
186
187    fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
188        let mut stmt = self
189            .conn
190            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
191        let rows = stmt.query_map([node_id], |row| row.get(0))?;
192        let mut v = Vec::new();
193        for r in rows {
194            v.push(r?);
195        }
196        Ok(v)
197    }
198
199    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
200        let mut stmt = self
201            .conn
202            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
203        let rows = stmt.query_map([node_id], |row| row.get(0))?;
204        let mut v = Vec::new();
205        for r in rows {
206            v.push(r?);
207        }
208        Ok(v)
209    }
210
211    fn augment_with_tag_alias_hits(
212        &self,
213        tokens: &[String],
214        hits: &mut Vec<RankedHit>,
215    ) -> Result<()> {
216        let existing: std::collections::HashSet<String> =
217            hits.iter().map(|h| h.node.id.clone()).collect();
218
219        for t in tokens {
220            // Tag exact
221            let mut stmt = self.conn.prepare(
222                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
223                        n.content_hash, n.created_at, n.updated_at
224                 FROM node_tags t
225                 JOIN nodes n ON n.id = t.node_id
226                 WHERE lower(t.tag) = ?1
227                 LIMIT 20",
228            )?;
229            let rows = stmt.query_map([t.as_str()], |row| {
230                let type_str: String = row.get(1)?;
231                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
232                let symbol_hash_raw: Option<i64> = row.get(4)?;
233                Ok(Node {
234                    id: row.get(0)?,
235                    node_type,
236                    title: row.get(2)?,
237                    file_path: row.get(3)?,
238                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
239                    summary: row.get(5)?,
240                    content_hash: row.get(6)?,
241                    created_at: row.get(7)?,
242                    updated_at: row.get(8)?,
243                })
244            })?;
245            for r in rows {
246                let node = r?;
247                if existing.contains(&node.id) {
248                    continue;
249                }
250                hits.push(RankedHit {
251                    node,
252                    score: 3.5,
253                    reasons: vec![format!("tag-exact:{t}")],
254                });
255            }
256
257            // Alias exact
258            let mut stmt = self.conn.prepare(
259                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
260                        n.content_hash, n.created_at, n.updated_at
261                 FROM node_aliases a
262                 JOIN nodes n ON n.id = a.node_id
263                 WHERE a.alias = ?1
264                 LIMIT 20",
265            )?;
266            let rows = stmt.query_map([t.as_str()], |row| {
267                let type_str: String = row.get(1)?;
268                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
269                let symbol_hash_raw: Option<i64> = row.get(4)?;
270                Ok(Node {
271                    id: row.get(0)?,
272                    node_type,
273                    title: row.get(2)?,
274                    file_path: row.get(3)?,
275                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
276                    summary: row.get(5)?,
277                    content_hash: row.get(6)?,
278                    created_at: row.get(7)?,
279                    updated_at: row.get(8)?,
280                })
281            })?;
282            for r in rows {
283                let node = r?;
284                if hits.iter().any(|h| h.node.id == node.id) {
285                    continue;
286                }
287                hits.push(RankedHit {
288                    node,
289                    score: 3.0,
290                    reasons: vec![format!("alias-exact:{t}")],
291                });
292            }
293        }
294        Ok(())
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::types::NodeType;
302
303    #[test]
304    fn tag_boost_ranks_higher() {
305        let db = Database::open_in_memory().unwrap();
306        let n1 = Node {
307            id: "docs/a".into(),
308            node_type: NodeType::Concept,
309            title: "Something Else".into(),
310            file_path: None,
311            symbol_hash: None,
312            summary: Some("mentions raft in body".into()),
313            content_hash: None,
314            created_at: 1,
315            updated_at: 1,
316        };
317        let n2 = Node {
318            id: "docs/b".into(),
319            node_type: NodeType::Concept,
320            title: "Protocol".into(),
321            file_path: None,
322            symbol_hash: None,
323            summary: Some("tagged note".into()),
324            content_hash: None,
325            created_at: 1,
326            updated_at: 1,
327        };
328        db.insert_node(&n1).unwrap();
329        db.insert_node(&n2).unwrap();
330        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
331        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
332        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
333
334        let hits = db
335            .search_ranked("raft", &QueryOptions::default())
336            .unwrap();
337        assert!(hits.len() >= 2);
338        // Tag match should beat pure body mention when scores are close, or at least appear
339        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
340        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
341        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
342    }
343}