Skip to content

Take the write and the 643-entity fan-out off the search path - #121

Merged
raphasouthall merged 1 commit into
mainfrom
fix/120-search-latency
Aug 25, 2026
Merged

Take the write and the 643-entity fan-out off the search path#121
raphasouthall merged 1 commit into
mainfrom
fix/120-search-latency

Conversation

@raphasouthall

Copy link
Copy Markdown
Owner

Closes #120.

What was wrong

vault_search took 7 to 227 seconds and MCP clients gave up at ~30s with "No response received". Three compounding causes, measured against the live store (695 notes, 9627 triples, 12.85M co-occurrence pairs of which 12.72M were search-generated):

  • Entity extraction matched substrings anywhere. LIKE '%word%' per query word meant %agent% matched most of the graph. The 4-word query "azure foundry knowledge agent" produced 643 query entities, which are not the query's entities, and every stage after it was sized by that number.
  • The co-occurrence boost was an N+1. Two indexed lookups per query entity: 643 entities became 1286 round trips, 3.5s measured in isolation. A profile of one search showed 1056 fetchall calls costing 9.2s of 14.5s.
  • Every search performed a write. Reinforcement wrote (query entities x result entities) rows inline. SQLite has one writer, so two overlapping searches serialized — that is the 227s outlier. It also grew the table by roughly 250k rows a day.

The fix

Extraction is one statement for the whole query, anchored at a word start (the entity begins with the word, or a word inside it does), with hyphens and underscores normalized to spaces so azure-foundry-hub still reads as words. Wildcards inside a query word are escaped. The result is capped at MAX_QUERY_ENTITIES (50), keeping the least frequent entities: a term filling hundreds of triple slots boosts every candidate note equally and so separates none of them, which makes the cap a statement about signal rather than a latency patch.

The boost lookup is one chunked IN query instead of one per entity. The bounded-boost arithmetic below it is byte-identical and regression-tested against a re-implementation of the old per-entity loop.

Reinforcement is buffered in memory and drained off the request path: at 5000 pending pairs a short-lived daemon thread writes with its own connection, and an atexit hook flushes the remainder. The search call itself now issues zero writes to entity_cooccurrence. Per-search fan-out is bounded at 50 query entities x 40 result entities = 2000 pairs, with result entities taken in rank order so the top note's associations survive the cap.

Co-occurrence stays a feature. Its read path earns its place in the ranking boost and in attractor.py's clustering blend, and existing accumulated values are untouched.

Measured

before after
query entities ("azure foundry knowledge agent") 643 capped at 50
SQL statements for extraction one per word 1
boost lookups one per entity 1 per 500-entity chunk
writes from the search call thousands of rows 0
three consecutive searches 41s, 7.4s, 226.8s 0.61s, 0.63s, 0.77s
4 threads x 3 overlapping searches 227s pathology median 5.8s, max 7.5s, no serialization

Timings after are on a synthetic 700-note / 10k-triple store with a stubbed embedder; the before figures are the live store. Live verification against the deployed service follows the merge.

Ranking changes, stated deliberately

Notes boosted only because an unrelated entity happened to contain a query word as a substring lose that boost. When the cap bites, common entities drop out of the boost input, so notes associated with them lose it too — intentional sharpening, and the most visible thing to check live. Back-to-back identical searches no longer see each other's reinforcement, since the write now lands after the drain. Reinforcement accumulates far more slowly, and a pair repeated inside one flush window is bumped once rather than once per search.

Gate

uv run ruff check src/ tests/ exit 0. uv run pytest -q exit 0, 828 passed (+20), reproduced cold after the build.

One existing test changed: test_reinforce_from_search asserted that a search writes reinforcement, which is exactly the behaviour this issue removes. It now asserts the search writes nothing, the pair is buffered, and the row lands with the same increased value after a flush. Its sibling test_reinforce_noop_no_entities_in_search passes unmodified.

…h path

vault_search took 7 to 227 seconds and MCP clients gave up at 30.

Entity extraction matched substrings anywhere, so a 4-word query pulled
643 entities and every stage after it was sized by that number. The
co-occurrence boost then ran two lookups per entity, 1286 round trips.
And every search wrote reinforcement inline, which serialized overlapping
searches on the single SQLite writer and grew the table 250k rows a day.

Extraction is now one statement, anchored at a word start, capped at 50
and keeping the least frequent entities: a term filling hundreds of triple
slots boosts every candidate equally and separates none of them. The boost
lookup is one chunked IN query. Reinforcement buffers in memory and drains
on a background thread, so the search call issues no writes at all.

Co-occurrence stays: its read path feeds the ranking boost and the
attractor's clustering blend, and accumulated values are untouched.

Three consecutive searches go from 41s, 7.4s, 226.8s to 0.61s, 0.63s,
0.77s. Four threads of overlapping searches no longer serialize.

Closes #120
@raphasouthall
raphasouthall merged commit 31b3a08 into main Aug 25, 2026
5 checks passed
@raphasouthall
raphasouthall deleted the fix/120-search-latency branch August 25, 2026 15:58
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.

vault_search times out: loose entity extraction, N+1 boost lookups, and a write on every search

1 participant