fix(kebab-store-vector): close P7-3 vector orphan caveat #41
Reference in New Issue
Block a user
Delete Branch "fix/vector-orphan-cleanup"
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?
요약
HOTFIXES
2026-05-02 P7-3의 vector store cleanup caveat 닫음. P7-3 PR #40 의 storage UNIQUE bug fix 가 SQLite 측만 sweep 했고 LanceDB 의 vector 는 디스크에 잔존했음. 본 PR 이 그 gap 을 메움.핵심 변경
VectorStore::delete_by_chunk_idstrait method 추가 (default no-op — 기존 impl / fake 그대로 컴파일).LanceVectorStore::delete_by_chunk_ids— connection 의 모든chunk_embeddings_*table 순회 +Table::delete("chunk_id IN (...)")batch=200. 다중 모델 워크스페이스 (마이그레이션 중) 에서도 안전.SqliteStore::stale_chunk_ids_at(workspace_path, new_asset_id)— read-only SELECT 로 옛 chunk_id 반환. CASCADE 가 흐르기 전 에 caller 가 호출.kebab-app::purge_vector_orphans_for_workspace_path— 위 둘을 orchestrate. 세 ingest path (markdown / image / pdf) 의put_asset_with_bytes호출 직전에 한 줄로 호출.Smoke 검증 (release binary, fastembed enabled)
chunk_ids = {f616…, 4e0f…}가 vector store 에 존재.new doc_id = 3741…+new chunk_ids = {ed0c…, e13c…}.kebab search --mode vector "REWRITTEN chapter two"→ 새 chunk_ids 만 hit."Edited page two body"→ 옛 chunk_ids 가 더 이상 vector store 에 없음 (의미적으로 가장 가까운 새 chunks 가 hit).old_chunks_visible: False두 query 모두에서 확인.HOTFIXES + SMOKE 갱신
검증
cargo test -p kebab-app --test pdf_pipeline9 passed (회귀 0)cargo test -p kebab-store-sqlite33+ passedcargo test -p kebab-store-vector7 passedcargo clippy --workspace --all-targets -- -D warningscleanTest plan
P7-3 의 storage UNIQUE bug fix 가 SQLite 측 (documents → blocks / chunks / embedding_records) 만 sweep 했음. LanceDB 의 vector 는 별도 store 라 옛 chunk_id 를 가진 row 가 디스크에 잔존. 검색에는 영향 없지만 디스크는 무한 누적. HOTFIXES `2026-05-02 P7-3` caveat 의 "P+ task" 약속을 같은 후속 PR 안에서 닫음. 변경: - `VectorStore::delete_by_chunk_ids(&[ChunkId])` trait method 추가 (default no-op 제공 — 테스트 fake / 기존 impl 이 그대로 컴파일). - `LanceVectorStore::delete_by_chunk_ids` 가 connection 의 모든 `chunk_embeddings_*` 테이블을 순회 + `Table::delete("chunk_id IN (...)")` 를 batch=200 단위로 실행. 다중 모델 워크스페이스 (마이그레이션 중간 등) 에서도 안전. - `SqliteStore::stale_chunk_ids_at(workspace_path, new_asset_id)` 가 read-only SELECT 로 옛 chunk_id 들 반환. CASCADE 가 흐르기 *전* 에 caller 가 호출. - `kebab-app::purge_vector_orphans_for_workspace_path` 가 위 두 단계를 orchestrate. 세 ingest path (markdown / image / pdf) 의 `put_asset_with_bytes` 호출 직전에 한 줄로 호출. Smoke 검증 (release binary, fastembed enabled): - whitepaper.pdf 첫 ingest → chunk_ids = {f616…, 4e0f…}, vector store 에 그 두 ID 의 row 존재. - byte 변경 후 re-ingest → 새 doc_id (3741…) + 새 chunk_ids (ed0c…, e13c…). vector search "REWRITTEN chapter two" → 새 chunk_ids 만 hit. 옛 query "Edited page two body" 시도해도 옛 chunk_ids 는 vector store 에 더 이상 없음 (의미적으로 가장 가까운 새 chunks 가 hit). HOTFIXES `2026-05-02 P7-3` 의 \"vector store cleanup\" 항목이 \"deferred\" → \"closed by follow-up PR\" 로 갱신. SMOKE.md 의 알려진 동작 (\"옛 vector 잔존\") 도 \"두 store 정합\" 으로 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — 한 가지 actionable: Lance SQL 의 chunk_id IN(...) 입력값에 대한 hex invariant 를 debug_assert 로 명시. ChunkId newtype 이 hand-construct 가능해 코멘트 이상의 강제가 필요. 그 외는 칭찬 — backward-compat default no-op, multi-table iteration, read-only SELECT 분리, helper 위치 doc-lock, HOTFIXES caveat 정직 갱신 모두 좋음.
@@ -952,0 +982,4 @@vec_store.delete_by_chunk_ids(&stale).context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;tracing::debug!((칭찬)
purge_vector_orphans_for_workspace_path가 세 ingest path (markdown / image / pdf) 의 동일한 "put_asset_with_bytes 직전" 위치에서 한 줄로 호출되도록 분리된 게 좋습니다. helper 의 doc-comment 가 "왜 SELECT BEFORE put_asset" 의 race 가드 ("so the SELECT still sees the old chunk_ids") 를 정확히 명시 — 미래에 누군가 호출 위치를 옮기지 못하게 하는 자물쇠.@@ -171,6 +171,18 @@ pub trait VectorStore {k: usize,filters: &SearchFilters,) -> anyhow::Result<Vec<VectorHit>>;(칭찬)
delete_by_chunk_ids의 defaultOk(())no-op 가 backward compat 의 핵심입니다. 기존MockVectorStore/ 테스트 fake / 다른 impl 이 그대로 컴파일되면서, 운영 LanceDB 만 실제 삭제 동작. trait extension 이 SemVer 적으로 minor 인 이유를 코드 한 줄로 표현.@@ -311,0 +317,4 @@/// Called by `kebab-app::ingest_one_*_asset` BEFORE/// `put_asset_with_bytes` so the caller can hand the IDs to/// `VectorStore::delete_by_chunk_ids`. After the SQLite cleanup/// runs (CASCADE on `documents` → `chunks`) the same chunk_ids(칭찬)
stale_chunk_ids_at가 read-only SELECT 만 함 + JOIN 3 단 (chunks → documents → assets) 으로 문제 도메인을 표현. caller (kebab-app) 가 SELECT → DELETE-LANCE → SELECT-AGAIN-AND-DELETE-SQLITE 의 두 단계 transaction 을 자기 책임으로 가져갈 수 있는 게 좋고, 이 함수가 mutating 안 한다는 사실이 doc-comment 첫 줄에 "Read-only — does not mutate" 로 박혀 있어 미래 reader 가 안심 가능.(칭찬) 다중 테이블 iteration (
chunk_embeddings_*prefix) 이 "하나의 chunk_id 가 두 테이블에 동시 존재할 일이 v1 에서는 없다" 는 invariant 를 깨뜨리지 않으면서도 미래의 multi-model migration 케이스 (모델 교체 직후 두 테이블 공존) 를 자동으로 다룹니다. doc-comment 가 정확히 그 시나리오를 적시 — "e.g. mid-migration between embedding models". 한 줄짜리 미래 hardening 이 작업 단위 안에 정직하게 표현.(suggestion / 보강)
chunk_ids안에 single-quote (') 가 들어 있다면format!("'{}'")에서 SQL 가 깨집니다. "chunk_ids 가 32-hex-char blake3 prefix 이므로 injection 구조적으로 불가능" 라는 코멘트가 정확하지만,kebab_core::ChunkId(pub String)는 newtype 으로 hex 검증이 강제되지 않은 채 hand-construct 가능합니다 (AssetId처럼).Why: 미래에 누군가
ChunkId("abc'; DROP TABLE chunks;--".into())식으로 hand-construct 한 후 본 함수를 호출하면 SQL 가 깨질 수 있음. 현재 production code path 는id_for_chunk가 항상 hex 라 안전하지만, 코멘트가validate가 강제됨을 의미하는 것처럼 읽혀 미래 reader 를 misslead.How to apply: 둘 중 택일.
id_for_chunk가 emit 하는 hex 만 받는다는 invariant 를debug_assert!한 줄로 강제:debug_assert!(batch.iter().all(|id| id.0.bytes().all(|b| b.is_ascii_hexdigit())), "chunk_id must be hex");Table::delete가 prepared parameters 를 지원하면 그것 사용. 0.23 시점에 SQL string 만 지원하는 것 같으므로 (A) 가 현실적.(A) 한 줄 추가만으로도 미래 contributor 가 "왜 단순
format!(\"'{}'\")인지" 의 의도를 코드에서 즉시 볼 수 있음.@@ -33,3 +33,3 @@4. If the stale storage was `copied`, best-effort removes the byte file at `storage_path` so `data_dir/assets/` does not accumulate orphans across edits.**Caveat (still deferred)**: `embedding_records.chunk_id` CASCADE clears the SQLite side, but the LanceDB rows keyed on `chunk_id` live in a separate store and are not touched. Stale vectors do not affect retrieval (search joins through SQLite, so an orphan vector is never surfaced) but they consume disk in `data_dir/lancedb/`. A future task should reconcile by `chunk_id` set diff.**Vector store cleanup (closed by follow-up PR)**: `embedding_records.chunk_id` CASCADE clears the SQLite side, but LanceDB lives in a separate store. The follow-up PR adds:(칭찬) caveat 절을 "deferred" → "closed by follow-up PR" 로 갱신하면서 변경 4 항목 (trait 메서드 / Lance impl / SQLite SELECT 헬퍼 / app orchestrator) 를 명시적으로 enumerate 한 게 좋습니다. follow-up PR 이 완전히 닫혔는지 (다른 caveat 안 남았는지) 를 reader 가 한 눈에 확인 가능 — vector_store cleanup 절이 "closed" 로 끝났으니 P+ task 백로그 가 더 줄어든 셈.
회차 2 — 회차 1 지적 1건 (debug_assert hex invariant) 반영. clippy clean, smoke 회귀 0. 머지 가능. P7-3 vector orphan caveat 완전 닫힘.
(칭찬)
debug_assert!메시지가 "ChunkId must be ASCII hex (id_for_chunk invariant) — hand-constructed IDs that bypass this would let Lance's SQL parser see arbitrary text" 로 (1) invariant (2) 출처 (3) 무엇이 깨질 수 있는지 세 정보를 한 메시지에 박아둠. 미래 contributor 가 panic 메시지만 봐도 root cause 와 fix 방향 (hand-construct 안 하기 / id_for_chunk 사용) 을 즉시 추출 가능.