Skip to main content

comindly_core/
search.rs

1//! Hybrid code search: native LanceDB retrieval (BM25 + vector via RRF) followed by comindly's
2//! code-aware rerank — definition/exact-name boosts, structured path penalties, LLM-query
3//! recall boost, and a dependency-graph **centrality** signal no pure search tool has.
4//!
5//! Shared by the `comindly search` CLI and the `search` MCP tool.
6
7use std::collections::HashMap;
8
9use crate::embed::{rank, Embedder};
10use crate::graph::CodeGraph;
11use crate::model::{Symbol, SymbolKind};
12
13/// Per-symbol LLM enrichment keyed by rendered symbol id: `(summary, generated_queries)`.
14pub type Enrichment = HashMap<String, (String, Vec<String>)>;
15
16/// One ranked search result.
17#[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    /// `path:line`.
24    pub location: String,
25    pub signature: Option<String>,
26    pub score: f32,
27    pub deps: usize,
28    pub summary: Option<String>,
29}
30
31/// Retrieve candidates for `query` through the open `tables` and rank them. `limit` caps results.
32pub 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
48/// Blocking variant for the CLI (spawns its own runtime under the hood).
49pub 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
65/// Render ranked hits as markdown for handoff to an agent (or `--format md` on the CLI).
66pub 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        // Stable handle for zoom/ripple/thread/flow/context_pack.
90        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
97/// Apply comindly's code-aware + centrality boosts to native-retrieval candidates `(id, base)`.
98pub 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    // Collapse near-duplicates (a symbol surfaced by both BM25 and vector, or a duplicated
156    // search row): keep the highest-scored hit per unique symbol location.
157    let mut seen = std::collections::HashSet::new();
158    ranked.retain(|h| seen.insert((h.name.clone(), h.location.clone())));
159    ranked
160}
161
162/// Max overlap between the user's keywords and any one LLM-generated query (0..1).
163fn 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}