fix(search): score path relevance per query word, not per sub-token (#720) (#745)

A multi-word PascalCase query token — typically a project name a user
includes (`SuperBizAgent backend routes`) — splits into sub-tokens
(superbizagent / super / biz / agent) that ALL match the same path segment,
so path relevance summed +5 four times for one concept. In a mixed-stack
repo that ~doubled every score of the lexically-matching stack's file,
burying the stack the query was about.

Score path relevance per original query WORD instead: a word matches a path
level if any of its sub-tokens do, and counts once — while still splitting
the word (via extractSearchTerms on the original case) so it matches across
naming conventions (`getUserName` → `get_user_name`). Distinct words each
still contribute.

Partial fix: this removes the dominant path over-counting (backend rises
from absent-in-top-6 to parity on the reporter's repro). The residual lexical
edge from the project name in the FTS class-name match + dir match is a deeper
down-weighting change, tracked separately. No re-index needed (query-time).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-08 22:23:42 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5b3f5e36db
commit afec1282e1
3 changed files with 47 additions and 10 deletions
+20 -9
View File
@@ -173,23 +173,34 @@ export function extractSearchTerms(query: string, options?: { stems?: boolean })
* Higher score = more relevant path
*/
export function scorePathRelevance(filePath: string, query: string): number {
// Use base terms only — stem variants inflate path scores by generating
// many near-duplicate terms that all match the same path segments.
const terms = extractSearchTerms(query, { stems: false });
if (terms.length === 0) return 0;
const pathLower = filePath.toLowerCase();
const fileName = path.basename(filePath).toLowerCase();
const dirName = path.dirname(filePath).toLowerCase();
let score = 0;
for (const term of terms) {
// Score per original query WORD, not per sub-token. A single PascalCase word
// splits into many sub-tokens (a project name "SuperBizAgent" →
// superbizagent / super / biz / agent) that all match the SAME path segment,
// so summing per sub-token boosted that path 4× for one concept — enough to
// bury the rest of the query's stack (#720). A word matches a path level if
// ANY of its sub-tokens do, and counts ONCE; distinct words still each add.
// Split the ORIGINAL-case query into words; extractSearchTerms does the
// camelCase/snake split per word (so `getUserName` still matches a
// `get_user_name` path) — we just attribute each word's matches once.
const words = query.split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return 0;
for (const word of words) {
// Use base terms only — stem variants inflate path scores by generating
// many near-duplicate terms that all match the same path segments.
const subtokens = extractSearchTerms(word, { stems: false });
if (subtokens.length === 0) continue;
// Exact filename match (strongest)
if (fileName.includes(term)) score += 10;
if (subtokens.some((t) => fileName.includes(t))) score += 10;
// Directory match
if (dirName.includes(term)) score += 5;
if (subtokens.some((t) => dirName.includes(t))) score += 5;
// General path match
else if (pathLower.includes(term)) score += 3;
else if (subtokens.some((t) => pathLower.includes(t))) score += 3;
}
// Deprioritize test files unless the query is explicitly about tests