fix(search): down-weight the project name in ranking — completes #720 (#748)

The per-word path fix (#745) brought the backend to parity but not above:
the project name still gave the lexically-matching stack a residual dir
match + an FTS class-name match, so a backend query that included the
project name still ranked the frontend at/above the backend.

Derive the project name from go.mod module / package.json name / repo dir,
and treat a query word matching it as non-discriminative: drop it from path
relevance and from codegraph_explore's PascalCase type-disambiguation bias
(reporter's suggestions #1/#2) — unless it's the only query word, so a bare
project-name search still scores.

Narrow by construction: the down-weighting fires ONLY when a query word
matches the derived project name (≥5 chars), so every query that doesn't
name the project is byte-identical. On the reporter's repro the backend
controllers now top a backend question that includes the project name;
queries without it, bare project-name queries, and normal symbol queries
are unchanged. Query-time only (no re-index).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-08 22:43:54 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent afec1282e1
commit 75ae1e8bd9
6 changed files with 154 additions and 10 deletions
+18 -1
View File
@@ -176,6 +176,12 @@ function rowToFileRecord(row: FileRow): FileRecord {
export class QueryBuilder {
private db: SqliteDatabase;
// Project-name tokens (go.mod / package.json / repo dir), normalized. A query
// word matching one is dropped from path-relevance scoring — it names the
// whole project, not a symbol, so it carries no discriminative signal (#720).
// Set once by the CodeGraph instance; empty by default (no down-weighting).
private projectNameTokens: Set<string> = new Set();
// Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
private nodeCache: Map<string, Node> = new Map();
private readonly maxCacheSize = 1000;
@@ -219,6 +225,17 @@ export class QueryBuilder {
this.db = db;
}
/** Set the normalized project-name tokens used to down-weight non-discriminative
* query words in path scoring (#720). Called once when the project opens. */
setProjectNameTokens(tokens: Set<string>): void {
this.projectNameTokens = tokens;
}
/** The normalized project-name tokens (#720); empty if none were derived. */
getProjectNameTokens(): Set<string> {
return this.projectNameTokens;
}
// ===========================================================================
// Node Operations
// ===========================================================================
@@ -842,7 +859,7 @@ export class QueryBuilder {
...r,
score: r.score
+ kindBonus(r.node.kind)
+ scorePathRelevance(r.node.filePath, scoringQuery)
+ scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens)
+ nameMatchBonus(r.node.name, scoringQuery),
}));
results.sort((a, b) => b.score - a.score);
+19
View File
@@ -49,6 +49,7 @@ import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
import { EXTRACTION_VERSION } from './extraction/extraction-version';
import { getCodeGraphDir } from './directory';
import { deriveProjectNameTokens } from './search/query-utils';
import { CodeGraphPackageVersion } from './mcp/version';
// Re-export types for consumers
@@ -154,6 +155,13 @@ export class CodeGraph {
this.db = db;
this.queries = queries;
this.projectRoot = projectRoot;
// Down-weight the project name as a query term in search ranking — it names
// the whole repo, not a symbol, so it has no discriminative value (#720).
try {
this.queries.setProjectNameTokens(deriveProjectNameTokens(projectRoot));
} catch {
// Best-effort: ranking still works without it.
}
this.fileLock = new FileLock(
path.join(getCodeGraphDir(projectRoot), 'codegraph.lock')
);
@@ -747,6 +755,17 @@ export class CodeGraph {
return this.queries.searchNodes(query, options);
}
/**
* Normalized project-name tokens (go.mod / package.json / repo dir) used to
* down-weight the non-discriminative project name in search ranking (#720).
* Exposed so explore can exclude it from the PascalCase type-disambiguation
* bias, which would otherwise pull overloaded tokens toward whichever stack
* embeds the project name.
*/
getProjectNameTokens(): Set<string> {
return this.queries.getProjectNameTokens();
}
/**
* Find the project's "primary route file" — the file with the densest
* concentration of framework-emitted `route` nodes (≥3 routes, ≥30%
+9 -3
View File
@@ -21,7 +21,7 @@ import {
} from '../sync/worktree';
import type { PendingFile } from '../sync';
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
import { isTestFile } from '../search/query-utils';
import { isTestFile, normalizeNameToken } from '../search/query-utils';
import {
existsSync,
readFileSync,
@@ -1661,8 +1661,14 @@ export class ToolHandler {
// agent writes "DataRequest task validate", the `task`/`validate` it wants
// are DataRequest's, NOT the same-named overloads in Validation.swift /
// Concurrency.swift / the abstract base. Used below to bias overloaded
// names toward the file/class the query also names.
const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o));
// names toward the file/class the query also names. EXCLUDE the project
// name (a PascalCase token a user naturally includes) — it names the whole
// repo, so biasing toward it just pulls overloads to whichever stack
// embeds it, re-burying the rest (#720).
const projectNameTokens = cg.getProjectNameTokens();
const typeTokens = tokens.filter(
(o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has(normalizeNameToken(o)),
);
const inNamedContext = (n: Node) =>
typeTokens.some((ct) => {
const lc = ct.toLowerCase();
+65 -4
View File
@@ -4,9 +4,55 @@
* Shared module for search term extraction and scoring.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Node } from '../types';
/** Normalize a name to a comparable token: lowercase, alphanumerics only. */
export function normalizeNameToken(raw: string): string {
return raw.toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* Tokens that name the PROJECT as a whole — its `go.mod` module, `package.json`
* name, or repo root directory — rather than any specific symbol. A user
* naturally puts the project name in a query as context ("MyApp backend
* routes"), but it carries no discriminative signal: when it's also a substring
* of a symbol or path on one stack (a `MyAppFrontend/` dir, a `MyAppApp` class)
* it lexically inflates that stack and buries the rest (#720).
*
* Returned normalized (lowercase, alphanumerics only) so a query word can be
* compared by its normalized form. Only names ≥5 chars are kept — short ones
* (`api`, `app`, `core`, `web`) collide with real query terms too often to
* safely down-weight.
*/
export function deriveProjectNameTokens(projectRoot: string): Set<string> {
const tokens = new Set<string>();
const add = (raw: string | undefined | null): void => {
if (!raw) return;
const norm = normalizeNameToken(raw);
if (norm.length >= 5) tokens.add(norm);
};
// go.mod module last segment (the most reliable signal for Go repos).
try {
const gomod = fs.readFileSync(path.join(projectRoot, 'go.mod'), 'utf-8');
const m = gomod.match(/^\s*module\s+(\S+)/m);
if (m && m[1]) add(m[1].split('/').pop());
} catch { /* no go.mod */ }
// package.json name (strip an `@scope/` prefix).
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
if (typeof pkg.name === 'string') add(pkg.name.replace(/^@[^/]+\//, ''));
} catch { /* no / invalid package.json */ }
// Repo root directory name — a fallback when neither manifest names the project.
add(path.basename(path.resolve(projectRoot)));
return tokens;
}
/**
* Common stop words to filter from search queries.
* Includes generic English + code-specific noise words.
@@ -172,7 +218,11 @@ export function extractSearchTerms(query: string, options?: { stems?: boolean })
* Score path relevance to a query
* Higher score = more relevant path
*/
export function scorePathRelevance(filePath: string, query: string): number {
export function scorePathRelevance(
filePath: string,
query: string,
projectNameTokens?: Set<string>,
): number {
const pathLower = filePath.toLowerCase();
const fileName = path.basename(filePath).toLowerCase();
const dirName = path.dirname(filePath).toLowerCase();
@@ -187,10 +237,21 @@ export function scorePathRelevance(filePath: string, query: string): number {
// 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;
const allWords = query.split(/\s+/).filter((w) => w.length > 0);
if (allWords.length === 0) return 0;
for (const word of words) {
// A query word that just names the PROJECT (its go.mod / package.json / repo
// name) carries no discriminative path signal — drop it so the rest of the
// query decides the ranking, instead of every file under a `<ProjectName>…/`
// tree winning on the project name alone (#720). Only when OTHER words remain,
// so a bare project-name query still scores on its path.
const words =
projectNameTokens && projectNameTokens.size > 0
? allWords.filter((w) => !projectNameTokens.has(normalizeNameToken(w)))
: allWords;
const scored = words.length > 0 ? words : allWords;
for (const word of scored) {
// 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 });