Skip to content

fix(recall): make cold recall fast on large, long-lived stores - #148

Merged
Grivn merged 5 commits into
mnemon-dev:masterfrom
audreyt:perf/recall-cold-start
Oct 3, 2026
Merged

Grivn merged 5 commits into
mnemon-dev:masterfrom
audreyt:perf/recall-cold-start

Conversation

@audreyt

@audreyt audreyt commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

What

Two changes that make cold mnemon recall fast on large, long-lived stores:

  1. feat(memory): the Ollama client sends keep_alive (new MNEMON_EMBED_KEEP_ALIVE, default 30m; not sent to OpenAI-compatible servers). A new mnemon gc --compact runs VACUUM and reports the file size before and after.
  2. fix(recall): cuts the per-node SQLite reads in beam search:
    • neighbours resolve from the active-insight snapshot recall already loaded, instead of one GetInsightByID each;
    • each node's edges are cached across anchors within one recall;
    • the known-entity set comes from the same snapshot (IntentAwareRecallFrom, store.KnownEntities), so the insights table is scanned once;
    • new covering indexes answer GetNeighborEdges from the index alone, in a fixed order;
    • a small partial index of supersedes edges serves the superseded check;
    • both open modes set mmap_size.

Why

On a large, long-lived store, a cold recall took about 5.8s before this change. The time was mostly waste:

Cost Share Cause
Reloading the embedding model ~2.4s Ollama's default 5-minute keep_alive unloads it between agent turns
Reading a fragmented database ~3.2s Recall updates access counters on every hit and nothing ever vacuums, so table pages scatter and cold reads run at random-I/O speed. VACUUM alone cut a cold-cache recall from ~3.6s to ~1.4s.

Profiling the remaining time found several other costs in beam search:

  • For one query it made ~3.7k GetInsightByID calls and ~1k uncached GetEdgesByNode calls, around 440ms even when warm.
  • Edge lookups went through single-column indexes, so each edge row was a random read into the table.
  • The known-entity set was a second full scan of the insights table.

Measurements after the recall change (Apple M4, embedding model already loaded, medians of 5–7 runs over three queries):

before after
warm 0.82–0.87s 0.53–0.56s
page cache purged 1.92–1.96s 1.22–1.53s

Each query returned the same top-10 set.

Notes for review

  • Migration. migrateEdgeRecallIndexes runs last on every writable open, because the edge-table rebuild migrations recreate the single-column indexes. It adds idx_edges_source_cover, idx_edges_target_cover and the partial idx_edges_supersedes, and drops idx_edges_source / idx_edges_target. The (id, edge_type) indexes still serve plain id lookups. The first open builds the indexes (under a second with hundreds of thousands of edges). The file grows by ~15%.
  • Read-only opens skip migrations. The superseded lookup therefore falls back to the existing scoped IN (...) query when idx_edges_supersedes is absent (covered by a test). INDEXED BY is needed because SQLite's planner will not choose a partial index for this query by itself.
  • Ordering. GetNeighborEdges orders by weight DESC plus a key tiebreak. Beam search stops at a visit budget, so which neighbours it reaches depended on index order. This makes it deterministic, and it can reorder lower-ranked results relative to before.
  • Upstream behaviour kept. Self-supersession is still ignored (source_id != target_id); the partial index covers (target_id, source_id) so the filter stays index-only.
  • When to compact. gc --compact is explicit rather than automatic, because free-page counts alone do not reveal page scattering. It needs an exclusive lock and briefly doubles disk use.

Checklist

  • Deterministic tests pass (make test)
  • Relevant E2E/process/Docker boundaries pass (make test-integration, when affected)
  • New/changed behavior is covered by tests: keep_alive default, override and OpenAI omission; compaction; neighbour order; query plans use the covering and partial indexes; known-entity parity; missing-index fallback
  • Documentation updated: README, USAGE and zh docs cover MNEMON_EMBED_KEEP_ALIVE and gc --compact
  • User-facing release-note impact: new MNEMON_EMBED_KEEP_ALIVE (default keeps the model loaded 30 minutes); new mnemon gc --compact; faster recall; a one-time index migration on the first writable open

audreyt and others added 5 commits September 28, 2026 23:41
A cold recall paid twice before any ranking started. Ollama unloads a model
five minutes after its last request, so an agent that recalls once per turn
often reloads a multi-gigabyte embedding model first; the Ollama client now
sends keep_alive (MNEMON_EMBED_KEEP_ALIVE, default 30m; not sent to
OpenAI-compatible servers). Separately, recall bumps access counters on every
hit and nothing ever vacuumed, so a long-lived store ends up with table pages
scattered across the file and cold reads run at random-I/O speed.
`mnemon gc --compact` runs VACUUM and reports the size before and after.

On a large long-lived store on an Apple M4 laptop, VACUUM alone cut a
cold-cache recall from about 3.6s to 1.4s. Validated with make test plus new
tests for keep_alive (default, override, omitted for OpenAI) and compaction.
Beam search fetched each neighbour with GetInsightByID although recall had
already loaded every active insight, re-read a node's edges for every anchor
whose neighbourhood reached it, and read edges through single-column indexes
that sent each row to a random page of the edges table. Recall also scanned
the insights table a second time for the known-entity set. Neighbours now
resolve from the loaded snapshot, edges are cached per recall, and the entity
set comes from the same snapshot (IntentAwareRecallFrom, store.KnownEntities).

New covering indexes on (source_id|target_id, other end, edge_type, weight)
answer GetNeighborEdges without touching the table and replace the
single-column edge indexes. Its fixed order also makes the visit-budgeted
beam deterministic. The superseded check reads a small partial index of
supersedes edges once instead of probing the target index per candidate,
falling back to the scoped query on stores a read-only open has not
migrated. Both open modes set mmap_size.

On a large long-lived store (Apple M4, model already loaded, medians of
5-7 runs over three queries) recall went from 0.82-0.87s to 0.53-0.56s
warm and from 1.92-1.96s to 1.22-1.53s after purging the page cache, with
the same top-10 set. Validated with make test plus tests for neighbour
order, query plans, the known-entity set and the missing-index fallback.
Order cached neighborhoods by the full intent and similarity contribution before consuming the visit budget. Relevant causal, temporal, and vector matches must not disappear behind higher raw-weight edges; preserve the existing traversal bounds.

Validated focused search/store tests and real CLI comparisons: the causal result returns at rank two in all seven runs, while dense-graph recall retains the performance improvement.
Seek requested target IDs through the covering partial index in bounded batches instead of scanning unrelated supersession history. Retain the read-only legacy-index fallback and exclude self-supersession.

Validated query-plan, chunking, and fallback checks plus real CLI comparisons with 100,000 historical edges: median query time returns from 29.09 ms to 12.69 ms with unchanged results.
Inspect the checkpoint result rows before and after VACUUM, report blocked or partially completed compaction accurately, and propagate database size errors instead of returning a false success.

Validated real concurrent-reader CLI reproduction and SQLite tests for busy rejection, successful retry with committed data preserved, and read-only rejection. Normal compaction preserves content and passes integrity_check.
Copilot AI balanced review requested due to automatic review settings October 3, 2026 11:08
@Grivn
Grivn merged commit 5c98ff9 into mnemon-dev:master Oct 3, 2026
2 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Durable-store maintenance and budget-sensitive traversal need human validation, and a transaction deadlock remains unresolved.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Improves cold Memory recall by reducing database reads and embedding-model reloads, with explicit store compaction.

Changes:

  • Adds configurable Ollama model retention and gc --compact.
  • Reuses insight snapshots and cached transitions; adds recall indexes and memory-mapped reads.
  • Adds regression tests and English/Chinese documentation.
File Description
README.md Documents model retention setting.
internal/​memory/​store/​recall_index_test.go Tests indexes, ordering, entity parity, and fallback.
internal/​memory/​store/​node.go Extracts known entities from loaded insights.
internal/​memory/​store/​edge.go Adds optimized neighbor and supersession queries.
internal/​memory/​store/​db.go Adds recall indexes and memory-mapped reads.
internal/​memory/​store/​compact.go Implements checkpointed compaction.
internal/​memory/​store/​compact_test.go Tests space reclamation.
internal/​memory/​store/​compact_busy_test.go Tests busy and read-only rejection.
internal/​memory/​search/​recall.go Reuses snapshots and caches transitions.
internal/​memory/​search/​recall_transition.go Ranks transitions before visit limits.
internal/​memory/​search/​recall_budget_test.go Tests budget-sensitive ranking.
internal/​memory/​search/​integration_test.go Adapts traversal tests.
internal/​memory/​embed/​ollama.go Sends configurable Ollama retention.
internal/​memory/​embed/​ollama_test.go Tests retention defaults and protocol handling.
docs/​zh/​USAGE.md Documents new options in Chinese.
docs/​zh/​README.md Documents model retention in Chinese.
docs/​USAGE.md Documents compaction and model retention.
cmd/​memory/​recall.go Shares the active-insight snapshot.
cmd/​memory/​gc.go Exposes compaction and size reporting.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if db.readOnly {
return 0, 0, fmt.Errorf("compact: database is read-only")
}
if err := db.compactCheckpoint(); err != nil {
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.

3 participants