Skip to main content

gobby_code/search/
graph_boost.rs

1//! FalkorDB graph boost: find related symbols to boost in search ranking.
2//!
3//! Uses callers + usages as the boost set — symbols that are connected
4//! to the resolved query symbol in the call graph get a ranking boost via RRF.
5//!
6//! Source: src/gobby/code_index/searcher.py (_graph_boost method)
7
8use std::collections::{BTreeMap, HashSet};
9
10use crate::config::Context;
11use crate::graph::code_graph;
12use crate::search::fts;
13use crate::visibility;
14
15/// Get symbol IDs related to query via the call/import graph.
16///
17/// Returns a ranked list of symbol IDs for use as an RRF source.
18/// Returns empty vec when FalkorDB is unavailable (graceful degradation).
19pub fn graph_boost(ctx: &Context, conn: Option<&mut postgres::Client>, query: &str) -> Vec<String> {
20    if ctx.falkordb.is_none() {
21        return vec![];
22    }
23
24    let Some(conn) = conn else {
25        return vec![];
26    };
27    let mut resolved =
28        fts::search_symbols_exact_first_visible(conn, query, ctx, None, None, &[], 1).results;
29    let Some(symbol) = resolved.pop() else {
30        return vec![];
31    };
32    let graph_ctx = visibility::context_for_source_project(ctx, &symbol.project_id);
33
34    let callers = code_graph::find_caller_ids(&graph_ctx, &symbol.id, 10).unwrap_or_default();
35    let usages = code_graph::find_usage_ids(&graph_ctx, &symbol.id, 10).unwrap_or_default();
36
37    let mut ids = Vec::new();
38    let mut seen = HashSet::new();
39    for id in callers.into_iter().chain(usages) {
40        if !id.is_empty() && seen.insert(id.clone()) {
41            ids.push(id);
42        }
43    }
44    ids
45}
46
47/// Expand the graph neighborhood of seed symbols found by FTS/semantic search.
48///
49/// Takes symbol IDs from the top search results and queries FalkorDB for their
50/// callees (what they call) and callers (who calls them). Callees are ranked
51/// first since they represent implementation details more useful for conceptual
52/// queries. Returns deduplicated symbol IDs for use as an RRF source.
53pub fn graph_expand(
54    ctx: &Context,
55    conn: Option<&mut postgres::Client>,
56    seed_ids: &[String],
57) -> Vec<String> {
58    if seed_ids.is_empty() || ctx.falkordb.is_none() {
59        return vec![];
60    }
61
62    let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
63    let Some(conn) = conn else {
64        return vec![];
65    };
66    if let Ok(symbols) = visibility::visible_symbols_by_ids(conn, ctx, seed_ids) {
67        for symbol in symbols {
68            by_project
69                .entry(symbol.project_id)
70                .or_default()
71                .push(symbol.id);
72        }
73    }
74
75    graph_expand_grouped(ctx, by_project, |graph_ctx, ids_for_project| {
76        // Callees first — "what do these symbols call?" surfaces implementation details.
77        let callees =
78            code_graph::find_callee_ids_batch(graph_ctx, ids_for_project, 30).unwrap_or_default();
79        // Callers second — "who calls these symbols?" surfaces broader context.
80        let callers =
81            code_graph::find_caller_ids_batch(graph_ctx, ids_for_project, 30).unwrap_or_default();
82        (callees, callers)
83    })
84}
85
86fn graph_expand_grouped(
87    ctx: &Context,
88    by_project: BTreeMap<String, Vec<String>>,
89    mut graph_neighbors: impl FnMut(&Context, &[String]) -> (Vec<String>, Vec<String>),
90) -> Vec<String> {
91    let mut ids = Vec::new();
92    let mut seen = HashSet::new();
93    for (project_id, ids_for_project) in by_project {
94        let graph_ctx = visibility::context_for_source_project(ctx, &project_id);
95        let (callees, callers) = graph_neighbors(&graph_ctx, &ids_for_project);
96        for id in callees.into_iter().chain(callers) {
97            if id.is_empty() || !seen.insert(id.clone()) {
98                continue;
99            }
100            ids.push(id);
101        }
102    }
103    ids
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::path::PathBuf;
110
111    fn make_ctx_no_falkordb() -> Context {
112        Context {
113            database_url: "postgresql://localhost/nonexistent".to_string(),
114            project_root: PathBuf::from("/nonexistent"),
115            project_id: "test".to_string(),
116            quiet: true,
117            falkordb: None,
118            qdrant: None,
119            embedding: None,
120            code_vectors: crate::config::CodeVectorSettings::default(),
121            daemon_url: None,
122            index_scope: crate::config::ProjectIndexScope::Single,
123        }
124    }
125
126    fn make_ctx_with_overlay() -> Context {
127        Context {
128            database_url: "postgresql://localhost/nonexistent".to_string(),
129            project_root: PathBuf::from("/overlay"),
130            project_id: "overlay".to_string(),
131            quiet: true,
132            falkordb: Some(crate::config::FalkorConfig {
133                host: "127.0.0.1".to_string(),
134                port: 16379,
135                password: None,
136                graph_name: "g".to_string(),
137            }),
138            qdrant: None,
139            embedding: None,
140            code_vectors: crate::config::CodeVectorSettings::default(),
141            daemon_url: None,
142            index_scope: crate::config::ProjectIndexScope::Overlay {
143                overlay_project_id: "overlay".to_string(),
144                overlay_root: PathBuf::from("/overlay"),
145                parent_project_id: "parent".to_string(),
146                parent_root: PathBuf::from("/parent"),
147            },
148        }
149    }
150
151    #[test]
152    fn test_graph_boost_no_falkordb() {
153        let ctx = make_ctx_no_falkordb();
154        let result = graph_boost(&ctx, None, "some_function");
155        assert!(result.is_empty());
156    }
157
158    #[test]
159    fn test_graph_expand_no_falkordb() {
160        let ctx = make_ctx_no_falkordb();
161        let result = graph_expand(&ctx, None, &["some_function".to_string()]);
162        assert!(result.is_empty());
163    }
164
165    #[test]
166    fn test_graph_expand_empty_seeds() {
167        let ctx = make_ctx_no_falkordb();
168        let result = graph_expand(&ctx, None, &[]);
169        assert!(result.is_empty());
170    }
171
172    #[test]
173    fn graph_expand_grouped_expands_each_project_scope_and_dedupes() {
174        let ctx = make_ctx_with_overlay();
175        let by_project = BTreeMap::from([
176            (
177                "overlay".to_string(),
178                vec!["overlay-seed-1".to_string(), "overlay-seed-2".to_string()],
179            ),
180            ("parent".to_string(), vec!["parent-seed".to_string()]),
181        ]);
182        let mut calls = Vec::new();
183
184        let expanded = graph_expand_grouped(&ctx, by_project, |graph_ctx, ids| {
185            calls.push((graph_ctx.project_id.clone(), ids.to_vec()));
186            match graph_ctx.project_id.as_str() {
187                "overlay" => (
188                    vec!["impl-a".to_string(), "shared".to_string()],
189                    vec!["caller-a".to_string(), "shared".to_string()],
190                ),
191                "parent" => (
192                    vec!["parent-impl".to_string(), "impl-a".to_string()],
193                    vec!["".to_string(), "parent-caller".to_string()],
194                ),
195                other => panic!("unexpected project {other}"),
196            }
197        });
198
199        assert_eq!(
200            calls,
201            vec![
202                (
203                    "overlay".to_string(),
204                    vec!["overlay-seed-1".to_string(), "overlay-seed-2".to_string()]
205                ),
206                ("parent".to_string(), vec!["parent-seed".to_string()])
207            ]
208        );
209        assert_eq!(
210            expanded,
211            vec![
212                "impl-a".to_string(),
213                "shared".to_string(),
214                "caller-a".to_string(),
215                "parent-impl".to_string(),
216                "parent-caller".to_string()
217            ]
218        );
219    }
220}