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    /// 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. Escape the user query for FTS5 `MATCH`
90    /// 2. Collect BM25-ordered candidates (oversample by `2 × limit`)
91    /// 3. Apply additive boosts for title/id/tag/alias token hits
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 tokens: Vec<String> = query
101            .split_whitespace()
102            .filter(|t| !t.is_empty())
103            .map(|t| t.to_lowercase())
104            .collect();
105        if tokens.is_empty() {
106            return Err(crate::error::BrainError::FtsQuery(
107                "query must contain at least one non-whitespace token".into(),
108            ));
109        }
110
111        let escaped = escape_fts5_query(query)?;
112        let mut stmt = self.conn.prepare(
113            "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
114                    n.content_hash, n.created_at, n.updated_at,
115                    bm25(node_fts) AS bm25_score
116             FROM node_fts f
117             JOIN nodes n ON n.id = f.node_id
118             WHERE node_fts MATCH ?1
119             ORDER BY bm25_score
120             LIMIT ?2",
121        )?;
122
123        let rows = stmt.query_map(params![escaped, opts.limit as i64 * 2], |row| {
124            let type_str: String = row.get(1)?;
125            let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
126            let symbol_hash_raw: Option<i64> = row.get(4)?;
127            let bm25: f64 = row.get(9)?;
128            Ok((
129                Node {
130                    id: row.get(0)?,
131                    node_type,
132                    title: row.get(2)?,
133                    file_path: row.get(3)?,
134                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
135                    summary: row.get(5)?,
136                    content_hash: row.get(6)?,
137                    created_at: row.get(7)?,
138                    updated_at: row.get(8)?,
139                },
140                bm25 as f32,
141            ))
142        })?;
143
144        let mut hits: Vec<RankedHit> = Vec::new();
145        for r in rows {
146            let (node, bm25) = r?;
147            if !opts.allows(&node.node_type) {
148                continue;
149            }
150            // bm25() in SQLite FTS5: more relevant → more negative. Invert.
151            let fts_score = (-bm25).max(0.01);
152            let mut score = fts_score;
153            let mut reasons = vec![format!("fts:{fts_score:.3}")];
154            // Hub boost for root README node.
155            if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
156                score *= 1.2;
157                reasons.push("hub:readme".into());
158            }
159
160            // Title token hits
161            let title_l = node.title.to_lowercase();
162            let id_l = node.id.to_lowercase();
163            for t in &tokens {
164                if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t) {
165                    score += 2.0;
166                    reasons.push(format!("title:{t}"));
167                }
168                if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.ends_with(t) {
169                    score += 1.5;
170                    reasons.push(format!("id:{t}"));
171                }
172            }
173
174            // Tag boosts
175            let tags = self.get_tags_for(&node.id)?;
176            for tag in &tags {
177                let tag_l = tag.to_lowercase();
178                for t in &tokens {
179                    if tag_l == *t || tag_l.contains(t) {
180                        score += 3.0;
181                        reasons.push(format!("tag:{tag}"));
182                    }
183                }
184            }
185
186            // Alias boosts
187            let aliases = self.get_aliases_for(&node.id)?;
188            for alias in &aliases {
189                let a = alias.to_lowercase();
190                for t in &tokens {
191                    if a == *t || a.contains(t) {
192                        score += 2.5;
193                        reasons.push(format!("alias:{alias}"));
194                    }
195                }
196            }
197
198            // Type boost
199            for (ty, mult) in &opts.type_boosts {
200                if node.node_type == *ty {
201                    score *= mult;
202                    reasons.push(format!("type:{}×{mult}", ty.as_str()));
203                }
204            }
205
206            hits.push(RankedHit {
207                node,
208                score,
209                reasons,
210            });
211        }
212
213        // Also pull pure tag/alias matches that FTS may have missed (short queries).
214        self.augment_with_tag_alias_hits(&tokens, &mut hits, opts)?;
215
216        hits.retain(|h| opts.allows(&h.node.node_type));
217
218        hits.sort_by(|a, b| {
219            b.score
220                .partial_cmp(&a.score)
221                .unwrap_or(std::cmp::Ordering::Equal)
222        });
223        // Dedup by id keeping best score
224        let mut seen = std::collections::HashSet::new();
225        hits.retain(|h| seen.insert(h.node.id.clone()));
226        hits.truncate(opts.limit);
227        Ok(hits)
228    }
229
230    fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
231        let mut stmt = self
232            .conn
233            .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
234        let rows = stmt.query_map([node_id], |row| row.get(0))?;
235        let mut v = Vec::new();
236        for r in rows {
237            v.push(r?);
238        }
239        Ok(v)
240    }
241
242    fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
243        let mut stmt = self
244            .conn
245            .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
246        let rows = stmt.query_map([node_id], |row| row.get(0))?;
247        let mut v = Vec::new();
248        for r in rows {
249            v.push(r?);
250        }
251        Ok(v)
252    }
253
254    fn augment_with_tag_alias_hits(
255        &self,
256        tokens: &[String],
257        hits: &mut Vec<RankedHit>,
258        opts: &QueryOptions,
259    ) -> Result<()> {
260        let existing: std::collections::HashSet<String> =
261            hits.iter().map(|h| h.node.id.clone()).collect();
262
263        for t in tokens {
264            // Tag exact
265            let mut stmt = self.conn.prepare(
266                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
267                        n.content_hash, n.created_at, n.updated_at
268                 FROM node_tags t
269                 JOIN nodes n ON n.id = t.node_id
270                 WHERE lower(t.tag) = ?1
271                 LIMIT 20",
272            )?;
273            let rows = stmt.query_map([t.as_str()], |row| {
274                let type_str: String = row.get(1)?;
275                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
276                let symbol_hash_raw: Option<i64> = row.get(4)?;
277                Ok(Node {
278                    id: row.get(0)?,
279                    node_type,
280                    title: row.get(2)?,
281                    file_path: row.get(3)?,
282                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
283                    summary: row.get(5)?,
284                    content_hash: row.get(6)?,
285                    created_at: row.get(7)?,
286                    updated_at: row.get(8)?,
287                })
288            })?;
289            for r in rows {
290                let node = r?;
291                if existing.contains(&node.id) || !opts.allows(&node.node_type) {
292                    continue;
293                }
294                hits.push(RankedHit {
295                    node,
296                    score: 3.5,
297                    reasons: vec![format!("tag-exact:{t}")],
298                });
299            }
300
301            // Alias exact
302            let mut stmt = self.conn.prepare(
303                "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
304                        n.content_hash, n.created_at, n.updated_at
305                 FROM node_aliases a
306                 JOIN nodes n ON n.id = a.node_id
307                 WHERE a.alias = ?1
308                 LIMIT 20",
309            )?;
310            let rows = stmt.query_map([t.as_str()], |row| {
311                let type_str: String = row.get(1)?;
312                let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
313                let symbol_hash_raw: Option<i64> = row.get(4)?;
314                Ok(Node {
315                    id: row.get(0)?,
316                    node_type,
317                    title: row.get(2)?,
318                    file_path: row.get(3)?,
319                    symbol_hash: symbol_hash_raw.map(|h| h as u64),
320                    summary: row.get(5)?,
321                    content_hash: row.get(6)?,
322                    created_at: row.get(7)?,
323                    updated_at: row.get(8)?,
324                })
325            })?;
326            for r in rows {
327                let node = r?;
328                if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
329                {
330                    continue;
331                }
332                hits.push(RankedHit {
333                    node,
334                    score: 3.0,
335                    reasons: vec![format!("alias-exact:{t}")],
336                });
337            }
338        }
339        Ok(())
340    }
341
342    /// List unresolved WikiLink / symbol targets.
343    pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
344        let mut stmt = self.conn.prepare(
345            "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
346        )?;
347        let rows = stmt.query_map([], |row| {
348            Ok(PendingLink {
349                source_id: row.get(0)?,
350                raw_target: row.get(1)?,
351                relation_type: row.get(2)?,
352                created_at: row.get(3)?,
353            })
354        })?;
355        let mut out = Vec::new();
356        for r in rows {
357            out.push(r?);
358        }
359        Ok(out)
360    }
361
362    /// Count nodes grouped by `node_type`.
363    pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
364        let mut stmt = self
365            .conn
366            .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
367        let rows = stmt.query_map([], |row| {
368            Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
369        })?;
370        let mut out = Vec::new();
371        for r in rows {
372            out.push(r?);
373        }
374        Ok(out)
375    }
376}
377
378/// Unresolved link kept for later resolution.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PendingLink {
381    /// Source node id that referenced the target.
382    pub source_id: String,
383    /// Raw WikiLink / `symbol:…` target text.
384    pub raw_target: String,
385    /// Intended relation type (`relates_to`, `anchors`, …).
386    pub relation_type: String,
387    /// When the pending link was recorded (unix seconds).
388    pub created_at: i64,
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::types::NodeType;
395
396    #[test]
397    fn tag_boost_ranks_higher() {
398        let db = Database::open_in_memory().unwrap();
399        let n1 = Node {
400            id: "docs/a".into(),
401            node_type: NodeType::Concept,
402            title: "Something Else".into(),
403            file_path: None,
404            symbol_hash: None,
405            summary: Some("mentions raft in body".into()),
406            content_hash: None,
407            created_at: 1,
408            updated_at: 1,
409        };
410        let n2 = Node {
411            id: "docs/b".into(),
412            node_type: NodeType::Concept,
413            title: "Protocol".into(),
414            file_path: None,
415            symbol_hash: None,
416            summary: Some("tagged note".into()),
417            content_hash: None,
418            created_at: 1,
419            updated_at: 1,
420        };
421        db.insert_node(&n1).unwrap();
422        db.insert_node(&n2).unwrap();
423        db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
424        db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
425        db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
426
427        let hits = db
428            .search_ranked("raft", &QueryOptions::default())
429            .unwrap();
430        assert!(hits.len() >= 2);
431        // Tag match should beat pure body mention when scores are close, or at least appear
432        assert!(hits.iter().any(|h| h.node.id == "docs/b"));
433        let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
434        assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
435    }
436}