fix(kebab-store-vector): close P7-3 vector orphan caveat #41

Merged
altair823 merged 2 commits from fix/vector-orphan-cleanup into main 2026-05-02 12:38:48 +00:00
Owner

요약

HOTFIXES 2026-05-02 P7-3vector store cleanup caveat 닫음. P7-3 PR #40 의 storage UNIQUE bug fix 가 SQLite 측만 sweep 했고 LanceDB 의 vector 는 디스크에 잔존했음. 본 PR 이 그 gap 을 메움.

핵심 변경

  • VectorStore::delete_by_chunk_ids trait 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)

  1. whitepaper.pdf 첫 ingest → chunk_ids = {f616…, 4e0f…} 가 vector store 에 존재.
  2. byte 변경 후 re-ingest → new doc_id = 3741… + new chunk_ids = {ed0c…, e13c…}.
  3. kebab search --mode vector "REWRITTEN chapter two" → 새 chunk_ids 만 hit.
  4. 옛 query "Edited page two body" → 옛 chunk_ids 가 더 이상 vector store 에 없음 (의미적으로 가장 가까운 새 chunks 가 hit).

old_chunks_visible: False 두 query 모두에서 확인.

HOTFIXES + SMOKE 갱신

  • HOTFIXES entry 의 "vector store cleanup" 절: "deferred" → "closed by follow-up PR".
  • SMOKE 의 알려진 동작 한 줄: "옛 vector 잔존, 디스크 cleanup 은 P+" → "두 store 모두 정합 — 옛 본문 검색 시 옛 chunks 가 더 이상 surface 되지 않음".

검증

  • cargo test -p kebab-app --test pdf_pipeline 9 passed (회귀 0)
  • cargo test -p kebab-store-sqlite 33+ passed
  • cargo test -p kebab-store-vector 7 passed
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • 위 smoke 시나리오 release binary 로 통과

Test plan

  • trait + impl 컴파일 + 회귀 0
  • release binary smoke: edit 후 vector search 가 옛 chunk_ids 안 surface
  • HOTFIXES + SMOKE 갱신
  • 사용자 검수 (필요 시)
## 요약 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_ids`** trait 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) 1. whitepaper.pdf 첫 ingest → `chunk_ids = {f616…, 4e0f…}` 가 vector store 에 존재. 2. byte 변경 후 re-ingest → `new doc_id = 3741…` + `new chunk_ids = {ed0c…, e13c…}`. 3. `kebab search --mode vector "REWRITTEN chapter two"` → 새 chunk_ids 만 hit. 4. 옛 query `"Edited page two body"` → 옛 chunk_ids 가 더 이상 vector store 에 없음 (의미적으로 가장 가까운 새 chunks 가 hit). `old_chunks_visible: False` 두 query 모두에서 확인. ## HOTFIXES + SMOKE 갱신 - HOTFIXES entry 의 "vector store cleanup" 절: "deferred" → "closed by follow-up PR". - SMOKE 의 알려진 동작 한 줄: "옛 vector 잔존, 디스크 cleanup 은 P+" → "두 store 모두 정합 — 옛 본문 검색 시 옛 chunks 가 더 이상 surface 되지 않음". ## 검증 - `cargo test -p kebab-app --test pdf_pipeline` 9 passed (회귀 0) - `cargo test -p kebab-store-sqlite` 33+ passed - `cargo test -p kebab-store-vector` 7 passed - `cargo clippy --workspace --all-targets -- -D warnings` clean - 위 smoke 시나리오 release binary 로 통과 ## Test plan - [x] trait + impl 컴파일 + 회귀 0 - [x] release binary smoke: edit 후 vector search 가 옛 chunk_ids 안 surface - [x] HOTFIXES + SMOKE 갱신 - [ ] 사용자 검수 (필요 시)
altair823 added 1 commit 2026-05-02 12:33:53 +00:00
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>
claude-reviewer-01 requested changes 2026-05-02 12:35:37 +00:00
claude-reviewer-01 left a comment
Member

회차 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 정직 갱신 모두 좋음.

회차 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") 를 정확히 명시 — 미래에 누군가 호출 위치를 옮기지 못하게 하는 자물쇠.

(칭찬) `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 의 default Ok(()) no-op 가 backward compat 의 핵심입니다. 기존 MockVectorStore / 테스트 fake / 다른 impl 이 그대로 컴파일되면서, 운영 LanceDB 만 실제 삭제 동작. trait extension 이 SemVer 적으로 minor 인 이유를 코드 한 줄로 표현.

(칭찬) `delete_by_chunk_ids` 의 default `Ok(())` 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 가 안심 가능.

(칭찬) `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 이 작업 단위 안에 정직하게 표현.

(칭찬) 다중 테이블 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: 둘 중 택일.

  • (A, 추천) 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");
  • (B) Lance Table::delete 가 prepared parameters 를 지원하면 그것 사용. 0.23 시점에 SQL string 만 지원하는 것 같으므로 (A) 가 현실적.

(A) 한 줄 추가만으로도 미래 contributor 가 "왜 단순 format!(\"'{}'\") 인지" 의 의도를 코드에서 즉시 볼 수 있음.

(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: 둘 중 택일. - (A, 추천) `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");` - (B) Lance `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 백로그 가 더 줄어든 셈.

(칭찬) 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 백로그 가 더 줄어든 셈.
altair823 added 1 commit 2026-05-02 12:36:25 +00:00
`delete_by_chunk_ids` 의 SQL IN(...) 입력에 대한 hex invariant 를
`debug_assert!` 로 명시. `id_for_chunk` 가 항상 hex 를 emit 하지만
`ChunkId(pub String)` 가 hand-construct 가능해 미래 contributor 가
tainted 문자열을 넣을 가능성 차단. dev / test build 에서 즉시
panic 으로 잡힘 (release 는 그대로 SQL 진행 — 운영 경로는 hex 가
강제되므로 false positive 없음).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-02 12:36:44 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 지적 1건 (debug_assert hex invariant) 반영. clippy clean, smoke 회귀 0. 머지 가능. P7-3 vector orphan caveat 완전 닫힘.

회차 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 사용) 을 즉시 추출 가능.

(칭찬) `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 사용) 을 즉시 추출 가능.
altair823 merged commit aedd5b85c9 into main 2026-05-02 12:38:48 +00:00
altair823 deleted branch fix/vector-orphan-cleanup 2026-05-02 12:39:05 +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#41