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:
1 parent
8c4e90d27b
commit
491e8e5453
4 files changed
+134
-5
No files matched your search
+27
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user