1use std::collections::HashMap;
8
9use crate::embed::{rank, Embedder};
10use crate::graph::CodeGraph;
11use crate::model::{Symbol, SymbolKind};
12
13pub type Enrichment = HashMap<String, (String, Vec<String>)>;
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct SearchHit {
19 pub id: String,
20 pub name: String,
21 pub kind: String,
22 pub repo: String,
23 pub location: String,
25 pub signature: Option<String>,
26 pub score: f32,
27 pub deps: usize,
28 pub summary: Option<String>,
29}
30
31pub async fn hybrid(
33 tables: &crate::index::Tables,
34 by_id: &HashMap<String, Symbol>,
35 graph: &CodeGraph,
36 enrichment: &Enrichment,
37 embedder: &Embedder,
38 query: &str,
39 limit: usize,
40) -> anyhow::Result<Vec<SearchHit>> {
41 let qvec = embedder.embed_query(query);
42 let candidates = tables.hybrid_search(query, qvec, 80).await?;
43 let mut hits = rank_candidates(candidates, by_id, graph, enrichment, query);
44 hits.truncate(limit);
45 Ok(hits)
46}
47
48pub fn hybrid_blocking(
50 uri: &str,
51 by_id: &HashMap<String, Symbol>,
52 graph: &CodeGraph,
53 enrichment: &Enrichment,
54 embedder: &Embedder,
55 query: &str,
56 limit: usize,
57) -> anyhow::Result<Vec<SearchHit>> {
58 let qvec = embedder.embed_query(query);
59 let candidates = crate::index::hybrid_search_blocking(uri, query, qvec, 80)?;
60 let mut hits = rank_candidates(candidates, by_id, graph, enrichment, query);
61 hits.truncate(limit);
62 Ok(hits)
63}
64
65pub fn markdown(query: &str, hits: &[SearchHit]) -> String {
67 use std::fmt::Write as _;
68 let mut o = format!("## Search: \"{query}\" — {} result(s)\n\n", hits.len());
69 if hits.is_empty() {
70 return o + "_no results (or the index was built with `--no-embed`)_\n";
71 }
72 for (i, h) in hits.iter().enumerate() {
73 let _ = write!(
74 o,
75 "{}. **{}** _{}_ · score {:.3} · `{}`",
76 i + 1,
77 h.name,
78 h.kind,
79 h.score,
80 h.location,
81 );
82 if !h.repo.is_empty() {
83 let _ = write!(o, " · {}", h.repo);
84 }
85 let _ = write!(o, " · {} deps", h.deps);
86 if let Some(s) = &h.summary {
87 let _ = write!(o, "\n ↳ {s}");
88 }
89 let _ = write!(o, "\n id `{}`", h.id);
91 o.push('\n');
92 }
93 o.push_str("\n_Pass an `id` to `zoom`/`ripple`/`context_pack` to navigate from a hit._\n");
94 o
95}
96
97pub fn rank_candidates(
99 candidates: Vec<(String, f32)>,
100 by_id: &HashMap<String, Symbol>,
101 graph: &CodeGraph,
102 enrichment: &Enrichment,
103 query: &str,
104) -> Vec<SearchHit> {
105 let keywords = rank::query_keywords(query);
106 let symbolish = rank::is_symbol_query(query);
107
108 let mut ranked: Vec<SearchHit> = candidates
109 .into_iter()
110 .filter_map(|(rid, base)| {
111 let sym = by_id.get(&rid)?;
112 let deps = graph.dependents_count(&rid);
113 let enr = enrichment.get(&rid);
114
115 let is_def = matches!(
116 sym.kind,
117 SymbolKind::Function
118 | SymbolKind::Method
119 | SymbolKind::Class
120 | SymbolKind::Interface
121 | SymbolKind::Struct
122 | SymbolKind::Enum
123 );
124 let def_boost = if is_def { 1.2 } else { 1.0 };
125 let exact_boost = if symbolish && sym.name.eq_ignore_ascii_case(query.trim()) {
126 2.5
127 } else {
128 1.0
129 };
130 let penalty = rank::path_penalty(&sym.file_path);
131 let graph_boost = 1.0 + 0.15 * (1.0 + deps as f32).ln();
132 let query_boost = enr.map_or(1.0, |(_, qs)| 1.0 + 0.4 * query_match(&keywords, qs));
133
134 let score = base * def_boost * exact_boost * penalty * graph_boost * query_boost;
135 Some(SearchHit {
136 id: rid.clone(),
137 name: graph
138 .lookup(&rid)
139 .map_or_else(|| sym.name.clone(), |i| graph.node(i).name),
140 kind: format!("{:?}", sym.kind),
141 repo: sym.repo.0.clone(),
142 location: format!("{}:{}", sym.file_path, sym.range.start.line),
143 signature: sym.signature.clone(),
144 score,
145 deps,
146 summary: enr.map(|(s, _)| s.clone()),
147 })
148 })
149 .collect();
150 ranked.sort_by(|a, b| {
151 b.score
152 .partial_cmp(&a.score)
153 .unwrap_or(std::cmp::Ordering::Equal)
154 });
155 let mut seen = std::collections::HashSet::new();
158 ranked.retain(|h| seen.insert((h.name.clone(), h.location.clone())));
159 ranked
160}
161
162fn query_match(keywords: &[String], generated: &[String]) -> f32 {
164 if keywords.is_empty() {
165 return 0.0;
166 }
167 generated
168 .iter()
169 .map(|q| {
170 let qk = rank::query_keywords(q);
171 keywords.iter().filter(|k| qk.contains(*k)).count() as f32 / keywords.len() as f32
172 })
173 .fold(0.0, f32::max)
174}