Skip to main content

terraphim_grep/
hybrid_searcher.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use terraphim_types::Document;
6
7#[derive(Debug, Clone)]
8pub struct GrepOptions {
9    pub haystack: Haystack,
10    pub context_lines: usize,
11    pub max_results: usize,
12    pub force_rlm: bool,
13    pub include_answer: bool,
14}
15
16impl Default for GrepOptions {
17    fn default() -> Self {
18        Self {
19            haystack: Haystack::All,
20            context_lines: 0,
21            max_results: 50,
22            force_rlm: false,
23            include_answer: false,
24        }
25    }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum Haystack {
30    #[default]
31    Code,
32    Docs,
33    All,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RetrievedChunk {
38    pub content: String,
39    pub source: String,
40    pub line_start: Option<usize>,
41    pub line_end: Option<usize>,
42    pub relevance_score: f64,
43    pub haystack: &'static str,
44}
45
46impl From<Document> for RetrievedChunk {
47    fn from(doc: Document) -> Self {
48        Self {
49            content: doc.body,
50            source: doc.url,
51            line_start: None,
52            line_end: None,
53            relevance_score: doc.rank.unwrap_or(0) as f64,
54            haystack: "code",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct KgConcept {
61    pub id: u64,
62    pub name: String,
63    pub display_value: Option<String>,
64    pub score: f64,
65}
66
67#[derive(Debug, Clone)]
68pub struct HybridResults {
69    pub code_results: Vec<RetrievedChunk>,
70    pub doc_results: Vec<RetrievedChunk>,
71    pub kg_concepts: Vec<KgConcept>,
72}
73
74impl HybridResults {
75    pub fn to_chunks(&self) -> Vec<RetrievedChunk> {
76        let mut chunks = Vec::with_capacity(self.code_results.len() + self.doc_results.len());
77        chunks.extend(self.code_results.clone());
78        chunks.extend(self.doc_results.clone());
79        chunks
80    }
81
82    pub fn total_results(&self) -> usize {
83        self.code_results.len() + self.doc_results.len() + self.kg_concepts.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.code_results.is_empty() && self.doc_results.is_empty() && self.kg_concepts.is_empty()
88    }
89}
90
91/// Default weight applied to KG matches when boosting a chunk's relevance score.
92/// A weight of 1.0 means a chunk whose path and content fully match the top KG concept
93/// can roughly double its rank vs an unmatched chunk with the same base score.
94pub const DEFAULT_KG_BOOST_WEIGHT: f64 = 1.0;
95
96/// Compute the KG boost for a single chunk against a set of matched concepts.
97///
98/// For each concept whose lowercased `name` (or `display_value`, if set) appears in the
99/// chunk's lowercased source path or content, the concept's normalised score contributes
100/// to the boost. The result is in `[0.0, weight]`; callers add it to the chunk's
101/// `relevance_score`.
102///
103/// Why path-and-content: matching only paths misses content-defined concepts (a struct
104/// `RetryPolicy` declared in `src/network.rs`); matching only content over-rewards files
105/// that mention a concept in passing. Combining the two is a sensible default.
106pub fn score_kg_boost(chunk: &RetrievedChunk, concepts: &[KgConcept], weight: f64) -> f64 {
107    if concepts.is_empty() || weight <= 0.0 {
108        return 0.0;
109    }
110    let max_concept_score: f64 = concepts.iter().map(|c| c.score).fold(0.0, f64::max);
111    if max_concept_score <= 0.0 {
112        return 0.0;
113    }
114    let source_lower = chunk.source.to_lowercase();
115    let content_lower = chunk.content.to_lowercase();
116
117    let mut boost = 0.0;
118    for c in concepts {
119        let needle = c
120            .display_value
121            .as_deref()
122            .unwrap_or(c.name.as_str())
123            .to_lowercase();
124        if needle.is_empty() {
125            continue;
126        }
127        if source_lower.contains(&needle) || content_lower.contains(&needle) {
128            boost += c.score / max_concept_score;
129        }
130    }
131    (boost * weight).min(weight * concepts.len() as f64)
132}
133
134/// Apply KG boost to a batch of chunks and sort by boosted score (descending).
135/// Mutates `relevance_score` in place so downstream consumers can see the boost reflected
136/// in the JSON output -- otherwise the ordering would be inexplicable.
137pub fn boost_chunks_with_kg(
138    mut chunks: Vec<RetrievedChunk>,
139    concepts: &[KgConcept],
140) -> Vec<RetrievedChunk> {
141    for chunk in chunks.iter_mut() {
142        let boost = score_kg_boost(chunk, concepts, DEFAULT_KG_BOOST_WEIGHT);
143        chunk.relevance_score += boost;
144    }
145    chunks.sort_by(|a, b| {
146        b.relevance_score
147            .partial_cmp(&a.relevance_score)
148            .unwrap_or(std::cmp::Ordering::Equal)
149    });
150    chunks
151}
152
153pub struct HybridSearcher {
154    role_graph: Arc<tokio::sync::RwLock<terraphim_rolegraph::RoleGraph>>,
155    /// Kept alongside the rolegraph so KG-style boosting still works when no documents
156    /// have been indexed into the graph. The rolegraph requires indexed documents to
157    /// return meaningful query results; the raw thesaurus is enough to identify which
158    /// of the user's known concepts touch the query.
159    thesaurus: terraphim_types::Thesaurus,
160    search_paths: Vec<PathBuf>,
161}
162
163impl HybridSearcher {
164    pub fn new(
165        role_name: String,
166        thesaurus: terraphim_types::Thesaurus,
167    ) -> Result<Self, terraphim_rolegraph::Error> {
168        let rolegraph = terraphim_rolegraph::RoleGraph::new_sync(
169            terraphim_types::RoleName::new(&role_name),
170            thesaurus.clone(),
171        )?;
172
173        Ok(Self {
174            role_graph: Arc::new(tokio::sync::RwLock::new(rolegraph)),
175            thesaurus,
176            search_paths: vec![PathBuf::from(".")],
177        })
178    }
179
180    pub fn with_search_path(mut self, path: PathBuf) -> Self {
181        self.search_paths = vec![path];
182        self
183    }
184
185    pub fn with_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
186        self.search_paths = paths;
187        self
188    }
189
190    pub async fn search(
191        &self,
192        query: &str,
193        options: &GrepOptions,
194    ) -> Result<HybridResults, String> {
195        let max_results = options.max_results;
196        let search_paths = self.search_paths.clone();
197        let role_graph = self.role_graph.clone();
198        let query_owned = query.to_string();
199
200        let thesaurus = self.thesaurus.clone();
201
202        let (kg_concepts, code_results) = match options.haystack {
203            Haystack::All | Haystack::Code => {
204                let kg_handle = tokio::spawn({
205                    let query = query_owned.clone();
206                    let graph = role_graph.clone();
207                    let thes = thesaurus.clone();
208                    async move { Self::search_kg(&query, max_results, graph, &thes).await }
209                });
210
211                let code_handle = tokio::spawn({
212                    let query = query_owned.clone();
213                    let paths = search_paths.clone();
214                    async move {
215                        let mut all_results = Vec::new();
216                        for path in paths {
217                            let mut results = Self::search_code(&query, max_results, path).await?;
218                            all_results.append(&mut results);
219                        }
220                        Ok::<Vec<RetrievedChunk>, String>(all_results)
221                    }
222                });
223
224                let kg_concepts = kg_handle
225                    .await
226                    .map_err(|e| format!("KG search join error: {}", e))??;
227                let code_results = code_handle
228                    .await
229                    .map_err(|e| format!("Code search join error: {}", e))??;
230                (kg_concepts, code_results)
231            }
232            Haystack::Docs => {
233                let kg_concepts =
234                    Self::search_kg(&query_owned, max_results, role_graph.clone(), &thesaurus)
235                        .await?;
236                (kg_concepts, vec![])
237            }
238        };
239
240        // KG boost: re-rank code_results so chunks whose source path or content matches
241        // a thesaurus concept rank above generic matches. The base relevance from fff is
242        // currently uniform (1.0 per match), so without this step the user's knowledge
243        // does not influence ordering at all. Boost in place; the boosted score is what
244        // the JSON output reports so downstream tools see why a chunk ranked where it did.
245        let code_results = boost_chunks_with_kg(code_results, &kg_concepts);
246        let code_results: Vec<RetrievedChunk> =
247            code_results.into_iter().take(max_results).collect();
248
249        Ok(HybridResults {
250            code_results,
251            doc_results: vec![],
252            kg_concepts,
253        })
254    }
255
256    async fn search_kg(
257        query: &str,
258        limit: usize,
259        graph: Arc<tokio::sync::RwLock<terraphim_rolegraph::RoleGraph>>,
260        thesaurus: &terraphim_types::Thesaurus,
261    ) -> Result<Vec<KgConcept>, String> {
262        let graph_guard = graph.read().await;
263
264        let matches = graph_guard
265            .query_graph_with_trigger_fallback(query, None, Some(limit), false)
266            .map_err(|e| e.to_string())?;
267
268        if !matches.is_empty() {
269            let concepts = matches
270                .into_iter()
271                .map(|(doc_id, indexed_doc)| KgConcept {
272                    id: 0,
273                    name: doc_id,
274                    display_value: None,
275                    score: indexed_doc.rank as f64,
276                })
277                .collect();
278            return Ok(concepts);
279        }
280
281        // Fallback: rolegraph returned nothing (graph has no indexed documents yet, or no
282        // node matched the query). Fall back to thesaurus-only matching so KG boost still
283        // fires. Match the rolegraph's Aho-Corasick semantics by lowercasing both sides
284        // and scanning each thesaurus key for substring presence in the query.
285        let query_lower = query.to_lowercase();
286        let mut concepts: Vec<KgConcept> = thesaurus
287            .keys()
288            .filter_map(|key| {
289                let key_str = key.as_str();
290                let key_lower = key_str.to_lowercase();
291                if query_lower.contains(&key_lower) || key_lower.contains(&query_lower) {
292                    Some(KgConcept {
293                        id: 0,
294                        name: key_str.to_string(),
295                        display_value: None,
296                        score: 1.0,
297                    })
298                } else {
299                    None
300                }
301            })
302            .take(limit)
303            .collect();
304        // Stable ordering for deterministic boost output across runs.
305        concepts.sort_by(|a, b| a.name.cmp(&b.name));
306        Ok(concepts)
307    }
308
309    async fn search_code(
310        query: &str,
311        limit: usize,
312        search_path: PathBuf,
313    ) -> Result<Vec<RetrievedChunk>, String> {
314        #[cfg(feature = "code-search")]
315        {
316            use fff_search::{
317                FFFMode, FilePicker, FilePickerOptions, GrepMode, GrepSearchOptions,
318                parse_grep_query,
319            };
320
321            let mut picker = FilePicker::new(FilePickerOptions {
322                base_path: search_path.to_string_lossy().to_string(),
323                mode: FFFMode::Ai,
324                watch: false,
325                cache_budget: None,
326                ..FilePickerOptions::default()
327            })
328            .map_err(|e| format!("FilePicker init failed: {}", e))?;
329
330            picker
331                .collect_files()
332                .map_err(|e| format!("File scan failed: {}", e))?;
333
334            if picker.get_files().is_empty() {
335                return Ok(vec![]);
336            }
337
338            let fff_query = parse_grep_query(query);
339            let options = GrepSearchOptions {
340                max_file_size: 10 * 1024 * 1024,
341                max_matches_per_file: 200,
342                smart_case: true,
343                file_offset: 0,
344                page_limit: limit,
345                mode: GrepMode::PlainText,
346                time_budget_ms: 0,
347                before_context: 0,
348                after_context: 0,
349                classify_definitions: false,
350                ..GrepSearchOptions::default()
351            };
352
353            let result = picker.grep(&fff_query, &options);
354
355            let chunks = result
356                .matches
357                .iter()
358                .take(limit)
359                .filter_map(|m| {
360                    let file = result.files.get(m.file_index)?;
361                    Some(RetrievedChunk {
362                        content: m.line_content.clone(),
363                        source: file.relative_path(&picker),
364                        line_start: Some(m.line_number as usize),
365                        line_end: Some(m.line_number as usize),
366                        relevance_score: 1.0,
367                        haystack: "code",
368                    })
369                })
370                .collect();
371
372            Ok(chunks)
373        }
374
375        #[cfg(not(feature = "code-search"))]
376        {
377            let _ = (query, limit, search_path);
378            // `code-search` is a default feature, so reaching here means the binary was
379            // built with `--no-default-features` (or a custom set omitting it). Returning
380            // an empty Vec silently makes every query look like "0 results" with no
381            // explanation -- warn once so the cause is obvious rather than mysterious.
382            static WARNED: std::sync::Once = std::sync::Once::new();
383            WARNED.call_once(|| {
384                tracing::warn!(
385                    "terraphim-grep was built without the `code-search` feature; \
386                     file-content search is disabled and every query returns no matches. \
387                     Rebuild with `--features code-search` (the default) to enable grep."
388                );
389            });
390            Ok(vec![])
391        }
392    }
393
394    pub fn fuse_and_rank(&self, mut results: Vec<RetrievedChunk>) -> Vec<RetrievedChunk> {
395        results.sort_by(|a, b| {
396            b.relevance_score
397                .partial_cmp(&a.relevance_score)
398                .unwrap_or(std::cmp::Ordering::Equal)
399        });
400        results
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[tokio::test]
409    async fn test_hybrid_results_empty() {
410        let results = HybridResults {
411            code_results: vec![],
412            doc_results: vec![],
413            kg_concepts: vec![],
414        };
415        assert!(results.is_empty());
416        assert_eq!(results.total_results(), 0);
417    }
418
419    #[tokio::test]
420    async fn test_hybrid_results_to_chunks() {
421        let results = HybridResults {
422            code_results: vec![RetrievedChunk {
423                content: "test".to_string(),
424                source: "file1.rs".to_string(),
425                line_start: Some(1),
426                line_end: Some(1),
427                relevance_score: 0.9,
428                haystack: "code",
429            }],
430            doc_results: vec![RetrievedChunk {
431                content: "test doc".to_string(),
432                source: "file2.md".to_string(),
433                line_start: Some(5),
434                line_end: Some(5),
435                relevance_score: 0.8,
436                haystack: "docs",
437            }],
438            kg_concepts: vec![],
439        };
440
441        let chunks = results.to_chunks();
442        assert_eq!(chunks.len(), 2);
443        assert_eq!(results.total_results(), 2);
444    }
445
446    /// Regression for #47: a default build (which now enables `code-search`) must grep
447    /// file contents over a populated directory and return matches. Before the fix the
448    /// `code-search` feature was off by default, so `search_code` compiled to a no-op stub
449    /// and this exact scenario silently produced zero chunks.
450    #[cfg(feature = "code-search")]
451    #[tokio::test]
452    async fn default_build_greps_populated_directory() {
453        let tmp = tempfile::TempDir::new().expect("tempdir");
454        std::fs::write(
455            tmp.path().join("alpha.rs"),
456            "fn configure_pipeline() { /* pipeline setup */ }\n",
457        )
458        .unwrap();
459        std::fs::write(
460            tmp.path().join("beta.rs"),
461            "fn run_pipeline() { configure_pipeline(); }\n",
462        )
463        .unwrap();
464
465        let searcher = HybridSearcher::new(
466            "test-role".to_string(),
467            terraphim_types::Thesaurus::new("t".to_string()),
468        )
469        .expect("build hybrid searcher")
470        .with_search_path(tmp.path().to_path_buf());
471
472        let results = searcher
473            .search(
474                "pipeline",
475                &GrepOptions {
476                    haystack: Haystack::Code,
477                    max_results: 50,
478                    ..GrepOptions::default()
479                },
480            )
481            .await
482            .expect("search should succeed");
483
484        assert!(
485            !results.code_results.is_empty(),
486            "default build must return file-content matches over {:?}, got none",
487            tmp.path()
488        );
489    }
490
491    /// Multiple search paths must be scanned and their results merged.
492    #[cfg(feature = "code-search")]
493    #[tokio::test]
494    async fn multiple_search_paths_merge_results() {
495        let tmp = tempfile::TempDir::new().expect("tempdir");
496        let dir_a = tmp.path().join("a");
497        let dir_b = tmp.path().join("b");
498        std::fs::create_dir(&dir_a).unwrap();
499        std::fs::create_dir(&dir_b).unwrap();
500        std::fs::write(dir_a.join("alpha.rs"), "fn shared_term() {}\n").unwrap();
501        std::fs::write(dir_b.join("beta.rs"), "fn shared_term() {}\n").unwrap();
502
503        let searcher = HybridSearcher::new(
504            "test-role".to_string(),
505            terraphim_types::Thesaurus::new("t".to_string()),
506        )
507        .expect("build hybrid searcher")
508        .with_search_paths(vec![dir_a.clone(), dir_b.clone()]);
509
510        let results = searcher
511            .search(
512                "shared_term",
513                &GrepOptions {
514                    haystack: Haystack::Code,
515                    max_results: 50,
516                    ..GrepOptions::default()
517                },
518            )
519            .await
520            .expect("search should succeed");
521
522        assert_eq!(
523            results.code_results.len(),
524            2,
525            "expected one match from each of {:?} and {:?}",
526            dir_a,
527            dir_b
528        );
529    }
530
531    fn chunk(source: &str, content: &str, score: f64) -> RetrievedChunk {
532        RetrievedChunk {
533            content: content.to_string(),
534            source: source.to_string(),
535            line_start: Some(1),
536            line_end: Some(1),
537            relevance_score: score,
538            haystack: "code",
539        }
540    }
541
542    fn concept(name: &str, score: f64) -> KgConcept {
543        KgConcept {
544            id: 0,
545            name: name.to_string(),
546            display_value: None,
547            score,
548        }
549    }
550
551    #[test]
552    fn kg_boost_promotes_matching_chunks_to_top() {
553        // Two chunks with identical base scores. Only one mentions the KG concept in its
554        // path or content. After boost, the matching chunk must rank first -- this is the
555        // "your knowledge tops the results" guarantee.
556        let chunks = vec![
557            chunk("src/parse_csv.rs", "fn parse_csv() {}", 1.0),
558            chunk("src/retry_policy.rs", "pub struct RetryPolicy {}", 1.0),
559        ];
560        let concepts = vec![concept("retry_policy", 0.9)];
561
562        let ranked = boost_chunks_with_kg(chunks, &concepts);
563        assert_eq!(ranked[0].source, "src/retry_policy.rs");
564        assert!(
565            ranked[0].relevance_score > ranked[1].relevance_score,
566            "KG-matched chunk must outscore the unmatched chunk: {:?} vs {:?}",
567            ranked[0].relevance_score,
568            ranked[1].relevance_score,
569        );
570    }
571
572    #[test]
573    fn kg_boost_no_concepts_is_a_noop() {
574        // No KG concepts -> no boost -> ordering by base score only. Confirms the boost
575        // path stays neutral when there's nothing to learn from the KG.
576        let chunks = vec![chunk("a.rs", "alpha", 0.5), chunk("b.rs", "beta", 0.9)];
577        let ranked = boost_chunks_with_kg(chunks, &[]);
578        assert_eq!(ranked[0].source, "b.rs");
579        assert!((ranked[0].relevance_score - 0.9).abs() < f64::EPSILON);
580    }
581
582    #[test]
583    fn kg_boost_stacks_when_multiple_concepts_match() {
584        // A chunk that matches *two* concepts gets a larger boost than one matching only
585        // one. Pins down the additive behaviour of score_kg_boost.
586        let one_match = chunk("src/retry.rs", "fn retry() {}", 1.0);
587        let two_matches = chunk("src/retry.rs", "fn retry() -> backoff::Result<()>", 1.0);
588        let concepts = vec![concept("retry", 1.0), concept("backoff", 1.0)];
589
590        let b1 = score_kg_boost(&one_match, &concepts, 1.0);
591        let b2 = score_kg_boost(&two_matches, &concepts, 1.0);
592        assert!(
593            b2 > b1,
594            "two-concept match must score higher than one-concept match: {b2} vs {b1}"
595        );
596    }
597
598    #[test]
599    fn test_grep_options_default() {
600        let options = GrepOptions::default();
601        assert_eq!(options.haystack, Haystack::All);
602        assert_eq!(options.context_lines, 0);
603        assert_eq!(options.max_results, 50);
604        assert!(!options.force_rlm);
605        assert!(!options.include_answer);
606    }
607}