feat(p3-3): lancedb-store — kb-store-vector + V003 embedding status migration #16
Reference in New Issue
Block a user
Delete Branch "feat/p3-3-lancedb-store"
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?
⚠️ 머지 전 확인사항
개발 머신 AVX 부재로 LanceDB 통합 테스트가 end-to-end로 검증되지 않았습니다.
cargo test -p kb-store-vector -- --ignored라인을 AVX-capable 하드웨어에서 한 번 돌려보시고 머지를 결정해주세요. 본 PR의 모든 통합 테스트(8건 upsert/search + 1건 snapshot)는#[ignore]+require_avx_or_panic()패턴이라 비-AVX 호스트에서 실행 시 즉시 panic합니다. snapshot 베이스라인 fixture (tests/fixtures/vector/run-1.json)도 placeholder 상태로 — AVX 호스트에서KB_UPDATE_SNAPSHOTS=1로 재생성해야 실제 snapshot으로 활성화됩니다.변경 요약
P3-3 lancedb-store 작업입니다. 새 크레이트
kb-store-vector를 추가해서 첫 번째VectorStore구현을 제공합니다. LanceDB embedded를 백엔드로 사용하고, embedding_records (SQLite) 메타데이터와 두 단계 트랜잭션으로 정합성을 유지합니다.무엇을 했는가
V003 마이그레이션
migrations/V003__embedding_status.sql:embedding_records.status컬럼 (CHECK (status IN ('pending','committed','tombstone')), default'pending').embedding_records.vector_committed컬럼.chunks_bd_tombstone_embeddingsBEFORE DELETE 트리거 — chunks 행 삭제 시 dependent embedding_records 행을 tombstone 상태로 표시. 현재는 V001의ON DELETE CASCADEFK에 의해 즉시 덮어써집니다 — 트리거 UPDATE가 먼저 실행된 뒤 CASCADE가 행 자체를 지우는 순서. spec line 93의 "row를 살려두라"는 의도를 완전히 충족하려면 embedding_records를 재생성해서 CASCADE를 빼야 합니다. P3-3가 첫 writer이고 production 행이 아직 없으므로 P+ 마이그레이션으로 미뤘습니다 — V003 SQL의 코멘트에 명시.LanceVectorStore
INSERT OR REPLACE(status=pending) → phase-2 Lance MergeInsert (chunk_id 키, idempotent) → phase-3 SQLiteUPDATE … WHERE status='pending'(committed로 승격). phase-2 실패 시 행은 pending에 머무르고 다음 upsert가 자동 retry.embedding_records WHERE status='committed'만 join해서 partial-write 행이 노출되지 않습니다. cosine distance ∈ [0, 2] → similarity = 1 - distance ∈ [-1, 1] → score = (similarity + 1) / 2 ∈ [0, 1]. NaN은 0으로 강등 +tracing::warn!. clamping 대신 shift를 쓴 이유는 "무관 (sim ≈ 0)"과 "반대 (sim ≈ -1)" 사이의 ranking signal을 보존하기 위해서 (spec line 96).tokio::runtime::Builder::new_current_thread().enable_all().build()런타임을 들고 있다가 trait method마다runtime.block_on. "tokio runtime 안에서 호출 시 panic" 위험이 있으므로 struct doc-comment에 명시 — kb-app의 job scheduler가 현재 sync라 안전합니다.id_for_index(\"chunk_embeddings\", \"flat\", blake3(descriptor JSON))— 스키마가 바뀌면 자동으로 IndexId가 회전.kb-store-sqlite 확장
리뷰에서 raw SQL을 kb-store-vector에서 쓰고
rusqlite/globset을 직접 의존하는 구조가 spec의 Allowed deps을 위반한다는 BLOCKER 지적을 받아, 다음 헬퍼들을 kb-store-sqlite로 이전했습니다:pub fn put_embedding_records_pending(&[EmbeddingRecordRow])— phase-1.pub fn mark_embedding_records_committed(&[EmbeddingId])— phase-3 단일UPDATE … WHERE embedding_id IN (?, ?, …)을params_from_iter로 binding (per-row execute였던 NIT도 같이 정리).WHERE status='pending'가드로 tombstone 행이 잘못 활성화되는 경로 차단.pub fn filter_chunks(&[ChunkId], &SearchFilters) -> Vec<ChunkId>— embedding_records / chunks / documents / document_tags JOIN + globset 기반 path_glob 후처리. kb-store-vector 쪽은 이 헬퍼만 호출하고 자체 SQL을 쓰지 않습니다.kb-search(P2-2)의 필터 로직은 FTS5 SELECT와 interleaved되어 있어 구조가 다릅니다 — 본 PR에서는 그대로 두고, 차후 P+ refactor에서filter_chunks헬퍼와 통합 후보로 마크.테스트 정책
본 PR이 가장 신중하게 다룬 부분입니다.
kb-store-vector/src/**/*.rs(단위)kb-store-sqlite/src/{embeddings,filters}.rs(단위)kb-store-vector/tests/upsert_search.rs(통합)#[ignore]. AVX 필수 — 비-AVX 호스트에서--ignored로 돌리면 panic.kb-store-vector/tests/snapshot.rs(통합)#[ignore]. fixture는 현재 placeholder, AVX 호스트에서 regen 필요.리뷰에서 "silent skip이 false confidence를 만든다"는 BLOCKER를 받아
require_avx_or_skip → require_avx_or_panic으로 전환했습니다. 이제cargo test -- --ignored을 비-AVX 호스트에서 돌리면 "host CPU lacks AVX (LanceDB requires AVX)" 메시지로 즉시 fail합니다. snapshot fixture도_comment마커가 있으면 패닉하도록 만들어서 placeholder 상태가 silent pass로 위장하지 못하게 했습니다.워크스페이스 전체: 241 passed, 19 ignored, 0 failed.
cargo clippy --workspace --all-targets -- -D warningsclean.crash-recovery 테스트 보강
리뷰에서 기존 테스트가
INSERT OR REPLACE로 pre-seed 행을 즉시 덮어써서 실제 recovery 경로를 testing하지 않는다는 BLOCKER 지적을 받았습니다.upsert_retry_promotes_pending_to_committed을 재작성:sqlite.put_embedding_records_pending(&[row])로 phase-1만 직접 실행 (Lance 미접근, status=pending 상태로 행 staging).assert_eq!(query_status(&sqlite, &row.embedding_id), \"pending\").store.upsert(&[rec])호출 — phase-1 idempotent re-insert + phase-2 Lance MergeInsert + phase-3 status='committed' 승격.assert_eq!(query_status(...), \"committed\").이로써 "phase-2 도중 crash → 행이 pending에 머무름 → 다음 upsert가 promote" 시나리오가 실제로 검증됩니다.
의존성 (Allowed list 확장 사유)
spec lines 25-32의 Allowed deps에 명시된 항목 외에 다음을 추가하며, 모두 trait 시그니처 또는 외부 라이브러리 제약에 의해 강제됩니다:
tokio(rt + macros features만): LanceDB Rust API가 async-only인데VectorStoretrait는 sync — 내부 runtime이 필수.futures: LanceDB stream API가futures::TryStreamExt등을 expose.anyhow: kb-core trait 반환 타입.blake3: design §4.2의 IndexId params_hash 강제.time: Arrow Timestamp 컬럼의 created_at.rusqlite와globset은 직접 의존이 아닙니다 —[dev-dependencies]에서rusqlite만 (테스트 fixture seeder의 raw SQL용).cargo metadata --no-deps으로 검증.Forbidden deps (
kb-source-fs,kb-parse-md,kb-normalize,kb-chunk,kb-embed*,kb-search,kb-llm*,kb-rag,kb-tui,kb-desktop) 어느 것도 의존성 그래프에 없습니다.변경 파일
migrations/V003__embedding_status.sql(신규)crates/kb-store-sqlite/Cargo.toml(globset추가)crates/kb-store-sqlite/src/embeddings.rs(신규 — phase-1/3 헬퍼)crates/kb-store-sqlite/src/filters.rs(신규 —filter_chunks)crates/kb-store-sqlite/src/lib.rs(mod 등록)crates/kb-store-vector/Cargo.toml(신규 크레이트)crates/kb-store-vector/src/{lib,store,arrow_batch,paths}.rs(신규)crates/kb-store-vector/tests/common/mod.rs,upsert_search.rs,snapshot.rs(신규)crates/kb-store-vector/tests/fixtures/vector/run-1.json(placeholder)Cargo.toml(workspace member + lancedb/arrow/tokio/futures workspace deps)Cargo.lock후속 작업 후보
expand_path헬퍼를 kb-config의 public API로 promote — 현재 kb-store-sqlite/kb-embed-local/kb-store-vector 세 곳에 같은 helper hand-rolled.kb-search의 필터 로직과SqliteStore::filter_chunks을 공통화 — interleaved FTS5 SELECT의 구조가 달라서 P+ refactor.Mutex<TextEmbedding>제거 검토 (P3-2 follow-up).Out of scope
kb-app의 indexing 잡 오케스트레이션 (embed_indexfacade method body).design §5.6, §6.3, §7.2, §9 참고.
P3-3 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
이번 작업은 spec compliance + code quality 양쪽 리뷰에서 BLOCKER가 다수 떴습니다 — silent AVX skip이 false confidence를 만들고, snapshot fixture가 누락되어 있고, crash recovery test가 실제 recovery를 검증하지 않으며, kb-store-vector가 unauthorized direct deps (rusqlite, globset)을 가지고 있고, V003 tombstone trigger가 CASCADE에 의해 무력화되는 다섯 가지 문제였습니다.
모든 BLOCKER + MUST-FIX를 PR에 반영했습니다:
require_avx_or_panic+ 모든 통합 테스트에#[ignore](P3-2 모델 다운로드 패턴과 일치)._comment마커 + 테스트 측 panic 가드.put_embedding_records_pending로 phase-1만 직접 staging 후upsert호출로 phase-2/3 retry 경로 exercise.SqliteStore::filter_chunks로 이전 + rusqlite/globset을 kb-store-vector 직접 의존성에서 제거 (cargo metadata --no-deps검증).추가로 NIT cleanup 3건 (per-row UPDATE → IN-clause, descriptor_bytes rename, upsert info-level log) 반영.
⚠️ 개발 머신 (qemu64) AVX 부재로 통합 테스트 8건 + snapshot 1건이 end-to-end로 검증되지 않았습니다. 머지 전 AVX-capable 하드웨어에서
cargo test -p kb-store-vector -- --ignored을 한 번 돌리고 snapshot fixture도KB_UPDATE_SNAPSHOTS=1로 regen하시는 게 안전합니다. PR 본문 상단에도 동일 caveat를 굵게 표시했습니다. 이 부분을 사람의 판단으로 검증해주시기 바랍니다.워크스페이스 default 라인 241 passed / 19 ignored / 0 failed. clippy clean. 단위 테스트(filter_chunks, embeddings helpers, paths/arrow_batch math)로 SQL 측면과 pure logic 측면은 검증되었지만 LanceDB 측면(MergeInsert, vector_search, cosine 거리 계산)은 비-AVX에서 검증 불가능합니다.
inline 코멘트는 BLOCKER 수정 결정에 대한 노트입니다. 최종 머지 결정은 AVX 검증 후에 부탁드립니다.
@@ -0,0 +127,4 @@AND embedding_id IN ({placeholders})");tx.execute(&sql, params_from_iter(embedding_ids.iter())).map_err(StoreError::from)?;mark_embedding_records_committed이 단일UPDATE … WHERE embedding_id IN (?, ?, …)+params_from_iter+WHERE status='pending'가드. 세 가지를 한 번에 정리한 게 좋습니다 — 1) 라운드트립 1번으로 N개 행 처리 (per-row execute였던 NIT), 2) string 보간 없이 binding (SQL injection 차단), 3) status가 이미 'tombstone'이거나 'committed'인 행은 잘못 활성화되지 않음 (race-safe).@@ -0,0 +1,452 @@//! Chunk-level filter helpers shared between retrievers.리뷰의 unauthorized direct deps 지적 (rusqlite + globset이 kb-store-vector의 직접 의존성으로 들어와 있던 BLOCKER)을 해결하는 핵심 헬퍼입니다. embedding_records / chunks / documents / document_tags JOIN과 path_glob 후처리를 kb-store-sqlite로 이전해서 kb-store-vector는 깔끔하게 spec의 Allowed deps만 들고 있습니다 (
cargo metadata --no-deps검증). kb-search의 FTS5 인터리브 필터 로직과 통합은 구조 차이로 P+ refactor로 유보 — 합리적 trade-off입니다.@@ -0,0 +67,4 @@runtime: Runtime,connection: Connection,sqlite: Arc<SqliteStore>,/// Resolved absolute path to the Lance root. Kept for diagnostics# Async context도큐먼트 — block_on을 다른 tokio runtime 안에서 호출하면 "Cannot start a runtime from within a runtime" panic이 난다는 점을 struct doc에 명시. 현재 caller(kb-app job scheduler)가 sync라 안전한데, P4 RAG pipeline 등이 async가 되면 즉시 trip wire로 작용할 수 있습니다 — 미리 박아둔 가드입니다.@@ -0,0 +237,4 @@let table_schema = self.runtime.block_on(async { table.schema().await.context("read table schema") })?;Self::check_dim(&table_schema, dim)?;두 단계 upsert 순서가 정확합니다. SQLite-pending → Lance MergeInsert → SQLite-committed. phase-2 실패 시 행이 pending에 머무르고 다음 upsert에서 idempotent retry — "best-effort 2PC hand-wave"가 아니라 명시적 3-state 모델로 reconciliation이 모호하지 않습니다. crash recovery test도 phase-1만 직접 호출 후 phase-2/3 retry를 검증하도록 재작성되어 실제 recovery 경로를 exercise합니다.
@@ -0,0 +434,4 @@.as_any().downcast_ref::<StringArray>().context("text wrong type")?;let heading_path_str = batchscore conversion에 clamping 대신 shift를 쓴 결정의 가치가 코멘트로 남아있습니다.
(similarity + 1.0) / 2.0이 "무관 (sim ≈ 0)"과 "반대 (sim ≈ -1)"의 구별을 보존하는데, clamping은 둘을 모두 0으로 뭉개버립니다. RAG에서 negative-similarity hit가 어떤 식으로든 ranking signal로 의미 있는 케이스가 나오므로 (e.g., "이 문서는 query와 정반대다" 같은 contradiction 검출 후속 작업), 정보를 의도적으로 보존하는 선택입니다.@@ -0,0 +32,4 @@//! the tests stay independent of kb-parse-md / kb-normalize / kb-chunk//! and so we can construct adversarial fixtures (filtered tags,//! mismatched langs) without reproducing a Markdown round-trip.초기 구현의 silent skip(
if !require_avx_or_skip() { return; }→ 통과)이 false confidence를 만드는 BLOCKER였습니다.require_avx_or_panic()로 전환하고 모든 통합 테스트에#[ignore]을 붙인 결정이 정답입니다. 이제 비-AVX 호스트에서cargo test는 통합 테스트를 ignored로 표시하고,cargo test -- --ignored로 강제 실행 시에는 즉시 panic합니다 — 정확히 의도한 "red 또는 명시적 skip"입니다. P3-2��� 모델 다운로드 패턴과 일치하는 정책이라 워크플로우도 일관됩니다.@@ -0,0 +1,4 @@{snapshot fixture가 placeholder인 상태에서 silent pass가 되지 않게
_comment마커 + 테스트 측 panic 가드를 둔 게 좋습니다. 이렇게 두 단계로 막아두면 "AVX 호스트에서 한 번도 안 돌아본 채로 fixture가 비어 있다"는 상황을 머지 시점에 즉시 catch할 수 있습니다.KB_UPDATE_SNAPSHOTS=1regen 경로도 그대로 살아있어서 첫 AVX CI run에서 실제 fixture로 교체하면 됩니다.@@ -0,0 +35,4 @@-- runs", so this UPDATE will land a `tombstone` value that is-- immediately followed by the CASCADE removing the row. The trigger is-- therefore best-effort under the current FK; the only path that-- actually preserves the tombstone is to drop the CASCADE (tabletombstone trigger가 V001의 CASCADE FK에 의해 즉시 덮어써지는 한계를 SQL 코멘트로 정직하게 박아둔 점이 좋습니다. P3-3가 첫 writer라 production 행이 없으므로 "지금 CASCADE를 빼는 표 재생성"과 "P+ 마이그레이션으로 미루기" 사이에서 후자를 선택한 결정이 합리적입니다 — 두 가지 변경을 한 PR에 섞으면 risk가 conflate됩니다.
회차 2 — AVX 검증 완료.
VM CPU model을 host-passthrough로 전환 (AVX + AVX2 노출 확인)한 뒤
cargo test -p kb-store-vector -- --ignored실행:upsert_search.rs8/8 pass: ensure_table 멱등성, search-empty, upsert+search top-k, dim mismatch error 경로, tags_any filter, model isolation, determinism, crash-recovery (pending → committed 승격) 모두 통과.snapshot.rs1/1 pass:KB_UPDATE_SNAPSHOTS=1로 fixture regen → re-run으로 pin 확인.PR 본문 상단 caveat ("AVX 하드웨어에서 검증 필요")는 이번 commit으로 해소되었습니다. 머지 진행 가능합니다.
@@ -0,0 +1,34 @@[AVX2-capable VM (host-passthrough)에서 ignored lane 실행 결과로 placeholder 교체. 8/8 upsert_search + 1/1 snapshot 모두 통과 확인 후 fixture pin. 첫 PR 본문에 적었던 caveat 해소되었습니다.