perf: serve partial symbol lookup from a Qdrant full-text index - #108
perf: serve partial symbol lookup from a Qdrant full-text index#108GoodbyePlanet wants to merge 1 commit into
Conversation
find_by_name(exact=False) scrolled the whole collection in pages of 200 and substring-matched in Python, so every partial-name lookup pulled most of the payload over the wire and cost O(N) in collection size. Index a derived symbol_name_tokens payload field (the identifier plus its camelCase/snake_case subwords, from the same split_code_identifiers helper that feeds BM25) with a TEXT/PREFIX index, and query it with MatchText. Measured on a 24,003-symbol collection: token and token-prefix queries drop from a full scan to a flat ~1.5ms regardless of size. Mid-token fragments still match, but Qdrant resolves them with a linear scan of its own — no n-gram tokenizer exists, so arbitrary-substring lookup cannot be made sublinear. The pre-existing client-side scan is kept as a fallback for collections indexed before this field existed; they need one `make index-code` to reach the fast path. Also: - run _create_payload_indexes() on existing collections too, not only at creation, so indexes added later actually reach live deployments - add the missing KEYWORD index on symbol_name, which exact=True and get_code_context were filtering against unindexed - rank results exact -> prefix -> remainder, since scroll returns point-id order and buried the exact hit Closes #72 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Live test on a real indexed collectionRan the
✅ ConfirmedIndex creation against a pre-existing collection. Server booted, issued 8 Pre-reindex fallback. Every partial lookup kept working before migration (~22 ms via the client scan), transparently. Post-reindex latency. The headline claim holds on real data, ~11x:
Filters compose correctly with the text match — identical hits, no interference. Exact-first ranking works. ❌ Finding 1 — silent recall regressionPost-migration, queries that span a subword boundary but aren't anchored at the identifier's start silently return fewer results than before. A sweep of 768 fragments derived from actual symbol names in the collection found 20 such queries: Cause: The fallback does not rescue this, because it only fires when the indexed query returns zero rows. Here it returns 12, so Cheapest correct fix is probably to union the scan in whenever the indexed result count is below ❌ Finding 2 — the mid-token comment is inaccurate
Mid-token returns nothing from Qdrant and always falls through to the client-side scan. Measured post-migration: So the PR description's " ❌ Finding 3 — the migration instructions don't workThe description says one Blob-SHA change detection skips every unchanged file, so nothing is upserted and Not assessable at this scalePREFIX index build cost and memory. At 709 points (35 MB collection, Qdrant at 481 MB RSS) there's no usable signal to extrapolate to a large repo. That item stays open. SummaryThe core performance claim is real and reproducible on live data, and the migration is non-destructive. Findings 2 and 3 are documentation/comment corrections. Finding 1 is a behavioural regression that I'd want resolved before merge — it trades correctness for latency without surfacing that it's doing so. |
Closes #72.
Problem
find_by_name(exact=False)scrolled the entirecode_symbolscollection in pages of 200, lowercasing and substring-matching in Python until it collected 50 hits. Every partial-namefind_symbolcall pulled most of the collection's payload over the wire, at O(N) in collection size.Change
Index a derived
symbol_name_tokenspayload field — the identifier plus its camelCase/PascalCase/snake_case subwords, built with the existingsplit_code_identifiers()helper that already feeds BM25 — with aTEXTindex using thePREFIXtokenizer, and query it with a singleMatchText-filtered scroll.Three adjacent problems found while verifying the issue are fixed in the same pass:
_create_payload_indexes()only ran when the collection was created, so any index added in a later version would never reach an existing deployment. It now runs on the existing-collection path too, following theCommitStorepattern.symbol_namehad no payload index at all, soexact=Trueandget_code_contextwere filtering against an unindexed field. Added aKEYWORDindex.Measured against a real Qdrant (24,003-symbol collection)
placeOrderRequestorder(full subword token)ord,plac,reques(token prefixes)rder,quest(mid-token)The
PREFIXtokenizer earns its keep: truncated prefixes are real index hits, whichWORDwould not give.Honest limitation: mid-token fragments still match, but at linear cost — the scan moved from the client into Qdrant rather than disappearing. Qdrant has no n-gram tokenizer, so arbitrary-substring lookup cannot be made sublinear. What this PR removes is the client-side scan and its wire traffic, and it turns the common prefix query into a genuine index hit.
Migration
Collections indexed before this change have no
symbol_name_tokens, soMatchTextmatches nothing and_find_by_name_scanning()(the previous behaviour, kept as a fallback) handles them transparently. Onemake index-codepopulates the field and reaches the fast path. No collection drop or re-embedding needed.Verification
uv run pytest— 311 passed.tests/test_store.py's oldtest_find_by_name_fuzzy_scans_all_pagesis replaced by four tests (indexed query, filters coexisting with the text match, fallback path, ranking), plus tests for idempotent index creation, the tokenizer config, and the payload token field.symbol_name_tokens: textandsymbol_name: keyword;ensure_collection()is idempotent; ranking confirmed;zzzreturns nothing; stripping the token field falls back correctly.Why
needs testingThe numbers above come from synthetic symbol names in a throwaway collection. Worth confirming on a real indexed repo before merge:
find_symbollatency and result quality on an actualcode_symbolscollection, especially withservice/symbol_typefilters stacked alongside the text matchPREFIXindex build cost and memory on a large collection — each token expands to up to 30 prefixes🤖 Generated with Claude Code