Skip to content

fix(sqlite_exact): speed up query and status on large palaces - #2308

Merged
igorls merged 1 commit into
developfrom
fix/sqlite-exact-query-speed
Aug 19, 2026
Merged

fix(sqlite_exact): speed up query and status on large palaces#2308
igorls merged 1 commit into
developfrom
fix/sqlite-exact-query-speed

Conversation

@igorls

@igorls igorls commented Aug 19, 2026

Copy link
Copy Markdown
Member

sqlite_exact is the live backend on this machine (~167k drawers, ~166k closets, 1.6 GB). query() was exact cosine over every row: it selected id, document, metadata_json, embedding, JSON-parsed metadata, then dotted in a Python loop. MCP mempalace_search does that twice (drawers + closets). mempalace_status paged every metadata row because sqlite_exact had no facet_counts and _sqlite_taxonomy was chroma-only.

This PR does not touch chroma.py (in-flight work on develop, including #2307).

Changes

  • Rank from the embedding column with vectorized numpy cosine; hydrate only the top-k documents.
  • Cache the embedding matrix plus wing / room / source_file on the long-lived backend handle (invalidated on write).
  • facet_counts + sqlite_wing_room_counts so status / list_wings / list_rooms use one json_extract GROUP BY.
  • Advertise supports_metadata_facets and set collection._backend so the MCP facet path works through EmbeddingCollection.

Exact cosine ranking is unchanged (existing ranking tests plus new ones).

Live palace (this box, MCP HTTP, 167k drawers / 166k closets)

Call Before (3.7.1) After
mempalace_status 6997 ms 1045 ms
mempalace_search cold 8236 ms 5299 ms (first matrix load)
mempalace_search warm 6364 ms 210–1600 ms
mempalace_search wing=mempalace 3910 ms 1453 ms
mempalace_kg_query 19 ms 34 ms (unchanged class)

Isolated: FTS MATCH ~99 ms warm; embeddings-only numpy cosine ~100–186 ms once vectors are in RAM. The remaining warm-search spread is embed + two collection cosines + MCP JSON, not a full table scan of document text.

Tests

uv run pytest tests/test_sqlite_exact_backend.py tests/test_searcher.py tests/test_hybrid_search.py tests/test_mcp_server.py — 517 passed.

sqlite_exact.query() loaded every document and metadata blob to do
Python-loop cosine on the whole collection. On a 167k-drawer palace
that made mempalace_search 6-9s and mempalace_status 7s (paging every
metadata row because sqlite_exact had no facet_counts).

Rank from the embedding column with vectorized numpy cosine, hydrate
only the top-k documents, and cache the matrix plus wing/room/source_file
on the long-lived handle. Add facet_counts and sqlite_wing_room_counts
so status/list_wings use one GROUP BY. Advertise supports_metadata_facets
and expose collection._backend so the MCP facet path runs through the
embedding wrapper.

Live palace (167k drawers / 166k closets):
  status       6997ms -> 1045ms
  search warm  6364ms -> 210-1600ms
  search cold  8236ms -> 5299ms (first matrix load)
@igorls
igorls requested a review from milla-jovovich as a code owner August 19, 2026 20:41
Copilot AI lite review requested due to automatic review settings August 19, 2026 20:41
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@igorls igorls left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff and ran the numbers locally. The approach is right — ranking from the embedding column and hydrating only top-k is the correct shape, the SQL pushdown is properly guarded (_FACET_FIELD_RE on every interpolated key, so no injection surface), and _rows already used ORDER BY rowid so the stable-sort tie-breaking is preserved. Five things worth a look before merge, roughly in order of how much they matter.

1. The cache is cleared on every write, so the file-then-search cycle is always cold.

_cursor(write=True) does _vector_cache.clear(), and filing a single drawer is a write. On a box where hooks file drawers continuously, the warm path may be the exception rather than the rule.

Measured on a synthetic 40k-drawer / 384-dim palace:

COLD query:            99 ms
WARM query:             6 ms
query AFTER ONE WRITE: 90 ms   (15x the warm path)

So the 210–1600 ms warm figure in the description is real but may be rare in practice, and 5299 ms cold is closer to the steady state for a machine that is actively filing. Worth considering an incremental update — append the new rows to the matrix instead of dropping all of it — since an upsert usually adds a handful of vectors to a 166k-row matrix.

2. The cache has no cap and no opt-out.

At 166k drawers x 384 float32 that is ~255 MB per collection, so ~510 MB across drawers + closets, pinned on the handle for the life of the hub and growing linearly with the palace.

The load transient is larger than the matrix itself — .fetchall() materializes every row (id, blob, three json_extract results) before np.stack allocates the copy:

40k drawers, 61 MB matrix  ->  +197 MB peak RSS on the cold load   (~3.2x)
extrapolated to 166k       ->  ~255 MB resident, ~800 MB transient per collection

Given the memory work that just landed, a half-gig floor plus a multi-hundred-meg spike per cold load seems worth at least an env knob or a row-count ceiling above which the backend keeps the old streaming path.

3. np.stack raises when collections.dimension is NULL and rows have mixed widths.

_load_all_vectors only skips size-mismatched vectors when expected is not None. With a NULL dimension every row is kept and np.stack fails:

ValueError: all input arrays must have the same shape

The old per-query loop skipped mismatches (vec.size != q.size) and degraded gracefully. Reachability is narrow — _ensure_collection_dimension pins the dimension on first write and rejects mixed batches, so it needs a legacy or externally-modified DB — but it turns a soft skip into a hard failure. documents.dim is already a NOT NULL column, so filtering in SQL (WHERE dim = ?) or falling back to the first kept vector's size would close it.

4. Latent: a concurrent write can be swallowed by the cache.

_rank_vectors does get-miss, load, then store. A write committing between the load and the store clears a cache that is then repopulated with the pre-write snapshot, and nothing invalidates it until the next write — so a just-filed drawer stays invisible to search.

Not reachable through the hub today: _http_dispatch serializes every tool call on _HTTP_REQUEST_LOCK, and reads take handle.lock while writes take mine_palace_lock. But the cache's correctness currently depends on a lock in a different module rather than on anything local. A generation counter — same pattern as _capacity_cache_generation in the chroma backend, bump on clear, only store if unchanged — would make it self-contained.

5. Minor: the cosine is now computed entirely in float32.

dots, norms and the division all stay float32, where the old path did float(np.dot(...)) and divided in float64. Same formula, marginally different rounding, so near-ties can reorder. The ranking tests pass and this is almost certainly below any threshold that matters — noting it only so it is a deliberate choice rather than an accident.

Nothing here is an objection to the direction: mempalace_status at 6997 ms -> 1045 ms is a real win and the facet pushdown is clearly the right call.

@igorls
igorls merged commit c860fb1 into develop Aug 19, 2026
9 checks passed
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.

2 participants