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:
co-authored by
Claude Opus 5
parent
85b188977e
commit
2c5909a268
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user