Skip to content

fix(search): path: and name: filters find their matches at any limit - #3018

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fix-search-filters-before-limit
Oct 29, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fix-search-filters-before-limit

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

A search with a path: or name: filter, in codegraph query, codegraph_search or the library's searchNodes(), now finds the symbols it asks for at any limit, and an offset pages through those symbols alone.

Reimplements #2254 by @rudycelekli, who found the bug.

Root cause

QueryBuilder.searchNodes applied path: and name: only to its final list, after every candidate source had already cut its rows to the limit:

  • full-text search to max(limit*5, 100) rows;
  • the substring fallback to limit rows;
  • the typo fallback to 50 names;
  • a filter-only search to the first limit*5 symbols in name order;
  • the exact-name supplement to 20 rows.

Whenever enough symbols outside the filters ranked first, nothing the filters asked for was left. codegraph query "authenticate path:product/" found nothing at limits 1, 2 and 10, and found the match at 200. That broke the query parser's contract (query-parser.ts): filters narrow the set, and the search ranks within it.

Fix

  • Every source applies the filters before its own limit and offset. With filters, its statement is streamed and JavaScript decides each row, so matching keeps toLowerCase's Unicode folding and literal substrings. SQLite's lower() and LIKE fold ASCII only, and LIKE would read % and _ as wildcards. A page and its offset count only the rows that pass, and the read stops once the page is full.
  • To spare JavaScript the rows that cannot pass, each filtered statement also carries a SQL pre-filter that keeps every row the filters could pass. A printable ASCII value is tested with instr(lower(col), ?), which agrees exactly with JavaScript on printable ASCII text. Text holding any other character passes through to JavaScript, and that is the only text a value with another character can match.
  • The typo fallback looks names up until it has its results, instead of giving up after 50 names that the filters may all reject, and it skips without a lookup any name that name: rules out.
  • A filter that no symbol's name, or no symbol's file, passes returns nothing at once. This is checked against the memoized name list the fallbacks already use, plus a memoized list of file paths.
  • The old final filter stays as a safety net, now before the cut instead of after it.
  • A search without these filters runs exactly the SQL it ran before, with one exception: a filter-only search (kind:function) now honours its offset. It returns the same rows at offset 0, which every caller in the codebase passes.

Validation

  • Kit repro through codegraph query --json on the PR's fixture: on main all 18 filtered queries (6 queries at limits 1, 2 and 10) returned nothing; with the fix each returns its match. The unfiltered authenticate query returns the same three symbols on both.

  • 9 new tests in __tests__/search-field-filters.test.ts: limits 1, 2 and 10 through each source, offset paging, non-ASCII folding (including the Kelvin sign, whose lowercase is an ASCII k), literal % and _, repeated filters, filters nothing passes, an unfiltered search, and codegraph_search at limit 1. 7 fail on main's source. Mutations caught: each of the five sources left unfiltered, the offset ignored, the non-ASCII pass-through removed or kept only for non-ASCII values, and the JavaScript check removed.

  • Latency A/B, main vs fix in one process on one index, 5 interleaved rounds, each the median of 5 calls. The 45 unfiltered queries are every search term at explore's kinds and limit 16, and at limit 10, plus three multi-word and two kind-only searches. A whole vscode index needs about 10 GB of RAM, which this shared machine can't spare, so the large index is a 10× replica of the real one: every symbol copied under copyN/ paths, with full-text search indexing the copies.

    index unfiltered: same ids and scores unfiltered total, main → fix per-query ratio filtered total, main → fix
    vscode src/vs/base + src/vs/editor (56,406 nodes) 45 / 45 89.7 → 88.4 ms 0.91 – 1.07 52.3 → 62.1 ms
    10× replica (564,060 nodes) 45 / 45 435.7 → 441.1 ms 0.82 – 1.11 434.5 → 625.1 ms

    Filtered queries on the replica (results in parentheses; main's are what was left after its final filter):

    query (limit 10) main fix
    model path:src/vs/editor/contrib/ 19.6 ms (1) 25.7 ms (10)
    getValue path:src/vs/base/common/ 1.8 ms (0) 2.2 ms (10)
    model name:text 18.4 ms (0) 15.9 ms (10)
    getValeu path:src/vs/editor/contrib/ (typo fallback) 1.3 ms (0) 1.4 ms (5)
    odelDecor path:src/vs/editor/contrib/ (substring fallback) 161.7 ms (0) 158.1 ms (10)
    kind:class path:src/vs/editor/contrib/ 9.3 ms (0) 18.7 ms (10)
    kind:method path:src/vs/base/common/ 67.0 ms (0) 116.1 ms (10)
    dispose name:widget (nothing matches both) 6.0 ms (0) 155.7 ms (0)
    editor path:nonexistent/ 42.2 ms (0) 0.3 ms (0)
    path:nonexistent/ 0.5 ms (0) 0.2 ms (0)
  • The real CLI on the real vscode index: codegraph query "dispose path:src/vs/editor/contrib/" --limit 3, "getValue path:src/vs/base/common/" and "kind:class name:widget path:src/vs/editor/browser/" return nothing on main. With the fix they return dispose methods in editor/contrib/, getValue in base/common/, and CodeEditorWidget, DiffEditorWidget and EmbeddedCodeEditorWidget. codegraph explore prints byte-identical answers on main and with the fix for 5 queries, at the same speed (1.2–1.4 s per call, mostly CLI start-up).

  • codegraph_explore never sends these filters. It searches with words split from the query at non-alphanumeric characters, and looks symbols up by identifier-shaped tokens, which can't hold a single : or a /. Either way a path: or name: token never arrives intact.

  • Rebased onto main da1f263, which brings in fix(rust): use bindings follow Rust's scopes, and module paths walk the crate's modules #3014 (Rust resolution) and fix(index): a repeat full index keeps the name words of files it didn't change #3017 (the name-word vocabulary fix, which also edits src/db/queries.ts, in other functions). The rebase was clean and the diff is unchanged (same patch-id). The measurements above were taken on the first base, e7c9ffd. This PR's tests and fix(index): a repeat full index keeps the name words of files it didn't change #3017's pass together on the rebased code.

  • Full suite on this exact commit (900f53d, on main da1f263), native kernel built: 785 files, 9,295 tests passed, 0 failed, 85 skipped (--maxWorkers=2, no reruns).

Left out

  • A filter-only path: search that matches some files but few symbols walks the name index to fill its page: about 0.2–0.3 s on the 564k-node replica for one small directory. A substring test can't use an index. Reading matches in path order instead would make the common broad filter, which today stops after a few rows, sort every match.
  • The offset still applies before re-ranking, as it did before for unfiltered searches.

🤖 Generated with Claude Code

@colbymchenry
colbymchenry force-pushed the claude/fix-search-filters-before-limit branch from 5714a44 to 3aded9b Compare October 29, 2026 18:42
QueryBuilder.searchNodes applied `path:` and `name:` only to its final
list, after every candidate source had cut its rows to the limit: full-text
search to max(limit*5, 100) rows, the substring fallback to `limit`, the
typo fallback to 50 names, a filter-only search to the first limit*5 names
in name order, and the exact-name supplement to 20 rows. Whenever enough
symbols outside the filters ranked first, nothing the filters asked for was
left, so `codegraph query "authenticate path:product/"` found nothing at
limits 1, 2 and 10, and found the match at 200. That broke the query
parser's contract: filters narrow the set and the search ranks within it.

Every source now applies the filters before its own limit and offset. Its
statement is streamed and JavaScript decides each row, so matching keeps
toLowerCase's Unicode folding and literal substrings (SQLite's lower() and
LIKE fold ASCII only, and LIKE reads % and _ as wildcards). A page and its
offset count only the rows that pass, and the read stops once the page is
full. To spare JavaScript the rows that cannot pass, each statement also
carries a SQL pre-filter that keeps every row the filters could pass:
instr(lower(...)) for a printable ASCII value, which agrees exactly with
JavaScript on printable ASCII text, while text with any other character
passes through to JavaScript. The typo fallback looks names up until it
has its results instead of giving up after 50 names, and skips names that
`name:` rules out. A filter that no symbol's name, or no symbol's file,
passes returns nothing at once, checked against the memoized name list and
a memoized list of file paths. The old final filter stays as a safety net,
now before the cut. A search without these filters runs the same SQL as
before, except that a filter-only search now honours its offset (the same
rows at offset 0, which every caller in the codebase passes).

Validation:
- Kit repro through `codegraph query --json`: on main all 18 filtered
  queries (6 queries at limits 1, 2 and 10) returned nothing; with the fix
  each returns its match.
- 9 new tests in search-field-filters.test.ts; 7 fail on main's source.
  Mutations caught: each of the five candidate sources left unfiltered, the
  offset ignored, the non-ASCII pass-through removed or kept only for
  non-ASCII values, and the JavaScript check removed.
- Latency A/B, main vs fix in one process on one index, 5 interleaved
  rounds of the median of 5 calls: vscode src/vs/base + src/vs/editor
  (56,406 nodes) and a 10x replica of it (564,060 nodes). The 45 unfiltered
  queries (explore's kinds at limit 16, plain limit 10) return identical
  ids and scores: 89.7 vs 88.4 ms and 435.7 vs 441.1 ms in total. On the
  replica the filtered queries now find their matches in 0.2-26 ms; the
  slowest are a substring fallback that finds nothing (156 ms, what an
  unfiltered miss already costs) and `kind:method path:...` (116 ms). A
  filter nothing passes answers in 0.2 ms instead of reading every row.

Reimplements PR #2254 by @rudycelekli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry force-pushed the claude/fix-search-filters-before-limit branch from 3aded9b to 900f53d Compare October 29, 2026 19:08
@colbymchenry
colbymchenry merged commit 57dc7ae into main Oct 29, 2026
@colbymchenry
colbymchenry deleted the claude/fix-search-filters-before-limit branch October 29, 2026 19:48
inth3shadows added a commit to inth3shadows/codegraph that referenced this pull request Oct 29, 2026
Brings 4955eac..787eafb (colbymchenry#3014, colbymchenry#3017, colbymchenry#3020, colbymchenry#3018, colbymchenry#3023). Clean
auto-merge. README: drop colbymchenry#2871 (closed upstream; colbymchenry#2934 merged in its
place, and name-matcher already matches upstream).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant