feat(p2-1): fts-schema — chunks_fts + sync triggers + V002 migration #12

Merged
altair823 merged 1 commits from feat/p2-1-fts-schema into main 2026-05-01 04:58:57 +00:00
Owner

변경 요약

P2-1 fts-schema 작업입니다. SQLite FTS5 lexical 인덱스를 chunks 테이블 위에 얹기 위해 migrations/V002__fts.sql을 새로 만들고, kb-store-sqliterebuild_chunks_fts 헬퍼와 10개의 테스트를 추가했습니다.

무엇을 했는가

  • migrations/V002__fts.sql — design 문서 §5.5의 SQL 블록을 verbatim으로 ship합니다. chunks_fts virtual table (contentless FTS5, unicode61 remove_diacritics 2 tokenizer, chunk_id/doc_id는 UNINDEXED)과 세 개의 sync trigger (chunks_ai, chunks_ad, chunks_au)를 만들고, 마지막에 일회성 backfill INSERT로 기존 chunks 행을 그대로 미러링합니다.
  • crates/kb-store-sqlite/src/fts.rspub fn rebuild_chunks_fts(&Connection) -> Result<()> 추가. 향후 kb index --rebuild-fts CLI에서 쓸 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 경로를 자동으로 처리합니다.

테스트

  • fts 통합 테스트 10건 신설 (crates/kb-store-sqlite/tests/fts.rs):
    • V001만 적용 → chunks N개 seed → V002 verbatim 적용 후 backfill로 chunks_fts 행이 N개 생기고 MATCH로 정확히 매핑되는지 (literal cold-upgrade 경로)
    • INSERT/DELETE/UPDATE trigger 각각 chunks_fts에 정확히 전파되는지
    • rebuild_chunks_fts가 idempotent하고 drift에서 회복되는지
    • run_migrations을 두 번 호출해도 no-op인지 (refinery bookkeeping)
    • V002의 §5.5 블록과 design 문서 §5.5 SQL이 whitespace-normalized로 정확히 일치하는지 (양쪽에서 anchored extraction 후 비교 — 둘 중 하나만 변경하면 즉시 fail)
    • store drop 후 WAL/SHM 파일이 실제로 제거 가능한지 (handle leak portability canary)
  • workspace 전체 테스트 185건 모두 통과, cargo clippy --workspace --all-targets -- -D warnings clean.

후속 ���업으로 남긴 항목

이번 PR 범위 밖입니다. P2-2 진입 시점에 정리할 예정:

  • schema_meta 테이블 활성화 + refinery bookkeeping과의 정합성 (design §5.9).
  • IngestReport JSON 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 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_fts` virtual table (contentless FTS5, `unicode61 remove_diacritics 2` tokenizer, `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-fts` CLI에서 쓸 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 경로를 자동으로 처리합니다. ## 테스트 - fts 통합 테스트 10건 신설 (`crates/kb-store-sqlite/tests/fts.rs`): - V001만 적용 → chunks N개 seed → V002 verbatim 적용 후 backfill로 chunks_fts 행이 N개 생기고 MATCH로 정확히 매핑되는지 (literal cold-upgrade 경로) - INSERT/DELETE/UPDATE trigger 각각 chunks_fts에 정확히 전파되는지 - `rebuild_chunks_fts`가 idempotent하고 drift에서 회복되는지 - `run_migrations`을 두 번 호출해도 no-op인지 (refinery bookkeeping) - V002의 §5.5 블록과 design 문서 §5.5 SQL이 whitespace-normalized로 정확히 일치하는지 (양쪽에서 anchored extraction 후 비교 — 둘 중 하나만 변경하면 즉시 fail) - store drop 후 WAL/SHM 파일이 실제로 제거 가능한지 (handle leak portability canary) - workspace 전체 테스트 185건 모두 통과, `cargo clippy --workspace --all-targets -- -D warnings` clean. ## 후속 ���업으로 남긴 항목 이번 PR 범위 밖입니다. P2-2 진입 시점에 정리할 예정: - `schema_meta` 테이블 활성화 + refinery bookkeeping과의 정합성 (design §5.9). - `IngestReport` JSON 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 참고.
altair823 added 1 commit 2026-05-01 04:43:35 +00:00
Adds FTS5 lexical index for chunks per design §5.5: chunks_fts virtual
table (unicode61 remove_diacritics 2 tokenizer, contentless w/ UNINDEXED
chunk_id+doc_id) plus chunks_ai/chunks_ad/chunks_au triggers that mirror
every chunks mutation into chunks_fts inside the host transaction.

V002 ships the verbatim §5.5 SQL block plus a one-shot backfill INSERT
so existing P1 databases gain searchability without re-ingest. Refinery
bookkeeping makes double-apply naturally idempotent.

Adds rebuild_chunks_fts(&Connection) escape hatch for kb index
--rebuild-fts (CLI wiring deferred to a later task). Uses SAVEPOINT
instead of Transaction so callers can invoke from inside an outer
transaction; WAL serializes writers so no DELETE/INSERT race vs.
concurrent chunks mutators is possible.

Tests (10): V001-only → V002 cold-upgrade backfill (literal path),
chunks_ai/ad/au trigger sync, MATCH-token verification, rebuild
idempotency, drift recovery, double-run no-op, V002 ↔ design §5.5
verbatim diff guard (anchored extraction from both files), WAL/SHM
release on store drop. All 185 workspace tests pass.

Allowed deps respected (kb-core, kb-config, rusqlite, refinery — no
new deps). FTS query implementation deferred to p2-2 (lexical-retriever).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 reviewed 2026-05-01 04:44:19 +00:00
claude-reviewer-01 left a comment
Member

P2-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.

전반적으로 spec compliance와 code quality 양쪽 모두 단단합니다. 핵심 포인트만:

  • design §5.5의 SQL 블록을 V002에 verbatim으로 박고, 그 등가성을 anchor 기반 추출로 양방향 강제하는 테스트가 있어서 design 문서와 마이그레이션이 서로 drift 할 수 없습니다.
  • rebuild_chunks_fts&Connection + SAVEPOINT 패턴으로 구현되어 outer transaction 안에서 호출 가능하고, WAL writer 직렬화 덕분에 DELETE/INSERT 사이의 race가 구조적으로 차단되는 부분이 doc에 명시되어 있습니다.
  • cold-upgrade 테스트(V001 적용 → chunks seed → V002 적용)가 refinery가 한 번에 적용해버리는 경로를 우회해서 V002의 literal backfill INSERT를 직접 검증합니다.

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. blocker / must-fix 없습니다. 머지 진행해도 됩니다.

P2-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only. 전반적으로 spec compliance와 code quality 양쪽 모두 단단합니다. 핵심 포인트만: - design §5.5의 SQL 블록을 V002에 verbatim으로 박고, 그 등가성을 anchor 기반 추출로 양방향 강제하는 테스트가 있어서 design 문서와 마이그레이션이 서로 drift 할 수 없습니다. - `rebuild_chunks_fts`이 `&Connection` + SAVEPOINT 패턴으로 구현되어 outer transaction 안에서 호출 가능하고, WAL writer 직렬화 덕분에 DELETE/INSERT 사이의 race가 구조적으로 차단되는 부분이 doc에 명시되어 있습니다. - cold-upgrade 테스트(V001 적용 → chunks seed → V002 적용)가 refinery가 한 번에 적용해버리는 경로를 우회해서 V002의 literal backfill INSERT를 직접 검증합니다. 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 합니다.

`# 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 실패가 원본 에러를 가리지 않도록 한 처리도 합리적입니다.

`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의 동작은 독립적으로 검증됩니다.

이 테스트가 진짜 의미 있는 테스트입니다. 단순히 `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 수준 차이는 잡아냅니다. 적절한 균형입니다.

`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.5 heading → 다음 ```sql 펜스 → CREATE VIRTUAL TABLE chunks_fts ~ 마지막 END; 슬라이스. design 문서가 §5.5 위치를 옮기거나 섹션 제목을 바꾸거나 SQL을 손대면 즉시 fail합니다. 양쪽 anchor를 모두 명시한 panic 메시지도 디버깅에 도움됩니다.

design 문서 anchor 기반 추출이 견고하게 짜였습니다 — `### 5.5` heading → 다음 ```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됩니다.

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 트랜잭션 안에 깔끔하게 표현됩니다.

Backfill INSERT을 마이그레이션 파일 자체에 inline으로 둔 결정이 좋습니다. refinery가 V002를 정확히 한 번만 적용하므로 별도 backfill 코드 경로 없이 자연스럽게 idempotent해집니다. P1에서 이미 ingest한 데이터베이스가 V002 적용만으로 검색 가능 상태가 되는 cold-upgrade 시나리오가 단일 SQL 트랜잭션 안에 깔끔하게 표현됩니다.
altair823 merged commit 5aef478b96 into main 2026-05-01 04:58:57 +00:00
altair823 deleted branch feat/p2-1-fts-schema 2026-05-01 04:58:58 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#12