feat(p2-1): fts-schema — chunks_fts + sync triggers + V002 migration #12
Reference in New Issue
Block a user
Delete Branch "feat/p2-1-fts-schema"
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?
변경 요약
P2-1 fts-schema 작업입니다. SQLite FTS5 lexical 인덱스를 chunks 테이블 위에 얹기 위해
migrations/V002__fts.sql을 새로 만들고,kb-store-sqlite에rebuild_chunks_fts헬퍼와 10개의 테스트를 추가했습니다.무엇을 했는가
migrations/V002__fts.sql— design 문서 §5.5의 SQL 블록을 verbatim으로 ship합니다.chunks_ftsvirtual table (contentless FTS5,unicode61 remove_diacritics 2tokenizer,chunk_id/doc_id는 UNINDEXED)과 세 개의 sync trigger (chunks_ai,chunks_ad,chunks_au)를 만들고, 마지막에 일회성 backfill INSERT로 기존 chunks 행을 그대로 미러링합니다.crates/kb-store-sqlite/src/fts.rs—pub fn rebuild_chunks_fts(&Connection) -> Result<()>추가. 향후kb index --rebuild-ftsCLI에서 쓸 escape hatch입니다 (CLI wiring은 이번 PR 범위 밖).&mut Connection이 아니라&Connection을 받도록 spec이 못 박아서 SAVEPOINT 패턴으로 atomic rebuild를 구현했습니다 — outer transaction 안에서도 호출할 수 있다는 보너스가 따라옵니다. WAL mode가 writer를 직렬화하므로 DELETE/INSERT 사이에 다른 transaction이 끼어들어 chunks_fts에 중복을 만드는 race는 발생하지 않습니다 (모듈 doc에# Concurrency항목으로 명시).왜 P1-6에서 분리했는가
P1은 관계형 스키마와 asset I/O에 집중하고, FTS는 P2의 lexical retriever가 의존하는 인덱스 레이어로 분리했습니다. 더 중요한 실용적 이유는 마이그레이션 스토리입니다 — V002를 additive migration으로 따로 ship하면 이미 P1에서 ingest한 데이터베이스가 chunks를 다시 만들지 않고도 검색 가능 상태로 업그레이드됩니다. V002 안의 backfill INSERT가 그 cold-upgrade 경로를 자동으로 처리합니다.
테스트
crates/kb-store-sqlite/tests/fts.rs):rebuild_chunks_fts가 idempotent하고 drift에서 회복되는지run_migrations을 두 번 호출해도 no-op인지 (refinery bookkeeping)cargo clippy --workspace --all-targets -- -D warningsclean.후속 ���업으로 남긴 항목
이번 PR 범위 밖입니다. P2-2 진입 시점에 정리할 예정:
schema_meta테이블 활성화 + refinery bookkeeping과의 정합성 (design §5.9).IngestReportJSON Schema validation (docs/wire-schema/v1/ingest_report.schema.json대상).kb_core::AssetId::try_new(s) -> Result<Self>— asset_id 32-hex 검증을 한 곳에 모으기 위한 생성자.변경 파일
migrations/V002__fts.sql(신규)crates/kb-store-sqlite/src/fts.rs(신규)crates/kb-store-sqlite/src/lib.rs(mod fts+pub use rebuild_chunks_fts)crates/kb-store-sqlite/tests/fts.rs(신규, 10 tests)design §5.5 / §9 참고.
P2-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
전반적으로 spec compliance와 code quality 양쪽 모두 단단합니다. 핵심 포인트만:
rebuild_chunks_fts이&Connection+ SAVEPOINT 패턴으로 구현되어 outer transaction 안에서 호출 가능하고, WAL writer 직렬화 덕분에 DELETE/INSERT 사이의 race가 구조적으로 차단되는 부분이 doc에 명시되어 있습니다.inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. blocker / must-fix 없습니다. 머지 진행해도 됩니다.
@@ -0,0 +36,4 @@/// correctly. A panic inside the DELETE/INSERT closure leaks the/// savepoint name on the connection until the connection is dropped;/// that's acceptable because the next caller's `SAVEPOINT/// rebuild_chunks_fts` legally shadows the leaked one.# Concurrency항목이 잘 정리되어 있습니다. 특히SAVEPOINT시점에 reserved write lock이 잡혀서 WAL이 writer를 직렬화한다는 점, 그래서 DELETE/INSERT 사이에 chunks insert가 끼어들어 chunks_fts에 중복을 만드는 race가 발생할 수 없다는 추론을 명시한 부분이 핵심입니다. 향후 "성능 위해 lock을 늦추자"는 변경이 들어올 때 이 문서가 먼저 protect 합니다.@@ -0,0 +55,4 @@).context("repopulate chunks_fts from chunks")?;Ok(())})();ROLLBACK TO+RELEASE두 단계로 정리하는 패턴이 SQLite 공식 문서와 일치합니다 (ROLLBACK TO만으로는 savepoint 이름이 stack에 남고RELEASE가 pop). 두 호출 모두let _ =로 묶어서 cleanup 실패가 원본 에러를 가리지 않도록 한 처리도 합리적입니다.@@ -0,0 +62,4 @@////// This is the literal cold-upgrade path: V001-shipped database, V002/// applied on top, existing chunks become searchable without re-ingest./// The trigger-based mirror (chunks_ai) is covered by the §2 tests.이 테스트가 진짜 의미 있는 테스트입니다. 단순히
run_migrations을 호출하면 refinery가 V001+V002를 한 번에 적용해서 V002의 literal backfill 경로를 우회하게 되는데, 여기서는 V001 SQL만 먼저execute_batch한 다음 chunks N개를 seed하고 V002 SQL을 별도로 적용해서 "P1 데이터베이스가 P2-1으로 cold-upgrade 되는 경로"를 그대로 재���합니다. trigger AI가 버그가 있어도 backfill INSERT의 동작은 독립적으로 검증됩니다.@@ -0,0 +319,4 @@/// blank-line counts while still catching token-level changes.fn normalize_ws(s: &str) -> String {s.split_whitespace().collect::<Vec<_>>().join(" ")}split_whitespace+join(" ")로 whitespace를 normalize한 비교는 cosmetic drift (빈 줄 개수, 들여쓰기 차이 등)는 통과시키고 token 수준 차이는 잡아냅니다. 적절한 균형입니다.@@ -0,0 +368,4 @@.rfind("END;").expect("§5.5 FTS slice must terminate with `END;`");fts_slice[..last_end + "END;".len()].to_string()}design 문서 anchor 기반 추출이 견고하게 짜였습니다 —
### 5.5heading → 다음 ```sql 펜스 →CREATE VIRTUAL TABLE chunks_fts~ 마지막END;슬라이스. design 문서가 §5.5 위치를 옮기거나 섹션 제목을 바꾸거나 SQL을 손대면 즉시 fail합니다. 양쪽 anchor를 모두 명시한 panic 메시지도 디버깅에 도움됩니다.@@ -0,0 +475,4 @@if db_path.exists() {std::fs::remove_file(&db_path).expect("main DB file should be removable after store drop");}WAL/SHM cleanup 테스트가 이제 실제로 assert를 합니다. Linux에서는 unlink가 open file에서도 성공해서 mostly cosmetic이지만 (코멘트에도 그렇게 적혀있고), Windows portability canary 역할은 정확히 합니다 — 누가 connection drop을 빠뜨리면 mandatory file locking 환경에서 즉시 detect됩니다.
@@ -0,0 +44,4 @@-- runs V002 exactly once via its bookkeeping table, so this INSERT is-- naturally idempotent across restarts.INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text)SELECT chunk_id, doc_id, heading_path_json, text FROM chunks;Backfill INSERT을 마이그레이션 파일 자체에 inline으로 둔 결정이 좋습니다. refinery가 V002를 정확히 한 번만 적용하므로 별도 backfill 코드 경로 없이 자연스럽게 idempotent해집니다. P1에서 이미 ingest한 데이터베이스가 V002 적용만으로 검색 가능 상태가 되는 cold-upgrade 시나리오가 단일 SQL 트랜잭션 안에 깔끔하게 표현됩니다.