perf(search): seek the name index for exact-name lookups - #1542
Open
maxmilian wants to merge 1 commit into
Open
Conversation
`nodes` carries two name indexes and neither can serve
`WHERE name = ? COLLATE NOCASE`: `idx_nodes_name` is BINARY-collated, and
`idx_nodes_lower_name` is an expression index the planner only matches against
the same expression. All three whole-name lookups in the query layer were
written that way, so each one degraded to a full table scan
(`EXPLAIN QUERY PLAN` reports `SCAN nodes`).
The LIMITs on those queries do not rescue them. SQLite can only stop early once
it has produced LIMIT rows, and the two dominant cases never get there: a query
word that names no symbol at all, and a name with only a handful of definitions.
`searchNodes` runs its supplement once per query term; `findNodesByExactName`
runs two passes per symbol extracted from the question, and extraction is
generous, so a plainly-worded question issues a dozen full scans.
Written as `lower(name) = lower(?)` the same predicate seeks
`idx_nodes_lower_name`. Measured on four indexed repositories, baseline vs fix
in one process (the only difference being how the predicate is spelled):
query "how does the retry backoff work" findNodesByExactName searchNodes
gin (2.5k nodes) 1.27ms -> 0.18ms 3.1 -> 2.6ms
Alamofire (4.5k nodes) 2.39ms -> 0.22ms 4.9 -> 4.0ms
excalidraw (11k nodes) 10.54ms -> 0.17ms 10.4 -> 5.8ms
django (62k nodes) 49.91ms -> 0.17ms 27.6 -> 4.9ms
The seek is flat across all four; the scan grows with the corpus. A one-word
query into `searchNodes` on django is unchanged (~20ms) because a single term's
scan is not what dominates it there.
Lowering the parameter in SQL rather than in JavaScript is deliberate. SQLite's
`lower()` and NOCASE both fold ASCII only, while JavaScript's `.toLowerCase()`
folds Unicode; comparing a JS-lowered parameter against `lower(name)` would
silently stop matching non-ASCII identifiers that NOCASE used to match.
`getNodesByLowerName` is spelled the same way for the same reason. It already
sought the index, but as a bare `lower(name) = ?` it took a pre-lowered
parameter on trust: any input carrying an uppercase letter returned nothing at
all. This is behaviour-neutral for its one caller — `matchFuzzy` lowers in
JavaScript before calling, and `lower()` over an already-lowered string is a
no-op, verified over the ASCII and non-ASCII cases alike. It closes the trap for
the next caller; the non-ASCII gap on the `matchFuzzy` side is a resolution
change and is deliberately not bundled here.
Result sets are unchanged, including which rows the LIMITs keep: entries under
one key in the expression index are ordered by rowid, the same order a table
scan produces. Verified over 14,400 lookups (top-400 names of the four
corpora, probed as stored / upper / lower, against all three call sites) with
zero differences, and end-to-end above with identical result ids.
Tests assert the planner's verdict rather than a wall-clock number, so they are
deterministic: they intercept the SQL each call site prepares and require an
index seek, with a guard that the lookups actually ran. Reverting any call site
turns them red.
maxmilian
marked this pull request as ready for review
August 12, 2026 11:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every whole-name lookup in the query layer was written as
WHERE name = ? COLLATE NOCASE, which no index onnodescan serve, so each one degraded to a full table scan. Written aslower(name) = lower(?)the same predicate seeksidx_nodes_lower_name. Results are unchanged — verified, not assumed; see below.nodeshas two name indexes and neither one matches the NOCASE spelling:idx_nodes_nameis BINARY-collated, so NOCASE equality can't use it;idx_nodes_lower_nameis an expression index onlower(name), and the planner only matches it against the same expression.The
LIMITs do not rescue these queries. SQLite can only stop early once it has producedLIMITrows, and the two dominant cases never get there: a query word that names no symbol at all, and a name with only a handful of definitions.searchNodesruns its supplement once per query term;findNodesByExactNameruns two passes per symbol extracted from the question, and extraction is generous — so a plainly-worded question issues a dozen full scans before it returns anything.Query plans
EXPLAIN QUERY PLAN, on an indexed checkout ofdjango(62,080 nodes):searchNodesexact-name supplement (… WHERE name = ? COLLATE NOCASE LIMIT 20)SCAN nodesSEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)findNodesByExactNamepass 1 (SELECT DISTINCT file_path … LIMIT 100)SCAN nodes USING INDEX idx_nodes_file_pathSEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)findNodesByExactNamepass 2 (… LIMIT 50)SCAN nodesSEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)getNodesByLowerNameSEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)Measurements
Baseline vs fix in a single process, against four already-indexed repositories. The only difference between the two arms is how the predicate is spelled: the baseline arm wraps the db handle and rewrites
lower(name) = lower(?)back toname = ? COLLATE NOCASEbefore preparing, so identical JavaScript runs on both sides. Every pair was checked to return identical result ids.Query
how does the retry backoff work(6 extracted names):findNodesByExactNamesearchNodesQuery
where is UserService registered with the DI container(8 extracted names), django:findNodesByExactName66.25ms → 0.22ms (300×),searchNodes44.36ms → 14.10ms (3.1×).Where it makes no difference: a single-word query into
searchNodeson django is 20.31ms → 20.07ms (1.0×). One term means one scan, and that is not what dominates that path — the FTS and fuzzy work around it is. The win is in the multi-term and multi-symbol shapes, which is what a question in prose produces, and it grows with the corpus while the seek stays flat.Why
lower(?)and not a JavaScript.toLowerCase()This is the part worth a second look, because getting it wrong fails silently.
SQLite's
lower()and itsNOCASEcollation both fold ASCII only. JavaScript's.toLowerCase()folds Unicode. So lowering the parameter in JavaScript and comparing it againstlower(name)puts the two sides on different rules, and non-ASCII identifiers stop matching with no error:name = ? COLLATE NOCASElower(name) = lower(?)lower(name) = ?with a JS-lowered paramÜnïcodeÜnïcodeÜnïcodeÜnïcodeünïcode← different rowÜnïcodeÜNÏCODEÜNÏCODEÜNÏCODEünïcode← different rowAsciiNameASCIINAMEAsciiName,ASCIINAMEAsciiName,ASCIINAMEAsciiName,ASCIINAMELowering the parameter in SQL keeps both sides on SQLite's rules, which is exactly what NOCASE did.
getNodesByLowerNameis spelled the same way for the same reason, though the note above says its plan is unchanged — it already sought the index. What it did not do was defend its own contract: as a barelower(name) = ?it took a pre-lowered parameter on trust, so any input carrying an uppercase letter returned nothing at all. This is behaviour-neutral for its one caller today (matchFuzzylowers in JavaScript first, andlower()over an already-lowered string is a no-op — checked over the ASCII and non-ASCII cases alike); it closes the trap for the next caller. The non-ASCII gap on thematchFuzzyside is a resolution-layer behaviour change and is deliberately not bundled into this PR.Result-set equivalence
Not assumed — the
LIMITs make it a real question, since a scan and a seek could in principle keep different rows.LIMITs keep the same rows.Tests
__tests__/name-lookup-index.test.ts, 5 cases:searchNodes issues its exact-name supplement as an index seekfindNodesByExactName issues both of its passes as index seeksgetNodesByLowerName seeks the index and does not depend on the caller loweringstill matches case-insensitively across both call sitesfolds exactly what COLLATE NOCASE folded — ASCII onlyThe first three assert the planner's verdict, not a wall-clock number — they intercept the SQL each call site actually prepares, run
EXPLAIN QUERY PLANon it, and require an index seek. That makes them deterministic on any machine and immune to a fast laptop hiding a regression, and it can't drift from the code, since the SQL under test is whatever the call site prepared. Each has a guard asserting the lookups actually ran, so a refactor that stops issuing them can't pass vacuously.Mutation-checked: reverting any one of the four call sites to its old spelling turns the suite red (
expected 'SCAN nodes' to match /SEARCH nodes USING .*idx_nodes_lower_name/, andexpected [] to deeply equal [ 'hr-1', 'hr-2' ]for the fourth).tsc --noEmitclean. Full suite 2906 passed / 0 failed / 178 skipped.Scope
Deliberately not included:
matchFuzzycaller-side lowering. Making it stop lowering in JavaScript would change which nodes the fuzzy resolver matches for non-ASCII identifiers — a resolution behaviour change with its own evidence to gather, not a query-plan change.name LIKE ?lookups (findNodesByNameSubstringand friends). A leading-wildcardLIKEcan't use an index at all; that needs a different remedy, not a different spelling.