fix(store-sqlite): #229 chunks_fts 삭제를 전체 스캔에서 rowid 조회로 #235
Reference in New Issue
Block a user
Delete Branch "fix/fts-rowid-delete"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
chunk_id는chunks_fts에서UNINDEXED다. 그런데 V002 이래 삭제 트리거가 그 컬럼으로 행을 찾았다 (DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id). FTS5 는 UNINDEXED 컬럼에 색인을 만들지 않으므로 이 조건을 만족할 색인이 없고, 삭제가 색인 전체 스캔으로 떨어진다. chunk 한 건 삭제가 O(색인 전체) 였다.chunks에서 DELETE 가 나가는 모든 경로가 이 비용을 냈다 —sweep_deleted_files,reset --orphans-only, 그리고 파일이 수정될 때마다 도는purge_orphan_at_workspace_path. 즉 정상적인 증분 재색인이 코퍼스가 커질수록 느려지는 형태였다.migrations/V016__fts_rowid_delete.sql이chunks_fts를 drop 후 재생성하면서 rowid 를chunks.rowid와 맞추고, 세 트리거의 행 지정을rowid로 바꾼다. FTS5 는 rowid 로 B-tree 조회를 하므로 O(log n) 이 된다. 컬럼 구성과 tokenizer 는 그대로라 검색 경로(bm25,snippet(chunks_fts, 3, …),f.chunk_id/f.doc_id참조)는 한 줄도 손대지 않았다.설계:
docs/superpowers/specs/2026-04-27-kebab-final-form-design.md§5.5왜 이슈가 제안한 external-content 가 아닌가
이슈는
content='chunks'를 제안했다. 그 편이 본문 그림자(chunks_fts_content, 실측 550 MB)까지 회수한다. 하지만 V009 트리거가 색인하는 값이tokenized_korean_text || ' ' || text라chunks의 어느 컬럼과도 일치하지 않는다. generated column 신설 + FTS 테이블에서chunk_id/doc_id제거 + 검색 경로의 rowid join 전환이 딸려온다. 삭제 비용은 rowid 정렬만으로 같은 복잡도로 내려가므로 그림자 회수는 별 건으로 남겼다.이슈 본문의 사실관계 두 가지도 정정해 둔다. 지목된 마이그레이션은 V002 가 아니라 V009 다 (V007 이 trigram 으로, V009 가 unicode61 + 한국어 형태소 컬럼으로 각각 다시 만들었다). 그리고
chunks_fts는 contentless 가 아니다 —chunks_fts_content가 실재하고 그 550 MB 가 위에서 말한 그림자다.곁다리로 잡은 잠복 결함
rebuild_chunks_fts가 V009 의 한국어 형태소 접두를 빠뜨리고 raw text 만 넣고 있었다. 재구축을 한 번 돌리면 2자 한국어 질의가 다음 재색인 때까지 안 맞는 상태가 됐다. 트리거와 같은 CASE 를 넣어 맞췄다 (rowid 명시도 같이 — 안 하면 FTS5 가 자기 번호를 매겨 정렬이 깨지고 그 뒤 모든 삭제가 조용히 no-op 이 된다).검증
실제 KB 사본 (문서 28,427건 / chunk 600,808건), 문서 200건 삭제:
chunk_id로 DELETE)rowid로 DELETE)약 800배. 삭제 후 남은
chunks행 수와chunks_fts행 수가 양쪽 다 595,741 로 같다.마이그레이션 자체는 60만 chunk 기준 32초 (릴리스 바이너리로 재봤을 때 검색 한 번 포함 37.8초).
chunks와 임베딩은 손대지 않으므로 재색인은 필요 없다.검색 결과는 바뀌지 않는다. 실제 KB 에서 네 질의의 상위 20건을 chunk_id·bm25 점수·snippet 까지 해시로 비교했고 마이그레이션 전후가 동일했다:
그래서 V016 은
corpus_revision을 올리지 않는다 — 어휘 검색 정렬이ORDER BY score, f.chunk_id라 rowid 와 무관하므로 미결 pagination cursor 를 무효화할 이유가 없다. tokenizer 가 바뀐 V009 와는 다른 경우다.cargo test --workspace --no-fail-fast녹색 (186 ok / 0 FAILED)cargo clippy -p kebab-store-sqlite --all-targets -- -D warnings녹색 (kebab-app은 main 에서 이미 붉다 —kebab-parse-code의question_marklint, 이 PR 무관)시험 항목 (Test Plan)
crates/kebab-store-sqlite/tests/fts.rs에 네 건 추가:fts_v016_shadow_rowid_mirrors_chunks_rowid— 그림자의 rowid 가 원본과 일치하고 같은 chunk_id 를 들고 있는가. 나머지 셋이 전부 이 전제 위에 선다.fts_v016_delete_removes_only_the_deleted_row— 한 건 삭제가 그 행만 지우는가. V016 이전에도 통과하던 정확성 하한이며, rowid 전환이 이걸 깨뜨리지 않는지 본다.fts_v016_delete_plan_uses_rowid_not_a_scan— 쿼리 플랜이 rowid 등식을 FTS5 에 넘기는가(INDEX 0:=), 그리고chunk_id로는 여전히 못 넘기는가. 후자가 깨지면 이슈 #229 의 전제 자체가 바뀐 것이다.fts_v016_rebuild_preserves_rowid_and_korean_morphemes—rebuild_chunks_fts가 rowid 정렬과 형태소 접두를 둘 다 재현하는가, 그리고 재구축 뒤에도 삭제가 그림자를 찾는가.기존
fts_v009_matches_design_section_5_5_verbatim은fts_v016_…로 재조준했다. V007·V009 는 cold-upgrade 재생을 위해 남지만 더 이상 설계 문서와 비교되지 않는다 — V007 → V009 때와 같은 방식이다.비범위
chunks_fts_content) 550 MB 회수. external-content 전환이 필요하고, 그 쪽은contentless_delete=1로 가면snippet()이 죽는 문제도 따로 검토해야 한다.purge_deleted_workspace_path가 트랜잭션 없이 DELETE 세 개를 각각 autocommit 하는 것 (#230 후속에서 기록해 둔 별건).알아 둘 전제
chunks는chunk_id TEXT PRIMARY KEY라 INTEGER PRIMARY KEY 가 없다. SQLite 의 VACUUM 은 그런 테이블의 rowid 를 다시 매길 수 있고, 그러면 이 정렬이 깨진다. kebab 은 VACUUM 을 실행하지 않으며(코드베이스 전체에 없음), 사용자가 직접 실행했다면rebuild_chunks_fts가 복구 경로다. 이슈가 제안한 external-content 도 같은 전제를 깔고 있어 이 위험은 선택지 간 차이가 아니다.Assisted-by: Claude Code
2회차 리뷰가 1회차 지적 6건은 반영됐다고 확인했고, 대신 **1회차 대응으로 새로 넣은 `fts_shadow` 점검 자체에** HIGH 1건 + MEDIUM 2건을 찾았다. 1) 진단 명령이 없던 스토어를 만들었다 (HIGH) `SqliteStore::open` 은 마이그레이션은 안 돌리지만 `Connection::open` 이 파일을 만든다. KB 없는 머신에서 `kebab doctor` 한 번이 `<data_dir>/kebab.sqlite` 를 남겼고, 그러면 이후 `open_existing` 이 성공해 버려 `not_indexed` 로 갈렸어야 할 경로가 일반 오류로 바뀐다. `--readonly` 규약도 진단 명령이 깬다. `SQLITE_FILE` 을 공개하고 파일 존재를 먼저 확인한 뒤에만 연다. `doctor_does_not_create_a_store_where_none_exists` 로 고정. 2) V016 미적용 스토어를 고장 났다고 하고, 파괴적 조치를 권했다 (MEDIUM) doctor 는 마이그레이션을 돌리지 않으므로 V015 스토어를 그대로 읽는다. 그런데 V009 백필이 rowid 를 명시하지 않아서, 그 시점 `chunks.rowid` 에 구멍이 있던 스토어는 V015 에서 **이미 어긋나 있다**. 거기서는 삭제가 `chunk_id` 기준이라 무해한데, 새 점검이 `ok: false` → exit 3 + "`kebab reset` 후 재색인" 을 냈다. 바이너리를 올리고 doctor 부터 돌리는 자연스러운 순서에서 멀쩡한 KB 를 날리라고 안내한 셈이다. `migration_version()` 을 보고 V016 미만이면 `ok: true` + "마이그레이션 후 점검된다"로 간다. 실제 해법이 그것이다 — V016 의 명시 rowid repopulate 가 이 어긋남을 고쳐 준다. `doctor_does_not_call_a_pre_v016_store_broken` 으로 고정. 3) env override 를 무시해 엉뚱한 DB 를 검사했다 (MEDIUM) 바로 위 `data_dir_writable` 은 "Config::load 와 같은 precedence 유지"를 이유로 env 를 다시 얹는데 이 블록은 안 했다. `KEBAB_STORAGE_DATA_DIR` 을 쓰면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 같은 방식으로 맞췄다. 4) 잔가지 (LOW) - `None` 분기가 "스토어 없음 / 열기 실패 / 읽기 실패"를 전부 "KB 없음" 으로 뭉갰다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안 되므로 "점검하지 못했다" 를 따로 뒀다 (hint 가 있으므로 CLI 가 `!` 로 찍는다). - "표본 400행" 이 chunk 400개 미만인 스토어에서 거짓이었다. ASC/DESC 두 창이 겹치면 분자와 분모를 둘 다 두 번 셌다. UNION 으로 바꾸고 실제 표본 수를 함께 돌려준다 — 반환형이 `(checked, misaligned)` 다. - `pub const SQLITE_FILE` 위에 "Kept private" 이라는 옛 독 주석이 남아 있었다. - README 의 doctor 행에 `fts_shadow` 가 exit 3 을 낼 수 있다고 적었다. 새 플래그도 config 키도 없지만 **doctor 가 실패하는 새 사유**는 스크립트와 에이전트가 분기하는 사용자 표면이다. - `tasks/phase-2-lexical-search.md` 가 `kebab index --rebuild-fts` 를 산출물로 나열하고 있었다. 배선된 적 없는 명령이고 이 PR 이 "CLI 경로 없음"이라고 못박은 것과 어긋나서 정정했다. 실측 확인: env override 를 준 doctor 가 지정한 KB 를 검사하고("양끝 400행 표본"), V015 실제 KB(28,427 문서)는 "V016 적용 전 (현재 V015)" 로 나오며, KB 없는 경로에서는 파일을 남기지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF