Drop the JSON vector column, and fix what was hiding behind it

Migration 38 left memories.embedding in place so a rollback could still find
the vectors. Production has since been verified reading from embedding_blob,
so migration 42 drops it: 4 MB of a 99.6 MB database holding nothing anyone
reads.

Removing it surfaced a live bug. Changing your embedding model is supposed to
throw the bank's vectors away and let the post-turn pass rebuild them, because
two models' vectors are not comparable. The settings route did that by nulling
memories.embedding -- correct until 38 moved the vectors, after which it
cleared the dead column and left the blob intact with `embedded` still true.
_embed_pending filters on `embedded IS FALSE`, so it never saw those rows and
the bank went on ranking against the old model's vectors permanently.

Nothing would have reported it. cosine returns 0.0 on a width mismatch, so a
different-width model scores every memory zero and retrieval returns whichever
rows happen to sort first; a same-width model scores plausible garbage.

The bulk clear now sets both columns. It stays a bulk UPDATE rather than going
through set_vector -- loading the rows is the cost that whole path exists to
avoid -- so set_vector's docstring now names it as the one caller that
legitimately writes those columns by hand. No cache invalidation is added:
clearing `embedded` drops the rows out of the catalogue query, and set_vector
evicts each entry as the re-embed puts it back.

test_embedding_blob.py now rebuilds the pre-38 schema by hand where it tests
the backfill, since create_all no longer produces the column it converts from,
and asserts 42 removes it at the end of a full bootstrap -- 38 reads that
column and 42 drops it, so an upgrade that reordered them would arrive with an
empty bank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 13:44:59 +05:30
co-authored by Claude Opus 5
parent 85b188977e
commit 2c5909a268
7 changed files with 237 additions and 27 deletions
+8 -6
View File
@@ -86,13 +86,15 @@ def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
"""Store (or clear) a memory's embedding.
Every column that describes the vector moves together: `embedding_blob` is
what the ranking reads, `embedded` is the flag everything else reads, and
the JSON `embedding` stays correct behind both until the follow-up
migration drops it. Going through one function is what keeps them in step —
and it is also the only place a stored vector can change, which is what
makes the cache below safe to invalidate here and nowhere else.
what the ranking reads and `embedded` is the flag everything else reads.
Going through one function is what keeps them in step — and it is also the
only place a stored vector can change, which is what makes the cache below
safe to invalidate here and nowhere else.
The one caller that legitimately cannot come through here is the bulk
clear in `routers/settings.py` when the embedding model changes. It has to
set the same two columns by hand; see the note there.
"""
memory.embedding = vector
memory.embedding_blob = None if vector is None else vectors.pack(vector)
memory.embedded = vector is not None
cached = _vector_cache.get(memory.adventure_id)
+6
View File
@@ -144,6 +144,12 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# so anyone who picked a value keeps it — same rule as migration 29.
# Adventures already over 80 evict down on their next turn.
(41, "UPDATE settings SET memory_bank_capacity = 80 WHERE memory_bank_capacity = 200"),
# The JSON vectors, gone. Migration 38 left them in place so a rollback
# could still find them; production has since been verified reading from
# embedding_blob (schema_version 41, 134/134 backfilled), so the column is
# now 4 MB of a 99.6 MB database holding nothing anyone reads. DROP COLUMN
# is spelled the same on both dialects — SQLite has had it since 3.35.
(42, "ALTER TABLE memories DROP COLUMN embedding"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
-4
View File
@@ -158,10 +158,6 @@ 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 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)
+13 -1
View File
@@ -51,14 +51,26 @@ def update_settings(
# Vectors from the old model have a different dimensionality/space;
# clear them so the post-turn task re-embeds with the new model.
# (This user's adventures only — settings are per-user now.)
#
# Both columns, and the flag. This is the one place that clears vectors
# in bulk rather than through memorybank.set_vector, and when the
# vectors moved to embedding_blob it kept nulling the old JSON column
# alone: the blob survived, `embedded` stayed true, and _embed_pending
# — which looks for embedded IS FALSE — never picked the rows up. The
# bank went on ranking against the previous model's vectors forever.
owned = (
db.query(models.Adventure.id)
.filter(models.Adventure.user_id == user.id)
.scalar_subquery()
)
db.query(models.Memory).filter(models.Memory.adventure_id.in_(owned)).update(
{"embedding": None}, synchronize_session=False
{"embedding_blob": None, "embedded": False}, synchronize_session=False
)
# No cache invalidation needed, and deliberately none added: clearing
# `embedded` drops these rows out of the catalogue query, so retrieval
# stops asking for them, and by the time _embed_pending puts one back
# it has gone through set_vector, which evicts that entry. The rule
# holds — anything that removes a memory from play self-corrects.
db.commit()
return settings