Skip to content

perf(search): seek the name index for exact-name lookups - #1542

Open
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:perf/name-lookup-index-seek
Open

perf(search): seek the name index for exact-name lookups#1542
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:perf/name-lookup-index-seek

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Summary

Every whole-name lookup in the query layer was written as WHERE name = ? COLLATE NOCASE, which no index on nodes can serve, so each one degraded to a full table scan. Written as lower(name) = lower(?) the same predicate seeks idx_nodes_lower_name. Results are unchanged — verified, not assumed; see below.

nodes has two name indexes and neither one matches the NOCASE spelling:

  • idx_nodes_name is BINARY-collated, so NOCASE equality can't use it;
  • idx_nodes_lower_name is an expression index on lower(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 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 before it returns anything.

Query plans

EXPLAIN QUERY PLAN, on an indexed checkout of django (62,080 nodes):

call site before after
searchNodes exact-name supplement (… WHERE name = ? COLLATE NOCASE LIMIT 20) SCAN nodes SEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)
findNodesByExactName pass 1 (SELECT DISTINCT file_path … LIMIT 100) SCAN nodes USING INDEX idx_nodes_file_path SEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)
findNodesByExactName pass 2 (… LIMIT 50) SCAN nodes SEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?)
getNodesByLowerName SEARCH nodes USING INDEX idx_nodes_lower_name (<expr>=?) unchanged — see the note below

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 to name = ? COLLATE NOCASE before 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):

corpus findNodesByExactName searchNodes
gin (2.5k nodes) 1.27ms → 0.18ms (6.9×) 3.09ms → 2.59ms (1.2×)
Alamofire (4.5k nodes) 2.39ms → 0.22ms (11.1×) 4.92ms → 3.96ms (1.2×)
excalidraw (11k nodes) 10.54ms → 0.17ms (61×) 10.42ms → 5.80ms (1.8×)
django (62k nodes) 49.91ms → 0.17ms (303×) 27.62ms → 4.93ms (5.6×)

Query where is UserService registered with the DI container (8 extracted names), django: findNodesByExactName 66.25ms → 0.22ms (300×), searchNodes 44.36ms → 14.10ms (3.1×).

Where it makes no difference: a single-word query into searchNodes on 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 its NOCASE collation both fold ASCII only. JavaScript's .toLowerCase() folds Unicode. So lowering the parameter in JavaScript and comparing it against lower(name) puts the two sides on different rules, and non-ASCII identifiers stop matching with no error:

stored probe name = ? COLLATE NOCASE lower(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 row
AsciiName ASCIINAME AsciiName, ASCIINAME AsciiName, ASCIINAME AsciiName, ASCIINAME

Lowering the parameter in SQL keeps both sides on SQLite's rules, which is exactly what NOCASE did.

getNodesByLowerName is 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 bare lower(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 (matchFuzzy lowers in JavaScript first, and lower() 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 the matchFuzzy side 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.

  • Entries under one key in the expression index are ordered by rowid, which is the same order a table scan produces, so the LIMITs keep the same rows.
  • Verified over 14,400 lookups with zero differences: the top-400 names of each of the four corpora, probed as stored / uppercased / lowercased, against all three rewritten call sites.
  • Verified again end-to-end: every measurement pair above compared result ids and matched.

Tests

__tests__/name-lookup-index.test.ts, 5 cases:

  • searchNodes issues its exact-name supplement as an index seek
  • findNodesByExactName issues both of its passes as index seeks
  • getNodesByLowerName seeks the index and does not depend on the caller lowering
  • still matches case-insensitively across both call sites
  • folds exactly what COLLATE NOCASE folded — ASCII only

The first three assert the planner's verdict, not a wall-clock number — they intercept the SQL each call site actually prepares, run EXPLAIN QUERY PLAN on 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/, and expected [] to deeply equal [ 'hr-1', 'hr-2' ] for the fourth).

tsc --noEmit clean. Full suite 2906 passed / 0 failed / 178 skipped.

Scope

Deliberately not included:

  • The matchFuzzy caller-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 (findNodesByNameSubstring and friends). A leading-wildcard LIKE can't use an index at all; that needs a different remedy, not a different spelling.
  • New indexes. This uses the one that already exists.

`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
maxmilian marked this pull request as ready for review August 12, 2026 11:08
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