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