@@ -95,10 +95,10 @@ pub const DEFAULT_KG_BOOST_WEIGHT: f64 = 1.0;
9595
9696/// Compute the KG boost for a single chunk against a set of matched concepts.
9797///
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 `.
98+ /// For each concept whose `name` (or `display_value`, if set) is matched by
99+ /// `terraphim_automata` in the chunk's source path or content, the concept's normalised
100+ /// score contributes to the boost. Matches embedded inside a larger alphanumeric word are
101+ /// ignored, so a concept like `auth` does not boost `Author `.
102102///
103103/// Why path-and-content: matching only paths misses content-defined concepts (a struct
104104/// `RetryPolicy` declared in `src/network.rs`); matching only content over-rewards files
@@ -111,26 +111,91 @@ pub fn score_kg_boost(chunk: &RetrievedChunk, concepts: &[KgConcept], weight: f6
111111 if max_concept_score <= 0.0 {
112112 return 0.0 ;
113113 }
114- let source_lower = chunk. source . to_lowercase ( ) ;
115- let content_lower = chunk. content . to_lowercase ( ) ;
116-
117114 let mut boost = 0.0 ;
118115 for c in concepts {
119- let needle = c
120- . display_value
121- . as_deref ( )
122- . unwrap_or ( c. name . as_str ( ) )
123- . to_lowercase ( ) ;
116+ let needle = c. display_value . as_deref ( ) . unwrap_or ( c. name . as_str ( ) ) . trim ( ) ;
124117 if needle. is_empty ( ) {
125118 continue ;
126119 }
127- if source_lower. contains ( & needle) || content_lower. contains ( & needle) {
120+ if automata_concept_matches ( & chunk. source , needle)
121+ || automata_concept_matches ( & chunk. content , needle)
122+ {
128123 boost += c. score / max_concept_score;
129124 }
130125 }
131126 ( boost * weight) . min ( weight * concepts. len ( ) as f64 )
132127}
133128
129+ fn automata_concept_matches ( text : & str , concept : & str ) -> bool {
130+ let role = terraphim_types:: RoleName :: new ( "terraphim-grep-kg-boost" ) ;
131+ let thesaurus = terraphim_automata:: thesaurus_from_terms ( & role, std:: iter:: once ( concept) ) ;
132+ match terraphim_automata:: find_matches ( text, thesaurus, true ) {
133+ Ok ( matches) => matches. iter ( ) . any ( |matched| {
134+ matched
135+ . pos
136+ . is_some_and ( |pos| has_concept_boundaries ( text, pos) )
137+ } ) ,
138+ Err ( error) => {
139+ tracing:: debug!( "KG boost automata match failed for concept {concept:?}: {error}" ) ;
140+ false
141+ }
142+ }
143+ }
144+
145+ fn has_concept_boundaries ( text : & str , ( start, end) : ( usize , usize ) ) -> bool {
146+ let before = text[ ..start] . chars ( ) . next_back ( ) ;
147+ let after = text[ end..] . chars ( ) . next ( ) ;
148+ !before. is_some_and ( char:: is_alphanumeric) && !after. is_some_and ( char:: is_alphanumeric)
149+ }
150+
151+ fn thesaurus_query_concepts (
152+ query : & str ,
153+ thesaurus : & terraphim_types:: Thesaurus ,
154+ limit : usize ,
155+ ) -> Vec < KgConcept > {
156+ match terraphim_automata:: find_matches ( query, thesaurus. clone ( ) , false ) {
157+ Ok ( matches) => {
158+ let mut seen = std:: collections:: HashSet :: new ( ) ;
159+ let mut matched_values = std:: collections:: HashSet :: new ( ) ;
160+ let mut concepts: Vec < KgConcept > = matches
161+ . into_iter ( )
162+ . filter_map ( |matched| {
163+ matched_values. insert ( matched. normalized_term . value . clone ( ) ) ;
164+ if !seen. insert ( matched. term . clone ( ) ) {
165+ return None ;
166+ }
167+ Some ( KgConcept {
168+ id : 0 ,
169+ name : matched. term ,
170+ display_value : None ,
171+ score : 1.0 ,
172+ } )
173+ } )
174+ . take ( limit)
175+ . collect ( ) ;
176+
177+ for ( key, value) in thesaurus. clone ( ) . into_iter ( ) {
178+ if matched_values. contains ( & value. value ) && seen. insert ( key. to_string ( ) ) {
179+ concepts. push ( KgConcept {
180+ id : 0 ,
181+ name : key. to_string ( ) ,
182+ display_value : None ,
183+ score : 1.0 ,
184+ } ) ;
185+ }
186+ }
187+
188+ concepts. sort_by ( |a, b| a. name . cmp ( & b. name ) ) ;
189+ concepts. truncate ( limit) ;
190+ concepts
191+ }
192+ Err ( error) => {
193+ tracing:: debug!( "Thesaurus query automata match failed for {query:?}: {error}" ) ;
194+ Vec :: new ( )
195+ }
196+ }
197+ }
198+
134199/// Apply KG boost to a batch of chunks and sort by boosted score (descending).
135200/// Mutates `relevance_score` in place so downstream consumers can see the boost reflected
136201/// in the JSON output -- otherwise the ordering would be inexplicable.
@@ -196,25 +261,16 @@ impl HybridSearcher {
196261
197262 let ( kg_concepts, code_results) = match options. haystack {
198263 Haystack :: All | Haystack :: Code => {
199- let kg_handle = tokio:: spawn ( {
200- let query = query_owned. clone ( ) ;
201- let graph = role_graph. clone ( ) ;
202- let thes = thesaurus. clone ( ) ;
203- async move { Self :: search_kg ( & query, max_results, graph, & thes) . await }
204- } ) ;
205-
206- let code_handle = tokio:: spawn ( {
207- let query = query_owned. clone ( ) ;
208- let path = search_path. clone ( ) ;
209- async move { Self :: search_code ( & query, max_results, path) . await }
210- } ) ;
211-
212- let kg_concepts = kg_handle
213- . await
214- . map_err ( |e| format ! ( "KG search join error: {}" , e) ) ??;
215- let code_results = code_handle
216- . await
217- . map_err ( |e| format ! ( "Code search join error: {}" , e) ) ??;
264+ let kg_concepts =
265+ Self :: search_kg ( & query_owned, max_results, role_graph. clone ( ) , & thesaurus)
266+ . await ?;
267+ let candidate_limit = if kg_concepts. is_empty ( ) {
268+ max_results
269+ } else {
270+ max_results. saturating_mul ( 5 ) . max ( max_results) . min ( 1000 )
271+ } ;
272+ let code_results =
273+ Self :: search_code ( & query_owned, candidate_limit, search_path. clone ( ) ) . await ?;
218274 ( kg_concepts, code_results)
219275 }
220276 Haystack :: Docs => {
@@ -230,7 +286,8 @@ impl HybridSearcher {
230286 // currently uniform (1.0 per match), so without this step the user's knowledge
231287 // does not influence ordering at all. Boost in place; the boosted score is what
232288 // the JSON output reports so downstream tools see why a chunk ranked where it did.
233- let code_results = boost_chunks_with_kg ( code_results, & kg_concepts) ;
289+ let mut code_results = boost_chunks_with_kg ( code_results, & kg_concepts) ;
290+ code_results. truncate ( max_results) ;
234291
235292 Ok ( HybridResults {
236293 code_results,
@@ -265,31 +322,10 @@ impl HybridSearcher {
265322 }
266323
267324 // Fallback: rolegraph returned nothing (graph has no indexed documents yet, or no
268- // node matched the query). Fall back to thesaurus-only matching so KG boost still
269- // fires. Match the rolegraph's Aho-Corasick semantics by lowercasing both sides
270- // and scanning each thesaurus key for substring presence in the query.
271- let query_lower = query. to_lowercase ( ) ;
272- let mut concepts: Vec < KgConcept > = thesaurus
273- . keys ( )
274- . filter_map ( |key| {
275- let key_str = key. as_str ( ) ;
276- let key_lower = key_str. to_lowercase ( ) ;
277- if query_lower. contains ( & key_lower) || key_lower. contains ( & query_lower) {
278- Some ( KgConcept {
279- id : 0 ,
280- name : key_str. to_string ( ) ,
281- display_value : None ,
282- score : 1.0 ,
283- } )
284- } else {
285- None
286- }
287- } )
288- . take ( limit)
289- . collect ( ) ;
290- // Stable ordering for deterministic boost output across runs.
291- concepts. sort_by ( |a, b| a. name . cmp ( & b. name ) ) ;
292- Ok ( concepts)
325+ // node matched the query). Fall back to thesaurus-only matching through
326+ // `terraphim_automata`, preserving the same Aho-Corasick semantics as the rest of
327+ // Terraphim rather than using ad-hoc substring matching.
328+ Ok ( thesaurus_query_concepts ( query, thesaurus, limit) )
293329 }
294330
295331 async fn search_code (
@@ -494,6 +530,50 @@ mod tests {
494530 }
495531 }
496532
533+ fn test_thesaurus ( terms : & [ & str ] ) -> terraphim_types:: Thesaurus {
534+ let mut thesaurus = terraphim_types:: Thesaurus :: new ( "test" . to_string ( ) ) ;
535+ for ( idx, term) in terms. iter ( ) . enumerate ( ) {
536+ let key = terraphim_types:: NormalizedTermValue :: from ( * term) ;
537+ let normalised = terraphim_types:: NormalizedTerm :: new ( idx as u64 , key. clone ( ) ) ;
538+ thesaurus. insert ( key, normalised) ;
539+ }
540+ thesaurus
541+ }
542+
543+ #[ test]
544+ fn thesaurus_query_concepts_uses_automata_not_substring_expansion ( ) {
545+ let thesaurus = test_thesaurus ( & [ "auth" , "authorisation" , "authentication" ] ) ;
546+
547+ let concepts = thesaurus_query_concepts ( "auth" , & thesaurus, 10 ) ;
548+
549+ assert_eq ! ( concepts. len( ) , 1 ) ;
550+ assert_eq ! ( concepts[ 0 ] . name, "auth" ) ;
551+ }
552+
553+ #[ test]
554+ fn thesaurus_query_concepts_expands_shared_normalised_term ( ) {
555+ let mut thesaurus = terraphim_types:: Thesaurus :: new ( "test" . to_string ( ) ) ;
556+ let normalised = terraphim_types:: NormalizedTermValue :: from ( "auth" ) ;
557+ for ( idx, term) in [ "auth" , "authentication" , "authorisation" ]
558+ . iter ( )
559+ . enumerate ( )
560+ {
561+ let key = terraphim_types:: NormalizedTermValue :: from ( * term) ;
562+ thesaurus. insert (
563+ key,
564+ terraphim_types:: NormalizedTerm :: new ( idx as u64 , normalised. clone ( ) ) ,
565+ ) ;
566+ }
567+
568+ let concepts = thesaurus_query_concepts ( "auth" , & thesaurus, 10 ) ;
569+ let names = concepts
570+ . into_iter ( )
571+ . map ( |concept| concept. name )
572+ . collect :: < Vec < _ > > ( ) ;
573+
574+ assert_eq ! ( names, vec![ "auth" , "authentication" , "authorisation" ] ) ;
575+ }
576+
497577 #[ test]
498578 fn kg_boost_promotes_matching_chunks_to_top ( ) {
499579 // Two chunks with identical base scores. Only one mentions the KG concept in its
@@ -541,6 +621,41 @@ mod tests {
541621 ) ;
542622 }
543623
624+ #[ test]
625+ fn kg_boost_does_not_match_concept_embedded_in_larger_word ( ) {
626+ let author_only = chunk ( "docs/plan.md" , "**Author**: OpenCode" , 1.0 ) ;
627+ let concepts = vec ! [ concept( "auth" , 1.0 ) ] ;
628+
629+ let boost = score_kg_boost ( & author_only, & concepts, 1.0 ) ;
630+
631+ assert_eq ! ( boost, 0.0 , "auth must not match Author" ) ;
632+ }
633+
634+ #[ test]
635+ fn kg_boost_matches_concept_at_identifier_boundary ( ) {
636+ let auth_identifier = chunk ( "src/auth_middleware.rs" , "fn auth_middleware() {}" , 1.0 ) ;
637+ let concepts = vec ! [ concept( "auth" , 1.0 ) ] ;
638+
639+ let boost = score_kg_boost ( & auth_identifier, & concepts, 1.0 ) ;
640+
641+ assert ! ( boost > 0.0 , "auth should match auth_middleware" ) ;
642+ }
643+
644+ #[ test]
645+ fn kg_boost_keeps_author_only_chunk_below_real_auth_chunk ( ) {
646+ let chunks = vec ! [
647+ chunk( "docs/design.md" , "**Author**: OpenCode" , 1.0 ) ,
648+ chunk( "src/auth_middleware.rs" , "fn auth_middleware() {}" , 1.0 ) ,
649+ ] ;
650+ let concepts = vec ! [ concept( "auth" , 1.0 ) ] ;
651+
652+ let ranked = boost_chunks_with_kg ( chunks, & concepts) ;
653+
654+ assert_eq ! ( ranked[ 0 ] . source, "src/auth_middleware.rs" ) ;
655+ assert_eq ! ( ranked[ 1 ] . source, "docs/design.md" ) ;
656+ assert_eq ! ( ranked[ 1 ] . relevance_score, 1.0 ) ;
657+ }
658+
544659 #[ test]
545660 fn test_grep_options_default ( ) {
546661 let options = GrepOptions :: default ( ) ;
0 commit comments