Skip to content

Commit b5800a5

Browse files
committed
fix(grep): rank KG matches above substring metadata
1 parent 725f10c commit b5800a5

1 file changed

Lines changed: 173 additions & 58 deletions

File tree

crates/terraphim_grep/src/hybrid_searcher.rs

Lines changed: 173 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)