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
+26 -1
View File
@@ -16,7 +16,7 @@ import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { LOW_CONFIDENCE_MARKER } from '../src/context';
import { isDistinctiveIdentifier } from '../src/search/query-utils';
import { isDistinctiveIdentifier, scorePathRelevance } from '../src/search/query-utils';
describe('isDistinctiveIdentifier', () => {
it('treats plain dictionary words as non-distinctive', () => {
@@ -39,6 +39,31 @@ describe('isDistinctiveIdentifier', () => {
});
});
// A single PascalCase query word (notably a project name a user naturally
// includes) splits into sub-tokens that all match the SAME path segment; summed
// per sub-token it boosted that path 4×, burying the rest of the query's stack
// (#720). Path relevance must count each original WORD once per level, while
// still splitting it for cross-convention matching.
describe('scorePathRelevance per-word scoring (#720)', () => {
it('counts a single PascalCase word once per path level, not once per sub-token', () => {
// "SuperBizAgent" → super/biz/agent/superbizagent all hit the dir, but it's
// one concept: +5 (dir) once, not +20.
expect(scorePathRelevance('SuperBizAgentFrontend/app.js', 'SuperBizAgent')).toBe(5);
});
it('still splits a word so it matches across naming conventions', () => {
// getUserName must still match a snake_case path via its sub-tokens.
expect(scorePathRelevance('get_user_name.go', 'getUserName')).toBeGreaterThanOrEqual(10);
});
it('still credits distinct query words matching different path segments', () => {
// auth (dir) and handler (filename) are separate concepts — each counts.
expect(scorePathRelevance('src/auth/login_handler.go', 'auth handler')).toBeGreaterThan(
scorePathRelevance('src/auth/login_handler.go', 'auth')
);
});
});
describe('Context ranking — common-word precision & confidence', () => {
let testDir: string;
let cg: CodeGraph;