fix(db,index): backfill chunks_tri when FTS_TRIGRAM flips on

Creating chunks_tri fresh on a 0->1 flip left it permanently empty for any
chunk indexed before the flip, since an unchanged note's file_hash never
sends it back through index_note. Mirror the dim/model rebuild path: db.py
marks pre-existing chunks tri_pending=1 when the table is freshly created,
and Indexer.rebuild_pending_trigram (called from scan()) backfills their
chunks_tri row and clears the flag.

Test: tests/test_db.py::test_trigram_flip_marks_existing_chunks_pending,
      tests/test_index.py::test_trigram_flip_backfills_existing_chunks_and_search_finds_them
This commit is contained in:
Markus committed 2026-09-12 18:33:06 +00:00
1 parent 8c4e90d27b
commit 491e8e5453
4 files changed
+134 -5

No files matched your search

+27 -5
View File
@@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS chunks(
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
ord INTEGER, heading TEXT, body TEXT,
embed_text_hash TEXT NOT NULL,
vec_pending INTEGER NOT NULL DEFAULT 0);
vec_pending INTEGER NOT NULL DEFAULT 0,
tri_pending INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS chunks_note ON chunks(note_id);
CREATE INDEX IF NOT EXISTS chunks_pending ON chunks(vec_pending);
@@ -112,10 +113,10 @@ class Database:
columns = {r[1] for r in self._write.execute("PRAGMA table_info(idempotency)")}
if "args_hash" not in columns:
self._write.execute("ALTER TABLE idempotency ADD COLUMN args_hash TEXT")
if self.trigram:
self._write.executescript(_TRIGRAM_SCHEMA)
else:
self._write.execute("DROP TABLE IF EXISTS chunks_tri")
chunk_columns = {r[1] for r in self._write.execute("PRAGMA table_info(chunks)")}
if "tri_pending" not in chunk_columns:
self._write.execute("ALTER TABLE chunks ADD COLUMN tri_pending INTEGER NOT NULL DEFAULT 0")
self._ensure_trigram_table()
self._write.commit()
rebuilt = self._ensure_vec_table()
self._set_meta("schema_version", str(SCHEMA_VERSION))
@@ -124,6 +125,27 @@ class Database:
self._write.commit()
return rebuilt
def _ensure_trigram_table(self) -> None:
"""Create/drop `chunks_tri` to match `self.trigram`.
Mirrors `_ensure_vec_table`: flipping FTS_TRIGRAM 0 -> 1 creates the
table fresh, but any chunks indexed while trigram was off have no row
in it. Rather than leaving that channel permanently empty, mark them
`tri_pending=1` so `Indexer.rebuild_pending_trigram` (index.py) can
backfill them -- the same pattern `vec_pending` uses for a dim/model
change (§5, §14.8).
"""
assert self._write is not None
cur = self._write
if not self.trigram:
cur.execute("DROP TABLE IF EXISTS chunks_tri")
return
have = cur.execute("SELECT name FROM sqlite_master WHERE name='chunks_tri'").fetchone()
if have is not None:
return
cur.executescript(_TRIGRAM_SCHEMA)
cur.execute("UPDATE chunks SET tri_pending=1")
def _ensure_vec_table(self) -> bool:
assert self._write is not None
cur = self._write
+37
View File
@@ -253,6 +253,11 @@ class Indexer:
if p.chunk_id not in seen_chunk_ids:
seen_chunk_ids.add(p.chunk_id)
stats.pending.append(p)
# Backfill chunks_tri for chunks indexed before FTS_TRIGRAM was
# enabled (db.py marks them tri_pending=1 on the flip); an unchanged
# note's file_hash never sends it back through index_note, so nothing
# else ever revisits them.
self.rebuild_pending_trigram()
stats.seconds = time.monotonic() - started
return stats
@@ -320,6 +325,38 @@ class Indexer:
out.append(PendingEmbed(chunk_id=r["id"], text=text, key_hash=r["embed_text_hash"]))
return out
def rebuild_pending_trigram(self) -> int:
"""Insert chunks_tri rows for chunks marked tri_pending (§5, §14.8).
Mirrors `pending_embeds`/`store_vectors` for the vector channel: when
db.py flips a fresh `chunks_tri` table into existence because
FTS_TRIGRAM went 0 -> 1, every pre-existing chunk is marked
tri_pending=1 rather than silently left out of the trigram channel.
"""
if not self.db.trigram:
return 0
rows = (
self.db.read()
.execute(
"""SELECT c.id, c.heading, c.body, n.title, n.keywords
FROM chunks c JOIN notes n ON n.id = c.note_id
WHERE c.tri_pending = 1"""
)
.fetchall()
)
if not rows:
return 0
with self.db.write() as w:
for r in rows:
kw = " ".join(json.loads(r["keywords"] or "[]"))
w.execute("DELETE FROM chunks_tri WHERE rowid=?", (r["id"],))
w.execute(
"INSERT INTO chunks_tri(rowid,text) VALUES(?,?)",
(r["id"], f"{r['title']}\n{kw}\n{r['heading']}\n{r['body']}"),
)
w.execute("UPDATE chunks SET tri_pending=0 WHERE id=?", (r["id"],))
return len(rows)
def store_vectors(self, items: list[tuple[int, bytes]]) -> None:
if not items:
return
+22
View File
@@ -81,6 +81,28 @@ def test_second_open_is_not_a_rebuild(tmp_path):
assert Database(p, dim=4, trigram=False, model="m").open() is False
def test_trigram_flip_marks_existing_chunks_pending(tmp_path):
"""Flipping FTS_TRIGRAM 0 -> 1 must queue existing chunks for a trigram
backfill, the same way a dim/model change queues them for re-embedding
(`_ensure_vec_table` sets vec_pending=1) -- otherwise chunks_tri stays
permanently empty for every chunk indexed before the flip."""
p = tmp_path / "index.db"
db = Database(p, dim=4, trigram=False)
db.open()
with db.write() as w:
w.execute("INSERT INTO notes(id,path,state,file_hash) VALUES('a','p','active','h')")
w.execute(
"INSERT INTO chunks(id,note_id,ord,heading,body,embed_text_hash) VALUES(1,'a',0,'','b','h')"
)
db.close()
db2 = Database(p, dim=4, trigram=True)
db2.open()
assert db2.read().execute("SELECT count(*) FROM chunks_tri").fetchone()[0] == 0
assert db2.read().execute("SELECT tri_pending FROM chunks WHERE id=1").fetchone()[0] == 1
db2.close()
def test_write_is_transactional(tmp_path):
db = Database(tmp_path / "index.db", dim=4, trigram=False)
db.open()
+48
View File
@@ -224,6 +224,54 @@ def test_scan_pending_has_no_duplicate_chunk_ids(idx):
assert len(stats.pending) == 2
def test_trigram_flip_backfills_existing_chunks_and_search_finds_them(tmp_path, monkeypatch):
"""Flipping FTS_TRIGRAM 0 -> 1 used to leave chunks_tri permanently empty
for chunks indexed before the flip: reopening with trigram=True created
the table but nothing populated it, so a substring-only query (matched
only via the trigram channel, not by whole-word FTS) found nothing."""
from mdmem.config import Settings
from mdmem.search import search as run_search
vault = Vault(tmp_path / "vault", tmp_path / ".history")
vault.ensure_dirs()
db_path = tmp_path / "index.db"
db1 = Database(db_path, dim=8, trigram=False)
db1.open()
indexer1 = Indexer(db1, vault)
n = Note.new(title="Bifrost", body="ein logfilexyz123abc taucht auf", type="fact")
vault.write(n)
indexer1.index_note(vault.read(vault.find(n.id)))
db1.close()
db2 = Database(db_path, dim=8, trigram=True)
db2.open()
indexer2 = Indexer(db2, vault)
stats = indexer2.scan()
assert stats.indexed == 0 and stats.unchanged == 1
chunk_count = db2.read().execute("SELECT count(*) FROM chunks").fetchone()[0]
tri_count = db2.read().execute("SELECT count(*) FROM chunks_tri").fetchone()[0]
assert chunk_count == 1
assert tri_count == 1
assert db2.read().execute("SELECT tri_pending FROM chunks").fetchone()[0] == 0
monkeypatch.setenv("VAULT_DIR", str(vault.root))
monkeypatch.setenv("INDEX_DB", str(db_path))
monkeypatch.setenv("CACHE_DB", str(tmp_path / "cache.db"))
monkeypatch.setenv("HISTORY_DIR", str(tmp_path / ".history"))
monkeypatch.setenv("EMBED_DIM", "8")
monkeypatch.setenv("MCP_TOKEN", "t")
monkeypatch.setenv("FTS_TRIGRAM", "1")
settings = Settings.from_env()
# "xyz123" is a substring of the compound token "logfilexyz123abc": whole-word
# FTS (chunks_fts) cannot match it, so a hit here proves the trigram channel.
resp = run_search(db2, settings, "xyz123", channels=("lex",))
assert resp.results and resp.results[0].id == n.id
db2.close()
def test_unchanged_embed_text_keeps_its_vector(idx):
indexer, vault, db = idx
n = Note.new(title="T", body="body", type="fact")