1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RankedHit {
19 pub node: Node,
21 pub score: f32,
23 pub reasons: Vec<String>,
25}
26
27#[derive(Debug, Clone)]
29pub struct QueryOptions {
30 pub limit: usize,
32 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 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 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 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 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 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 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 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 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 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 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 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}