fix(kebab-app): p9-fb-23 — Incremental ingest (skip unchanged docs) #98
Reference in New Issue
Block a user
Delete Branch "fix/p9-fb-23-incremental-ingest"
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?
요약
도그푸딩 피드백: "새 문서들이 폴더에 추가되면 ingest 시 변하지 않은 문서는 다시 ingest 하지 않고 변하거나 새로 추가된 문서만 처리하고 싶어."
kebab ingest가 변경 / 신규 doc 만 처리하도록 개선. 변하지 않은 doc 은 parse / chunk / embed / vector upsert 모두 회피 — 비용 dominator (fastembed) 가 변경된 / 새 doc 에만 발생.Skip 조건 (4개 모두 만족)
assets.checksum(스캔 중 재계산).documents.parser_version== 현 active.documents.last_chunker_version== 현 active.documents.last_embedding_version== 현 active (None == None 도 match).위 중 하나라도 mismatch → 정상 path.
IngestOpts.force_reingest=true→ skip 우회 강제 재처리.주요 변경
kebab-coreIngestItemKind::Unchangedvariant 신규 (기존Skipped와 의미 분리).IngestReport.unchanged: u32+ 각종 wrapper 카운터.CanonicalDocument에last_chunker_version: Option<ChunkerVersion>+last_embedding_version: Option<EmbeddingVersion>필드 추가 (14 construction site 모두 None 으로 backwards-compat).DocumentStore::get_asset_by_workspace_path(&WorkspacePath) -> Option<RawAsset>trait method 신규.kebab-store-sqlitedocuments테이블에 두 column 추가 (nullable).put_documentSQL + bindings 확장,get_documentrow mapper 확장.get_asset_by_workspace_pathimpl + DRY 한asset_from_rowhelper.kebab-appIngestOpts { progress, cancel, force_reingest }struct (AskOpts패턴). 신규 entryingest_with_config_opts. 기존ingest_with_config_*wrapper 보존.CanonicalDocument에 현 chunker + embedding version stamp (md / image / pdf 세 flow 모두).IngestItem { kind: Unchanged, .. }반환. asset 루프가Unchanged에 대해aggregate.unchanged += 1+IngestEvent::AssetFinished{result: Unchanged}emit + parse/chunk/embed/vector upsert 모두 회피.kebab-cli--force-reingestflag 신규 — skip 우회 강제 재처리.kebab-tuistatus_linefinal / aborted 라인 모두unchanged=N노출.Wire schema
ingest_report.v1에unchanged(integer, minimum 0) 필드 additive — v1 호환 유지.테스트
Spec contract impact
참조 문서
Known limitation (deferred)
Spec → 10-step plan, TDD per task (failing test → impl → pass → commit). Tasks: 1. IngestItemKind::Unchanged + IngestReport.unchanged + AggregateCounts.unchanged + wire schema additive 2. CanonicalDocument 에 last_chunker_version + last_embedding_version Option 필드 추가 + 14 callers None 채움 3. V006 migration + SQLite put/get_document round-trip 신규 컬럼 4. DocumentStore::get_asset_by_workspace_path trait + SQLite impl 5. ingest pipeline 이 CanonicalDocument 에 현 chunker/embedding version stamp (no skip yet) 6. IngestOpts { progress, cancel, force_reingest } struct + ingest_with_config_opts entry (AskOpts 패턴) 7. asset 루프 early-skip 블록 (4 조건 match → Unchanged + continue) 8. CLI --force-reingest flag 9. TUI status_line 에 unchanged=N 노출 10. docs sync — README + HANDOFF + HOTFIXES + INDEX + per-task spec Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the per-asset incremental-ingest skip block to all three flows (markdown / image / pdf). When `IngestOpts::force_reingest = false` AND the asset's blake3 checksum + parser/chunker/embedding versions all match the existing DB record, ingest emits `AssetFinished { result: Unchanged }`, bumps `aggregate.unchanged`, and skips parse / chunk / embed / vector upsert entirely. Shared `try_skip_unchanged` helper performs the four checks; per-flow callers supply the active parser_version + chunker_version + optional embedding_version. `force_reingest = true` bypasses the skip path so `incremental_ingest::force_reingest_bypasses_skip` still sees `Updated`. Tests: - new `incremental_ingest.rs` covers both paths. - existing `ingest_idempotent_on_second_run` / `re_ingest_image_produces_*` / `re_ingest_identical_pdf_produces_*` updated to assert `Unchanged` on identical-bytes re-ingest (the pre-task behaviour was `Updated`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — 10-task TDD 분해 깔끔. 4 조건 skip 로직이 의도대로 분리 (force_reingest off → checksum → doc lookup → chunker version → embedder version) + 세 flow (md/image/pdf) 모두 동일 패턴.
try_skip_unchangedhelper 추출로 DRY. tracing::debug 도 strategic placement — 디버깅 시 skip path 진입 추적 가능. 723 워크스페이스 테스트 통과 + clippy clean.actionable 4건:
asset_from_row의 positional column 인덱스 → named (row.get("asset_id")) 로 전환 — SELECT 순서 변경 회귀 방지.byte_len: i64 as u64cast →u64::try_from(...)로 silent overflow 방지.try_skip_unchanged가app.sqlite.*직접 호출 —DocumentStoretrait 경유 권장 (Task 4 가 추가한 trait method 의 일관 사용).약 10 신규카운트 → 실제 8 신규 + 3 갱신 으로 정정. 영속 기록.3번은 기존 코드의 store 호출 패턴 (trait 경유 vs concrete) 따르는 게 우선 — 일관성 깨면 도리어 노이즈.
@@ -254,3 +272,3 @@) -> anyhow::Result<IngestReport> {let progress = progress.as_ref();let progress = opts.progress.as_ref();let cancelled = || {try_skip_unchanged가app.sqlite.get_asset_by_workspace_path/app.sqlite.get_document으로 concreteSqliteStore를 직접 부름. 본 PR 의 Task 4 가 이 메서드들을DocumentStoretrait 에 추가했으므로 trait 경유로 부르는 게 일관 (dyn DocumentStore추상화 보존):현재 single-impl (SqliteStore) 환경에서 동작은 동일하지만 테스트 fake / 다른 백엔드 도입 시 문제. 기존
kebab-app::lib.rs의 다른 store 호출 패턴을 따라야 (e.g. 기존app.sqlite.put_document호출이 trait 우회 vs trait 경유 어느 쪽인지 확인 — 일관성 우선).asset_from_row가 positionalrow.get(0)~row.get(8)로 column 을 인덱스 접근. SELECT 순서 변경 시 silently 잘못된 column 매핑 → runtime data corruption (예: media_type 자리에 source_uri 가 들어감).named access 권장:
rusqlite 의
row.get(name)overload 가 column-name lookup 지원. 비용 무시 (HashMap lookup), SELECT order 와 row mapping 디커플링.같은 패턴이
document_row_from_sql등 기존 helper 에 적용돼 있는지 확인 — 일관성 유지 위해 이 파일의 다른 row mapper 패턴 따라야.byte_len: i64 as u64cast 가 음수 입력에 대해 silently overflow (음수 → very large u64). SQLite 가 negative 를 저장할 일 없지만 corrupt DB 또는 외부 도구로 수정된 경우 panic 보다 더 위험한 silent corruption.안전한 cast:
첫 번째 nit (named column access) 와 함께 적용하면 둘 다 동일 row mapper 안에서 정합.
@@ -17,0 +22,4 @@- SQLite V006 migration — `documents` 에 `last_chunker_version` + `last_embedding_version` TEXT (nullable) 추가. 기존 row 는 NULL → 첫 번째 ingest 시 항상 mismatch → 강제 재처리 (안전 default).- `kebab-core::IngestItemKind::Unchanged` variant 신규 (기존 `Skipped` 와 의미 분리: `Skipped` = media-type 필터, `Unchanged` = 모든 versions match).- `IngestReport.unchanged: u32` + `AggregateCounts.unchanged: u32` 신규. wire schema `ingest_report.v1` 에 `unchanged` 필드 additive (v1 호환 유지).테스트 카운트 "약 10 신규" 가 실제와 맞지 않습니다.
crates/kebab-app/tests/incremental_ingest.rs신규 2crates/kebab-app/tests/ingest_lexical.rs신규 2 (ingest_with_config_opts_default_*,ingest_stamps_chunker_version_*)crates/kebab-store-sqlite/tests/incremental_ingest.rs신규 4 (round-trip stamps × 2 + get_asset_by_workspace_path × 2)별도로
image_pipeline.rs/pdf_pipeline.rs/ingest_lexical.rs::ingest_idempotent_on_second_run3 건은 assertion 만 갱신 (Updated → Unchanged) — 신규 테스트가 아닌 갱신."약 10 신규" → "8 신규 + 3 기존 갱신" 으로 정정 권장. 영속 기록 정확도.
회차 2 — 회차 1 의 4 건 모두 수렴.
asset_from_rownamed column access (9 column) + doc 코멘트 정정.byte_len의u64::try_from안전 cast +FromSqlConversionFailure매핑.try_skip_unchangedtrait 미전환 결정: 기존kebab-app/src/lib.rs의 store 호출 (lines 586/634/772/1635) 모두 concreteapp.sqlite.*패턴이라 일관성 우선 — 합리.8 신규 + 3 갱신으로 file-level breakdown 까지 정확.10-task TDD 분해 + skip 4 조건 cascade + 세 flow (md/image/pdf) 통일 + tracing observability + force_reingest 우회 — 회귀 표면 잘 분리됨. 723 워크스페이스 테스트 통과 + clippy clean. 비용 dominator (fastembed) 가 변경된 / 새 doc 에만 발생 — 사용자 도그푸딩 피드백의 핵심 해소.
머지 동의.
@@ -478,0 +519,4 @@let media_type_json: String = row.get("media_type")?;let byte_len: i64 = row.get("byte_len")?;let checksum_raw: String = row.get("checksum")?;let storage_kind: String = row.get("storage_kind")?;named-column 전환 깔끔. 9 개 column 모두 schema 이름으로 표기 — SELECT 순서 변경 시 자동 자기-도큐먼트. doc 코멘트도 "column names are self-documenting" 으로 정정해 좋음.
@@ -478,0 +555,4 @@source_uri,workspace_path,media_type,byte_len: u64::try_from(byte_len)u64::try_from(byte_len)+FromSqlConversionFailure매핑 → silent overflow 방지. inline 코멘트가 "index parameter unused but type requires number" 명시 — rusqlite Error variant 의 history quirk 를 explainer 로 남긴 게 후속 reader 친화.