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::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 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 pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
100 let prepared = prepare_search_query(query)?;
101 let tokens = &prepared.tokens;
102
103 let mut stmt = self.conn.prepare(
104 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
105 n.content_hash, n.created_at, n.updated_at,
106 bm25(node_fts) AS bm25_score
107 FROM node_fts f
108 JOIN nodes n ON n.id = f.node_id
109 WHERE node_fts MATCH ?1
110 ORDER BY bm25_score
111 LIMIT ?2",
112 )?;
113
114 let rows = stmt.query_map(params![prepared.fts_match, opts.limit as i64 * 2], |row| {
115 let type_str: String = row.get(1)?;
116 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
117 let symbol_hash_raw: Option<i64> = row.get(4)?;
118 let bm25: f64 = row.get(9)?;
119 Ok((
120 Node {
121 id: row.get(0)?,
122 node_type,
123 title: row.get(2)?,
124 file_path: row.get(3)?,
125 symbol_hash: symbol_hash_raw.map(|h| h as u64),
126 summary: row.get(5)?,
127 content_hash: row.get(6)?,
128 created_at: row.get(7)?,
129 updated_at: row.get(8)?,
130 },
131 bm25 as f32,
132 ))
133 })?;
134
135 let mut hits: Vec<RankedHit> = Vec::new();
136 for r in rows {
137 let (node, bm25) = r?;
138 if !opts.allows(&node.node_type) {
139 continue;
140 }
141 let fts_score = (-bm25).max(0.01);
143 let mut score = fts_score;
144 let mut reasons = vec![format!("fts:{fts_score:.3}")];
145 if prepared.used_or {
146 reasons.push("fts:or".into());
147 }
148 if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
150 score *= 1.2;
151 reasons.push("hub:readme".into());
152 }
153
154 let title_l = node.title.to_lowercase();
156 let id_l = node.id.to_lowercase();
157 let fts_body = self
158 .get_fts_content(&node.id)
159 .ok()
160 .flatten()
161 .unwrap_or_default()
162 .to_lowercase();
163 let mut coverage = 0usize;
164 for t in tokens {
165 let mut hit_tok = false;
166 if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
167 score += 2.0;
168 reasons.push(format!("title:{t}"));
169 hit_tok = true;
170 }
171 if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
172 score += 1.5;
173 reasons.push(format!("id:{t}"));
174 hit_tok = true;
175 }
176 if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
177 score += 0.75;
178 hit_tok = true;
179 }
180 if hit_tok {
181 coverage += 1;
182 }
183 }
184 if tokens.len() > 1 && coverage > 1 {
185 let bonus = 1.5 * (coverage as f32);
186 score += bonus;
187 reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
188 }
189
190 let tags = self.get_tags_for(&node.id)?;
192 for tag in &tags {
193 let tag_l = tag.to_lowercase();
194 for t in tokens {
195 if tag_l == *t || tag_l.contains(t.as_str()) {
196 score += 3.0;
197 reasons.push(format!("tag:{tag}"));
198 }
199 }
200 }
201
202 let aliases = self.get_aliases_for(&node.id)?;
204 for alias in &aliases {
205 let a = alias.to_lowercase();
206 for t in tokens {
207 if a == *t || a.contains(t.as_str()) {
208 score += 2.5;
209 reasons.push(format!("alias:{alias}"));
210 }
211 }
212 }
213
214 for (ty, mult) in &opts.type_boosts {
216 if node.node_type == *ty {
217 score *= mult;
218 reasons.push(format!("type:{}×{mult}", ty.as_str()));
219 }
220 }
221
222 hits.push(RankedHit {
223 node,
224 score,
225 reasons,
226 });
227 }
228
229 self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
231
232 hits.retain(|h| opts.allows(&h.node.node_type));
233
234 hits.sort_by(|a, b| {
235 b.score
236 .partial_cmp(&a.score)
237 .unwrap_or(std::cmp::Ordering::Equal)
238 });
239 let mut seen = std::collections::HashSet::new();
241 hits.retain(|h| seen.insert(h.node.id.clone()));
242 hits.truncate(opts.limit);
243 Ok(hits)
244 }
245
246 fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
247 let mut stmt = self
248 .conn
249 .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
250 let rows = stmt.query_map([node_id], |row| row.get(0))?;
251 let mut v = Vec::new();
252 for r in rows {
253 v.push(r?);
254 }
255 Ok(v)
256 }
257
258 fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
259 let mut stmt = self
260 .conn
261 .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
262 let rows = stmt.query_map([node_id], |row| row.get(0))?;
263 let mut v = Vec::new();
264 for r in rows {
265 v.push(r?);
266 }
267 Ok(v)
268 }
269
270 fn augment_with_tag_alias_hits(
271 &self,
272 tokens: &[String],
273 hits: &mut Vec<RankedHit>,
274 opts: &QueryOptions,
275 ) -> Result<()> {
276 let existing: std::collections::HashSet<String> =
277 hits.iter().map(|h| h.node.id.clone()).collect();
278
279 for t in tokens {
280 let mut stmt = self.conn.prepare(
282 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
283 n.content_hash, n.created_at, n.updated_at
284 FROM node_tags t
285 JOIN nodes n ON n.id = t.node_id
286 WHERE lower(t.tag) = ?1
287 LIMIT 20",
288 )?;
289 let rows = stmt.query_map([t.as_str()], |row| {
290 let type_str: String = row.get(1)?;
291 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
292 let symbol_hash_raw: Option<i64> = row.get(4)?;
293 Ok(Node {
294 id: row.get(0)?,
295 node_type,
296 title: row.get(2)?,
297 file_path: row.get(3)?,
298 symbol_hash: symbol_hash_raw.map(|h| h as u64),
299 summary: row.get(5)?,
300 content_hash: row.get(6)?,
301 created_at: row.get(7)?,
302 updated_at: row.get(8)?,
303 })
304 })?;
305 for r in rows {
306 let node = r?;
307 if existing.contains(&node.id) || !opts.allows(&node.node_type) {
308 continue;
309 }
310 hits.push(RankedHit {
311 node,
312 score: 3.5,
313 reasons: vec![format!("tag-exact:{t}")],
314 });
315 }
316
317 let mut stmt = self.conn.prepare(
319 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
320 n.content_hash, n.created_at, n.updated_at
321 FROM node_aliases a
322 JOIN nodes n ON n.id = a.node_id
323 WHERE a.alias = ?1
324 LIMIT 20",
325 )?;
326 let rows = stmt.query_map([t.as_str()], |row| {
327 let type_str: String = row.get(1)?;
328 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
329 let symbol_hash_raw: Option<i64> = row.get(4)?;
330 Ok(Node {
331 id: row.get(0)?,
332 node_type,
333 title: row.get(2)?,
334 file_path: row.get(3)?,
335 symbol_hash: symbol_hash_raw.map(|h| h as u64),
336 summary: row.get(5)?,
337 content_hash: row.get(6)?,
338 created_at: row.get(7)?,
339 updated_at: row.get(8)?,
340 })
341 })?;
342 for r in rows {
343 let node = r?;
344 if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
345 {
346 continue;
347 }
348 hits.push(RankedHit {
349 node,
350 score: 3.0,
351 reasons: vec![format!("alias-exact:{t}")],
352 });
353 }
354 }
355 Ok(())
356 }
357
358 pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
360 let mut stmt = self.conn.prepare(
361 "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
362 )?;
363 let rows = stmt.query_map([], |row| {
364 Ok(PendingLink {
365 source_id: row.get(0)?,
366 raw_target: row.get(1)?,
367 relation_type: row.get(2)?,
368 created_at: row.get(3)?,
369 })
370 })?;
371 let mut out = Vec::new();
372 for r in rows {
373 out.push(r?);
374 }
375 Ok(out)
376 }
377
378 pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
380 let mut stmt = self
381 .conn
382 .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
383 let rows = stmt.query_map([], |row| {
384 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
385 })?;
386 let mut out = Vec::new();
387 for r in rows {
388 out.push(r?);
389 }
390 Ok(out)
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct PendingLink {
397 pub source_id: String,
399 pub raw_target: String,
401 pub relation_type: String,
403 pub created_at: i64,
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::types::NodeType;
411
412 #[test]
413 fn tag_boost_ranks_higher() {
414 let db = Database::open_in_memory().unwrap();
415 let n1 = Node {
416 id: "docs/a".into(),
417 node_type: NodeType::Concept,
418 title: "Something Else".into(),
419 file_path: None,
420 symbol_hash: None,
421 summary: Some("mentions raft in body".into()),
422 content_hash: None,
423 created_at: 1,
424 updated_at: 1,
425 };
426 let n2 = Node {
427 id: "docs/b".into(),
428 node_type: NodeType::Concept,
429 title: "Protocol".into(),
430 file_path: None,
431 symbol_hash: None,
432 summary: Some("tagged note".into()),
433 content_hash: None,
434 created_at: 1,
435 updated_at: 1,
436 };
437 db.insert_node(&n1).unwrap();
438 db.insert_node(&n2).unwrap();
439 db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
440 db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
441 db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
442
443 let hits = db
444 .search_ranked("raft", &QueryOptions::default())
445 .unwrap();
446 assert!(hits.len() >= 2);
447 assert!(hits.iter().any(|h| h.node.id == "docs/b"));
449 let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
450 assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
451 }
452}