Summary
Follow-up to #746 (and #720 / #745). Those landed the project-name token down-weighting. #746 also floated a more general idea:
discount a token that matches a large fraction of indexed paths/symbols (it carries ~no signal but currently dominates).
That corpus-frequency discount is still not implemented on main (1.1.0). As a result, a generic non-stopword query token that happens to exactly match a symbol name (e.g. a bare usage) wins a large nameMatchBonus and crowds out the on-target product code — both in codegraph query / codegraph_search (no dampening at all) and, to a lesser but still user-visible degree, in codegraph_explore (where an existing dampening fires but is insufficient).
What actually happens today (corrected root cause)
There are two scoring paths, and they behave differently — the original write-up conflated them:
-
codegraph query / codegraph_search (db/queries.ts searchNodes): score = bm25 + kindBonus + scorePathRelevance + nameMatchBonus. The only low-signal-token handling is dropping the project name (scorePathRelevance, via deriveProjectNameTokens). There is no corpus-frequency / generic-token discount, so a bare usage symbol ranks at the very top.
-
codegraph_explore (context/index.ts findRelevantContext): this path does already demote generic exact-name matches — isDistinctiveIdentifier(token) is false for a plain lowercase word like usage, so a common-word exact match with no 2nd corroborating term gets score *= 0.3 (and a generic single-term match gets *= 0.6). This demotion fires for the reported case — and is still not enough, because nameMatchBonus hands a bare exact-name match a score so high that 0.3× of it still beats product symbols that don't literally contain the token.
So the accurate statement is not "generic-token downweighting isn't done" — it's "the existing demotion is a binary heuristic (distinctive-vs-common + 2-term corroboration) that can't counter a large nameMatchBonus, and the plain query/search path has no such demotion at all." The #746 general corpus-frequency discount is what's missing.
The lever (nameMatchBonus, src/search/query-utils.ts)
For a query token that exactly equals a symbol name:
- whole query (whitespace-stripped)
=== name → 80
- in a multi-word query, a token
=== name → 60 ← this is the case here (the earlier draft said "80")
STOP_WORDS deliberately doesn't filter common symbol names (get/set/usage/update/status), so these tokens keep full weight even when they match thousands of symbols. matchesNonProductionDir's de-prioritized set is hardcoded (integration/sample/example/fixture/benchmark/demo) and not extensible, so a peripheral tree like optional-skills/.../scripts/ gets no de-prioritization.
Minimal reproduction (public, no private repo needed)
Layout — product code with no symbol literally named usage, plus two standalone helpers that each have a module-level def usage():
apps/desktop/statusbar/StatusBar.ts class DesktopStatusBar { render, refresh, mount }
apps/desktop/statusbar/StatusBarController.ts class StatusBarController
apps/desktop/context/ContextWindowMeter.ts class ContextWindowMeter { read, recompute }, estimateTokens
apps/desktop/context/format.ts formatTokens
gateway/server/server.ts, packages/core/util/strings.ts (filler)
optional-skills/bodyfat/scripts/body_calc.py def usage(): print("usage: ...")
optional-skills/nutrition/scripts/macro_calc.py def usage(): print("usage: ...")
codegraph init + codegraph index, then query desktop status bar context window usage:
| Run |
Entry |
Where the usage() helpers land |
A. codegraph query (no dampening) |
searchNodes |
ranks 1 and 2 (≈7626% vs product's top ≈5104%) |
B. codegraph explore (0.3× demotion active) |
findRelevantContext |
top two Source Code blocks — push StatusBarController.ts + format.ts out of the section |
C. control: same query with usage→tokens |
codegraph explore |
helpers don't appear; 4 product files fill the section |
D. causal probe: rename usage()→print_usage(), re-index |
codegraph explore |
helpers vanish entirely; product code reclaims every slot |
D isolates the cause to a single signal: removing the exact token match (so the 60-point nameMatchBonus no longer applies) removes the symptom. The existing explore-path demotion was active the whole time and didn't prevent B.
Why the existing user-side levers don't help
Proposal
Primary (the #746 follow-up — the part that's genuinely missing):
- Add a corpus-frequency / IDF-style discount so a query token matching a large fraction of indexed symbols/paths contributes proportionally less to
nameMatchBonus (and scorePathRelevance). This lowers the base an exact-name match starts from, which the explore path's existing 0.3× demotion then multiplies — fixing the "0.3× of a huge number is still huge" gap, and also fixing the query/search path that has no demotion at all.
Complementary / independently shippable, lower risk — keep these two distinct (they have different semantics):
- Deprioritize / scope (a ranking lever): make the non-production-dir de-prioritization user-extensible via
codegraph.json (e.g. deprioritize: ["optional-skills/", "scripts/"]), and/or add a --within <glob> / --exclude <glob> scope to explore / query + the MCP input schema.
- Exclude (an index/recall lever) is a bigger product decision — name it separately if pursued; it's not required to fix the ranking symptom above.
Validation caveat
This touches the scorer for every query, so per codegraph/CLAUDE.md and the note in #746 it must be A/B'd with scripts/agent-eval/run-all.sh across the README repos plus a control before merge. Edge case to preserve: a query where the "generic" token genuinely IS the discriminating term (an actual query about a usage() reporter) — IDF should discount, not erase, the signal.
Summary
Follow-up to #746 (and #720 / #745). Those landed the project-name token down-weighting. #746 also floated a more general idea:
That corpus-frequency discount is still not implemented on
main(1.1.0). As a result, a generic non-stopword query token that happens to exactly match a symbol name (e.g. a bareusage) wins a largenameMatchBonusand crowds out the on-target product code — both incodegraph query/codegraph_search(no dampening at all) and, to a lesser but still user-visible degree, incodegraph_explore(where an existing dampening fires but is insufficient).What actually happens today (corrected root cause)
There are two scoring paths, and they behave differently — the original write-up conflated them:
codegraph query/codegraph_search(db/queries.ts searchNodes): score =bm25 + kindBonus + scorePathRelevance + nameMatchBonus. The only low-signal-token handling is dropping the project name (scorePathRelevance, viaderiveProjectNameTokens). There is no corpus-frequency / generic-token discount, so a bareusagesymbol ranks at the very top.codegraph_explore(context/index.ts findRelevantContext): this path does already demote generic exact-name matches —isDistinctiveIdentifier(token)is false for a plain lowercase word likeusage, so a common-word exact match with no 2nd corroborating term getsscore *= 0.3(and a generic single-term match gets*= 0.6). This demotion fires for the reported case — and is still not enough, becausenameMatchBonushands a bare exact-name match a score so high that 0.3× of it still beats product symbols that don't literally contain the token.So the accurate statement is not "generic-token downweighting isn't done" — it's "the existing demotion is a binary heuristic (distinctive-vs-common + 2-term corroboration) that can't counter a large
nameMatchBonus, and the plain query/search path has no such demotion at all." The #746 general corpus-frequency discount is what's missing.The lever (
nameMatchBonus,src/search/query-utils.ts)For a query token that exactly equals a symbol name:
=== name→ 80=== name→ 60 ← this is the case here (the earlier draft said "80")STOP_WORDSdeliberately doesn't filter common symbol names (get/set/usage/update/status), so these tokens keep full weight even when they match thousands of symbols.matchesNonProductionDir's de-prioritized set is hardcoded (integration/sample/example/fixture/benchmark/demo) and not extensible, so a peripheral tree likeoptional-skills/.../scripts/gets no de-prioritization.Minimal reproduction (public, no private repo needed)
Layout — product code with no symbol literally named
usage, plus two standalone helpers that each have a module-leveldef usage():codegraph init+codegraph index, then querydesktop status bar context window usage:usage()helpers landcodegraph query(no dampening)searchNodescodegraph explore(0.3× demotion active)findRelevantContextStatusBarController.ts+format.tsout of the sectionusage→tokenscodegraph exploreusage()→print_usage(), re-indexcodegraph exploreD isolates the cause to a single signal: removing the exact token match (so the 60-point
nameMatchBonusno longer applies) removes the symptom. The existing explore-path demotion was active the whole time and didn't prevent B.Why the existing user-side levers don't help
codegraph_explore/ CLIexploreaccept onlyquery,maxFiles,projectPath.projectPathselects which index — there's no sub-path / glob scope ("onlyapps/desktop", "don't rankoptional-skills/").codegraph.json(ProjectConfig) supports onlyextensionsandincludeIgnored. Noexclude;includeIgnoredis the opposite lever..gitignoredoesn't apply — the peripheral tree is intentionally tracked content, so the Bug: Committedvendor/directory is indexed in Go project despite .gitignore, resulting in bloated database (v0.9.2) #316 / v0.9.4 indexes node_modules in non-git projects, causing noisy context results #407 / [BUG] Currently, it is impossible to turn off the indexing of nested repositories #976 "don't index gitignored dirs" line doesn't cover it.Proposal
Primary (the #746 follow-up — the part that's genuinely missing):
nameMatchBonus(andscorePathRelevance). This lowers the base an exact-name match starts from, which the explore path's existing 0.3× demotion then multiplies — fixing the "0.3× of a huge number is still huge" gap, and also fixing the query/search path that has no demotion at all.Complementary / independently shippable, lower risk — keep these two distinct (they have different semantics):
codegraph.json(e.g.deprioritize: ["optional-skills/", "scripts/"]), and/or add a--within <glob>/--exclude <glob>scope toexplore/query+ the MCP input schema.Validation caveat
This touches the scorer for every query, so per
codegraph/CLAUDE.mdand the note in #746 it must be A/B'd withscripts/agent-eval/run-all.shacross the README repos plus a control before merge. Edge case to preserve: a query where the "generic" token genuinely IS the discriminating term (an actual query about ausage()reporter) — IDF should discount, not erase, the signal.