Rank the memory bank without reading the memory bank

Retrieval walked adventure.memories, so every turn loaded every row of the
bank with its vector attached -- 3.1 MB, 96% of everything a turn read. It
now asks SQL which memories are in play (an id and a flag per row), ranks
against vectors held in process, and fetches text only for the five it picks.

Two more callers were doing the same thing and the production SQL could not
see them: _evict_over_capacity walked the bank to count it, and _embed_pending
walked it to find the rows with no vector. Both are counts and filters the
database can do without sending anything back.

    one turn      3,258.7 kB -> 723.4 kB cold, 122.3 kB warm
    run_post_turn 3,139.1 kB -> 0.7 kB
    Insights      3,223.7 kB -> 117.9 kB
    Memories drawer  ~3.1 MB -> 23.6 kB

A played turn is turn plus post-turn work: 6.4 MB down to 123 kB.

The cache needs no invalidation callbacks, which is what makes it safe. A
vector can only change through set_vector, which drops that one entry;
anything that removes a memory from play leaves the catalogue query, and
entries missing from the catalogue are dropped on the next read. So eviction,
deletion and pruning have nothing to remember to call.

memories.embedded joins the blob, for the same reason actions.variant_count
sits beside actions.variants: with the vector deferred, every "is this
embedded?" check would otherwise be a 6 KB lazy load, once per row.

Capacity drops 200 -> 80, on retrieval quality as much as cost -- ranking two
hundred memories to pick five buries the five. Eviction was measured at scale
first: trimming 100 to 80 costs 0.8 kB and reads no vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
This commit is contained in:
parththakkar106
2026-08-16 21:28:00 +05:30
co-authored by Claude Opus 5
parent c56864877a
commit b7e53ae581
9 changed files with 690 additions and 60 deletions
+14 -9
View File
@@ -158,10 +158,10 @@ class Memory(Base):
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, default="")
# Superseded by embedding_blob and written alongside it, so the two stay in
# step until a follow-up migration drops this one. Still the column the
# ranking path reads; that moves next.
embedding: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Superseded by embedding_blob and still written alongside it, so a
# rollback finds the vectors, until a follow-up migration drops it. Nothing
# reads it.
embedding: Mapped[list | None] = mapped_column(JSON, nullable=True, deferred=True)
# The vector, little-endian float32. Deferred because it is wider than the
# rest of the row put together and exactly one code path wants it: anything
# bulk-loading memories (the Memories drawer, eviction, the embed queue)
@@ -172,6 +172,11 @@ class Memory(Base):
# Action index range this memory summarizes (null for manual memories).
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Whether embedding_blob is set. Maintained on write by memorybank
# .set_vector, for the same reason actions.variant_count exists beside
# actions.variants: every reader wants the one-bit answer and none of them
# should have to fetch six kilobytes of vector to get it.
embedded: Mapped[bool] = mapped_column(Boolean, default=False)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
use_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -180,10 +185,6 @@ class Memory(Base):
adventure: Mapped[Adventure] = relationship(back_populates="memories")
@property
def embedded(self) -> bool:
return self.embedding is not None
class StoryCard(Base):
"""Owned by either a scenario or an adventure (exactly one set)."""
@@ -378,7 +379,11 @@ class Settings(Base):
# Phase 6: auto-summarization + memory bank
summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=200)
# Was 200. Lowered on retrieval-quality grounds first: ranking two hundred
# memories to pick five means the five are chosen out of a lot of noise,
# and older memories describe a story the player has moved on from. That it
# also cuts what the bank costs to read is the smaller reason.
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
@property