perf(serve): trigram candidate prefilter to cut O(N) query latency on large graphs (byte-identical results)#1431
Closed
papinto wants to merge 1 commit into
Closed
Conversation
_score_nodes and _find_node scan every node per query (O(nodes x terms)), so query latency scales with total graph size regardless of where the answer lives. Add a lazily-built character-trigram index (cached on the graph object, auto-invalidated on hot-reload like _idf_cache) that narrows each query to a small candidate superset before the unchanged scoring loop. Results are byte-identical: the index is a pure candidate generator over the exact fields the scorer reads (norm_label, label_tokens, nid, source_file); a non-candidate node always scores 0, and IDF stays a whole-graph statistic. _find_node candidates are returned in graph iteration order so its exact/prefix/substring ordering, and matches[0], stay unchanged. A selectivity guard falls back to the full scan when a query term is too short to trigram or its rarest trigram is still common (broad terms like model/client), preserving a never-worse contract. The index builds eagerly at load and before a reloaded graph is swapped in, so neither the first query nor the first post-reload query pays the one-time build cost. Storage format is unchanged (in-memory index only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
safishamsi
added a commit
that referenced
this pull request
Jun 23, 2026
…nchor merges Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator
|
Merged into |
safishamsi
added a commit
that referenced
this pull request
Jun 23, 2026
Query perf, a skill graph-health gate, and a batch of install/extraction fixes: - #1431 trigram query prefilter (faster, results unchanged) - #1437 Step 4.5 graph-health gate + semantic-cache anchored on the scan root - #1411 CUDA (.cu/.cuh) via the C++ extractor - #1402 cross-file type-annotation refs no longer duplicate nodes - #1403 hermes install -> %LOCALAPPDATA% on Windows - #1409 no punctuation-only Obsidian/Canvas filenames - #1413 opencode reminder backtick command-substitution fix - #1428 graphify extract --cargo handles missing Cargo.toml - #1429 prs.py F821 cleanup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
serve's query path scans every node on every query._score_nodesand_find_nodeboth loop over the whole graph (for nid in G.nodes(...),O(nodes x terms)), so query latency scales with total graph size regardless of
where the answer lives. On a large monorepo that is the dominant per-query cost.
This adds a lazily-built, in-memory character-trigram candidate index that
narrows each query to a small candidate superset before the existing scoring
loop runs. Results are byte-identical to today's output, the on-disk format
is unchanged (the index is in-memory only), and a selectivity guard keeps
broad-term queries from ever being slower than the current scan.
This is the query-latency lever discussed under #819 (performance for larger
codebases). It is orthogonal to and complementary with the storage-backend track
in #1297; this one needs no format change and no new dependency.
How it works
trigram -> node-idsindex is built over exactly the fields the scoreralready reads:
norm_label, the tokenizedlabel_tokensform,nid, andsource_file(NUL-joined so a trigram cannot span two fields). Because that isthe complete set of predicates
_score_nodesand_find_nodematch against,the index is a complete candidate generator: any node not in the candidate
set scores 0 under the unchanged scorer, so dropping it changes nothing.
inside
_score_nodesand_find_node; the call sites do not change. IDF staysa whole-graph statistic (computed from the full graph, not the candidate
subset), so scores are identical.
_find_nodecandidates are returned in graph-iteration order, so itsexact, prefix, and substring tie-ordering, and
matches[0], are byte-identicalto a full scan.
(same lifecycle as the existing
_idf_cache), so a reloaded graph rebuilds it.Never-worse guard
Before using the index, a cheap guard (postings-length lookups only, no set
intersection) falls back to the existing full scan when the index would not help:
term like
modelorclientwhose candidate set would approach the wholegraph).
So narrow, specific queries take the fast path; broad queries run the unchanged
scan at ~1.0x (never slower). The fast path is selected automatically per query.
Eager build at startup
The index builds on first use. Since a serve process exists to answer queries,
it is warmed right after load, and a reloaded graph is warmed before it is
swapped in, so neither the first query nor the first post-reload query pays the
one-time build cost (~2.3 s at 54k nodes, ~7.3 s at 177k).
Measured (our graphs; verified byte-identical against the current scorer)
End-to-end on the integrated path (the prefilter monkeypatched off gives today's
baseline), best-of-3 per query:
_find_nodelookup is byte-identical tothe current path across both graphs. New
test_serve.pycoverage is included.label_tokensportion shrinks with scale (~4.7% at 54k -> ~3.6% at 177k)because almost every label token is already a substring of
norm_label. Buildcost is roughly 41 ms per 1k nodes.
(~1.0x; the slowest observed cases are within best-of-3 timing noise on a
multi-second scan, since the fallback adds only a cheap O(query-trigrams) guard
probe). The relative win is concentrated where it matters (specific lookups on
big graphs) with no regression on the tail.
Scope and non-goals
was already GIL-bound, so scale-by-process is unchanged.
incremental update) is the separate track in RFC: optional SQLite tiered storage backend for serve (~17–64× lower query latency, byte-identical results) #1297. This is the
no-format-change query-latency win that stands alone.