1use 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#[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 pub include_types: Vec<NodeType>,
38 pub exclude_types: Vec<NodeType>,
40 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct PendingLink {
414 pub source_id: String,
416 pub raw_target: String,
418 pub relation_type: String,
420 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 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}