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