From e41431abc21077c6c83cb677db39b514c767a299 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 6 Apr 2026 16:16:25 -0500 Subject: [PATCH] feat: Fix stem variant inflation in multi-term search boosting by grouping related terms Addresses cases where stem variants like "index", "indexed", "indexe" were counted as separate term matches, artificially inflating match counts and giving false multi-term boosts to symbols matching one root word multiple times. Groups terms that are substrings of each other before counting matches to ensure each conceptual term contributes only once to the boost calculation. --- src/context/index.ts | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index b2af1c4..eec16b2 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -505,6 +505,27 @@ export class ContextBuilder { // (matches "shard" + "search" + "request"). const queryTermsForBoost = extractSearchTerms(query); if (queryTermsForBoost.length >= 2) { + // Group terms that are substrings of each other (stem variants of the same + // root word). "indexed", "indexe", "index" should count as ONE concept match, + // not three. Without this, stem variants inflate matchCount and give false + // multi-term boosts to symbols matching one root word multiple times. + const termGroups: string[][] = []; + const sorted = [...queryTermsForBoost].sort((a, b) => b.length - a.length); + const assigned = new Set(); + for (const term of sorted) { + if (assigned.has(term)) continue; + const group = [term]; + assigned.add(term); + for (const other of sorted) { + if (assigned.has(other)) continue; + if (term.includes(other) || other.includes(term)) { + group.push(other); + assigned.add(other); + } + } + termGroups.push(group); + } + for (const result of searchResults) { // Check term matches in name (substring) and path DIRECTORIES (exact). // Directory segments must match exactly — "search" matches directory @@ -513,10 +534,13 @@ export class ContextBuilder { const nameLower = result.node.name.toLowerCase(); const dirSegments = path.dirname(result.node.filePath).toLowerCase().split('/'); let matchCount = 0; - for (const term of queryTermsForBoost) { - const inName = nameLower.includes(term); - const inDir = dirSegments.some(seg => seg === term); - if (inName || inDir) matchCount++; + for (const group of termGroups) { + const groupMatches = group.some(term => { + const inName = nameLower.includes(term); + const inDir = dirSegments.some(seg => seg === term); + return inName || inDir; + }); + if (groupMatches) matchCount++; } if (matchCount >= 2) { // Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x