chore: PR #240 회차 1 리뷰 반영 — 테스트가 상수를 위로 올리는 방향을 못 잡았다

리뷰가 잡은 것 중 무거운 둘.

1. 테스트가 한 방향으로만 샜다. `REC_MIN_WIDTH` 를 100 으로 올려도 통과했다
   — 앞의 두 단언은 상수가 커질수록 더 잘 통과하기 때문이다(가드가 더 많이
   잡아 주고, 세션은 폭이 클수록 잘 돈다). 그 상태면 높이 48 기준 폭 100
   미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다. 이 PR 이
   없애려는 손실과 같은 종류다. 상수 옆에 `const _: () = assert!(REC_MIN_WIDTH
   <= 16, …)` 를 걸어 컴파일 자체를 막는다 — 상수를 만지는 사람이 테스트를
   돌리기 전에 걸리는 편이 낫다. 상한 16 은 "5~16 은 잉크가 있어도 빈 문자열,
   40 은 제대로 읽음" 실측에서 온 수다. 상수 주석의 "pins both sides" 도
   사실이 아니었으므로 함께 고쳤다.

2. 캐스케이드 비용 분석이 절반이었다. `id_for_doc` 이 접는 건 composite 가
   아니라 base PARSER_VERSION 이라 이 bump 는 모든 이미지·PDF 문서의 doc_id 를
   바꾸고, `purge_workspace_path_for_parser_bump` 로 store 를 통째로 다시 쓴다.
   게다가 기본 엔진이 `ollama-vision` 이고 OCR 이 기본 off 라 버그가 닿지 않는
   KB 도 같은 비용을 낸다. 엔진 범위로 좁히는 대안(`ocr_engine_version_for_sig`
   전용 토큰)이 더 정확하지만, #232 선례 옆에 두 번째 무효화 경로를 만드는
   값이 단일 사용자 저장소에서는 이득보다 크다고 보고 base bump 를 유지했다.
   빠져 있던 비용과 이 판단 근거를 HOTFIXES 에 적었다.

나머지:
- 테스트가 `ModelPaths::from_default_dir()` 을 써서 `KEBAB_IMAGE_OCR_MODEL_DIR`
  를 탔다. 상수를 배포되는 모델에 붙들어 두는 게 목적이므로 `CARGO_MANIFEST_DIR`
  에서 경로를 직접 만든다.
- PDF OCR 실패 노트가 `err={}` 라 anyhow 의 가장 바깥 context(`rec session run`)
  만 남고 ORT 원인이 잘렸다. 이슈가 안내한 `provenance_json LIKE '%Invalid input
  shape%'` 가 PDF 문서를 한 건도 못 찾는 원인이다. `{e:#}` 로 이미지 경로와
  형식을 맞췄다.
- `ingest.rs` 주석 4곳이 옛 버전을 현재형으로 말했다 (#232 가 자기 bump 때
  같은 자리를 갱신한 선례).
- `pdf_page_v1.rs` 테스트 픽스처의 하드코딩된 버전 문자열을 버전 중립으로.
  `kebab-chunk` 는 §8 경계상 `kebab-parse-pdf` 를 import 할 수 없어 실제 버전을
  넣으면 bump 마다 또 상한다.
- HOTFIXES: 이 커밋이 밀어 버린 줄 번호 참조 제거, 클래스 수 검사 논거를 약한
  대로 정직하게 다시 씀(정적 그래프 메타데이터라 `from_paths` 로 옮길 수 있다),
  따라갈 수 없는 `§3.4` 참조 정리, 내부 용어와 어색한 소제목 정리.
- DOGFOOD: §13.4 이미지 코퍼스를 실제 분류·장수로 채우고 §1.2 에 paddle-onnx
  설정 + 시나리오 1.2.f. 240 은 파일 수이고 ingest 대상은 tif 를 뺀 239 라는
  차이를 HOTFIXES 와 양쪽에 밝혔다.
- parse 컴포넌트 README: Image 에 빠져 있던 PARSER_VERSION 줄, "v1 유일 구현"
  이라는 틀린 서술과 다이어그램에 없던 `OnnxPaddleOcr`.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 경고 없음.
`run_rec` 과 상수는 240 장 측정 때와 바이트 동일 — 이번 변경은 컴파일 시점
단언과 테스트 본문뿐이라 36/240 → 0/240 결과는 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 17:39:53 +09:00
parent d6654ab3da
commit 77ed432af8
7 changed files with 106 additions and 29 deletions

View File

@@ -1629,7 +1629,7 @@ fn ingest_one_image_asset(
}
};
// p9-fb-23 task 7: incremental-ingest early-skip for the image flow.
// Image docs use the `image-meta-v1` parser_version + the same
// Image docs use the `image-meta-v2` parser_version + the same
// MdHeadingV2Chunker as the markdown flow (single-block doc). The
// embedding-version check matches the markdown path: when the
// active embedder's model_version equals what was stamped on the
@@ -1676,7 +1676,7 @@ fn ingest_one_image_asset(
.extract_for(&asset.media_type, &ctx, &bytes)
.context("kb-app::extract_for (image)")?;
// v0.26.2: store the composite parser_version (extractor baked the base
// `image-meta-v1`, which already fixed doc_id). Skip compare + stored
// `image-meta-v2`, which already fixed doc_id). Skip compare + stored
// field must agree for next-run detection.
canonical.parser_version = eff_parser_version.clone();
// `[[workspace.sources]]`: stamp the owning source id (image extractor
@@ -2541,7 +2541,7 @@ fn ingest_one_pdf_asset(
}
};
// p9-fb-23 task 7: incremental-ingest early-skip for the PDF flow.
// PDF docs use `pdf-text-v2` as the parser_version and `PdfPageV1Chunker`
// PDF docs use `pdf-text-v3` as the parser_version and `PdfPageV1Chunker`
// as the chunker — both pinned per-medium today (no config knob).
// v0.26.2: composite parser_version folds pdf.ocr (enabled/always_on/
// model) + chunking, so enabling scanned-PDF OCR auto-re-indexes PDFs.
@@ -2578,7 +2578,7 @@ fn ingest_one_pdf_asset(
let mut canonical = app
.extract_for(&asset.media_type, &ctx, &bytes)
.context("kb-app::extract_for (pdf)")?;
// v0.26.2: store the composite parser_version (base `pdf-text-v2` already
// v0.26.2: store the composite parser_version (base `pdf-text-v3` already
// fixed doc_id) so the next run's skip compare matches.
canonical.parser_version = eff_parser_version.clone();
// `[[workspace.sources]]`: stamp the owning source id (pdf extractor

View File

@@ -447,7 +447,10 @@ where
Err(e) => {
// OCR failure: warning event + skip (text-detect block 그대로).
let note = format!(
"page={} OCR failed engine={} version={} err={}",
// `{e:#}` (not `{e}`): the ORT detail lives under a `.context`, and
// the plain Display drops it — so a note written with `{e}` reads
// `err=rec session run` and cannot be grepped for the real cause.
"page={} OCR failed engine={} version={} err={:#}",
page_num,
engine.engine_name(),
engine.engine_version(),

View File

@@ -400,7 +400,10 @@ mod tests {
fn make_pdf_doc(pages: &[&str]) -> CanonicalDocument {
let workspace_path = WorkspacePath::new("docs/test.pdf".into()).unwrap();
let asset_id = AssetId("a".repeat(64));
let parser_version = ParserVersion("pdf-text-v2".into());
// Version-neutral on purpose: `kebab-chunk` cannot import
// `kebab-parse-pdf` (design §8), so a real version string here would
// go stale at every bump. The chunker only feeds it to `id_for_doc`.
let parser_version = ParserVersion("test-parser-v1".into());
let doc_id = id_for_doc(&workspace_path, &asset_id, &parser_version);
let mut blocks: Vec<Block> = Vec::new();

View File

@@ -57,8 +57,20 @@ const REC_HEIGHT: u32 = 48;
/// `Invalid input shape: {1,0}` — taking every already-recognized box on the
/// image down with it (issue #239). Measured against the bundled
/// `korean_ppocrv5_mobile_rec.onnx`: 1..=4 always fail, 5.. always succeed.
/// `rec_min_width_is_the_graph_floor` pins both sides of that boundary.
/// `rec_min_width_is_the_graph_floor` holds the constant from both
/// directions: too low and the graph rejects it, too high and it starts
/// discarding crops the graph would have accepted.
const REC_MIN_WIDTH: u32 = 5;
/// Raising `REC_MIN_WIDTH` is only free while it stays inside the band that
/// was measured to decode nothing anyway: widths 5..=16 come back empty even
/// with real ink in them, while width 40 reads glyphs at 0.97+ confidence
/// (issue #239). Above 16 the guard starts discarding crops the graph would
/// have read — the same silent loss this constant exists to prevent — so the
/// ceiling is enforced at compile time rather than left to review.
const _: () = assert!(
REC_MIN_WIDTH <= 16,
"REC_MIN_WIDTH is past the measured no-loss ceiling (16)"
);
/// DBNet probability-map binarization threshold. Looser than Paddle's default
/// `box_thresh` (0.6) to keep recall high on low-contrast Korean text.
const DET_BIN_THRESH: f32 = 0.3;
@@ -1022,17 +1034,37 @@ mod tests {
/// abort the ORT session, and `recognize`'s `?` turned that into "this
/// image has no OCR text at all" — 36 of 240 corpus images.
///
/// Both directions matter, so both are asserted. Below `REC_MIN_WIDTH` the
/// guard must short-circuit (delete it and this errors). At exactly
/// `REC_MIN_WIDTH` the real session must run (set the constant below the
/// graph's true floor, e.g. after a model swap, and this errors) — that
/// second half is what keeps the constant pinned to the shipped model
/// instead of being a number nobody rechecks.
/// Both runtime directions are asserted here. Below
/// `REC_MIN_WIDTH` the guard must short-circuit (delete it and this
/// errors). At exactly `REC_MIN_WIDTH` the real session must run, which is
/// what keeps the constant pinned to the shipped model rather than being a
/// number nobody rechecks (set it below the graph's floor, e.g. after a
/// model swap, and this errors). The upward direction — a constant raised
/// past the width band that was measured to decode nothing anyway, which
/// would silently drop crops the graph can read — is pinned by the
/// `const _` assertion next to `REC_MIN_WIDTH` itself.
#[test]
fn rec_min_width_is_the_graph_floor() {
let engine =
OnnxPaddleOcr::from_paths(&ModelPaths::from_default_dir(), 0.3, 1.5, 1000, 1600)
.expect("bundled OCR assets must load");
// The upward direction is pinned by the `const _` next to
// REC_MIN_WIDTH — a raise past the measured ceiling fails the build,
// not just this test.
//
// Pin to the *bundled* assets: `ModelPaths::from_default_dir` honors
// `KEBAB_IMAGE_OCR_MODEL_DIR`, and a developer with that exported would
// otherwise measure their own model here.
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/paddleocr-onnx");
let engine = OnnxPaddleOcr::from_paths(
&ModelPaths {
det: dir.join("ppocrv5_mobile_det.onnx"),
rec: dir.join("korean_ppocrv5_mobile_rec.onnx"),
dict: dir.join("korean_dict.txt"),
},
0.3,
1.5,
1000,
1600,
)
.expect("bundled OCR assets must load");
// A crop already at REC_HEIGHT makes run_rec's keep-aspect resize the
// identity, so the crop width *is* the rec input width — no rounding
// to reason about.

View File

@@ -122,10 +122,18 @@ endpoint = "http://192.168.0.47:11434"
enabled = false # opt-in
```
`paddle-onnx` 백엔드 (v0.27.0~, in-process ONNX — Ollama 없이 돈다):
```toml
[image.ocr]
enabled = true
engine = "paddle-onnx"
```
**verify**:
- `*.png` / `*.jpg` / `*.jpeg` 만 ingest target.
- OCR text 가 `Block::ImageRef.ocr.joined` 안.
- `[image.caption].enabled=true` 시 caption 도.
- (issue #239) 한 장도 **통째로** 비지 않는다. 얇은 검출 박스 하나가 rec 세션을 실패시키면 그 이미지의 인식 결과가 전량 버려지던 버그였다. 색인은 성공으로 끝나므로 `documents.provenance_json` 에 `Invalid input shape` 또는 `err=rec session run` 이 남았는지로 확인한다.
**scenarios**:
- 1.2.a Korean OCR (한국어 scan PNG) → OCR text + search hit.
@@ -133,6 +141,7 @@ enabled = false # opt-in
- 1.2.c photo (자연 사진, OCR 없음) → empty OCR or warning.
- 1.2.d corrupt image → graceful error.
- 1.2.e oversized image (> max_pixels) → downscale.
- 1.2.f (issue #239) `engine = "paddle-onnx"` 로 §13.4 이미지 코퍼스 전량 → OCR 오류 0 건. 본문이 파일명뿐인 문서가 남으면 provenance 를 확인한다. 글자가 없는 사진이라 0 자인 것과 이 버그로 통째로 버려진 것은 다르다 — 후자만 provenance 에 오류가 남는다.
### §1.3 PDF text ingest (P7-1)
@@ -975,7 +984,16 @@ bug 발견 시:
### §13.4 Image corpus
(P6 dogfood — 향후 추가).
도그푸딩 스토어의 `corpus/images/` — 4 분류 240 개 파일 (jpg 172 · png 63 · jpeg 4 · tif 1). `kebab ingest` 가 집는 것은 tif 를 뺀 239 개다.
| 분류 | 장수 | 쓰임 |
|---|---|---|
| `charts/` | 50 | 도표·다이어그램. 박스가 많고 얇은 조각이 잘 생긴다. #239 실패 8 장. |
| `english-text/` | 70 | 스크린샷·표지판 등 영문 위주. 1.2.b 판정용. #239 실패 10 장. |
| `korean-text/` | 70 | 한국어 스캔·필기. 1.2.a. #239 실패 9 장. |
| `photos/` | 50 | 글자가 거의 없는 자연 사진. 1.2.c 의 "정상적으로 0 자" 대조군. #239 실패 9 장 — 글자가 없어도 얇은 박스는 검출되므로 사진도 걸렸다. |
issue #239 실측 근거가 이 코퍼스 전량 스윕이다 (수정 전 36/240 실패 → 수정 후 0). 상세는 tasks/HOTFIXES.md 2026-08-28 항목.
---

View File

@@ -42,6 +42,10 @@ classDiagram
class OllamaVisionOcr {
endpoint, model, max_pixels
}
class OnnxPaddleOcr {
REC_HEIGHT = 48
REC_MIN_WIDTH = 5
}
class CaptionFns {
caption_image(lm, prep, opts) ModelCaption
apply_caption(block, lm, opts)
@@ -49,6 +53,7 @@ classDiagram
Extractor <|.. PdfTextExtractor
Extractor <|.. ImageExtractor
OcrEngine <|.. OllamaVisionOcr
OcrEngine <|.. OnnxPaddleOcr
ImageExtractor ..> OcrEngine : applied via apply_ocr
ImageExtractor ..> CaptionFns : applied via apply_caption
```
@@ -106,8 +111,10 @@ flowchart LR
**Image** (`kebab-parse-image`):
- `ImageExtractor` — `Extractor` 구현체. `MAX_DECODE_DIM = 16384` 초과 거부 (decode bomb 방어).
- `PARSER_VERSION = "image-meta-v2"` — version cascade entry (issue #239 에서 v1 → v2, 얇은 검출 박스가 이미지 OCR 을 통째로 날리던 것을 고치면서 기존 색인 이미지 재처리 유발).
- `OcrEngine` (trait) — `engine_id() / run(...) -> OcrText`. `OcrText.engine` 필드로 trust level 분기.
- `OllamaVisionOcr { endpoint, model, max_pixels }` — v1 유일 구현. `apply_ocr(block, engine, langs)` 가 `ImageRefBlock.ocr` 슬롯 채움.
- `OllamaVisionOcr { endpoint, model, max_pixels }` — `ollama-vision` 백엔드 (기본값). `apply_ocr(block, engine, langs)` 가 `ImageRefBlock.ocr` 슬롯 채움.
- `OnnxPaddleOcr` — `paddle-onnx` 백엔드 (v0.27.0, PP-OCRv5 ONNX in-process). rec 세션 입력 폭 하한은 `REC_MIN_WIDTH = 5` — 그 아래는 세션에 넣지 않고 빈 문자열을 돌려준다 (issue #239).
- `caption_image(lm: &dyn LanguageModel, prep, opts) -> Result<ModelCaption>` — `LanguageModel.generate_stream` 의 vision 입력 (`GenerateRequest.images`) 사용. `apply_caption` 이 block 에 in-place 주입.
## 외부 의존

View File

@@ -26,7 +26,7 @@ git history.
### 하한은 16 이 아니라 5 였다 — 실측
이슈는 `REC_MIN_WIDTH = 16` 을 제안했지만, 번들된 `korean_ppocrv5_mobile_rec.onnx` 를 직접 스윕해 보면 **실제 한계는 4** 다. 세 번의 독립 측정(서로 다른 조사 레인 + 검증 레인)이 같은 표를 냈다.
이슈는 `REC_MIN_WIDTH = 16` 을 제안했지만, 번들된 `korean_ppocrv5_mobile_rec.onnx` 를 직접 스윕해 보면 **실패하는 폭은 4 까지이고, 그래서 하한은 5** 다. 서로 다른 경로로 세 번 재 봤고 같은 표가 나왔다.
| rec 입력 폭 (높이 48) | 결과 |
|---|---|
@@ -39,18 +39,24 @@ git history.
`crop.width()` 가 아니라 리사이즈 **이후**의 `new_w` 를 검사한다. 3×200 짜리 크롭은 폭이 3 이지만 `new_w` 가 1 이고, 3×10 크롭은 같은 폭 3 인데 `new_w` 가 14 다. 네트워크가 보는 값은 `new_w` 뿐이다.
### 회귀 테스트는 양쪽을 다 고정한다
### 회귀 테스트는 상수를 위아래로 다 가둔다
`rec_min_width_is_the_graph_floor` (`paddle_onnx.rs` 의 `mod tests`) 는 두 방향을 모두 단언한다.
아래쪽은 `rec_min_width_is_the_graph_floor` (`paddle_onnx.rs` 의 `mod tests`) 가 두 가지로 잡는다.
- `1..REC_MIN_WIDTH` 는 세션에 닿지 않고 빈 문자열로 빠져야 한다 → **가드를 지우면 실패**한다. 실제로 가드만 지우고 돌려 확인했다: `w=1 must never reach the rec session: rec session run: ... Invalid input shape: {1,0}`.
- `1..REC_MIN_WIDTH` 는 세션에 닿지 않고 빈 문자열로 빠져야 한다 → **가드를 지우면 실패**한다. 가드만 지우고 돌려 확인했다: `w=1 must never reach the rec session: rec session run: ... Invalid input shape: {1,0}`.
- 정확히 `REC_MIN_WIDTH` 는 **진짜 세션을 통과**해야 한다 → 상수가 모델의 실제 하한보다 낮으면(예: 모델 교체 후) 실패한다.
아래쪽만 있는 테스트는 상수가 너무 낮아도 통과해 버린다. 위쪽 단언이 상수를 배포된 모델에 붙들어 두는 역할을 한다. 모델 에셋이 in-tree 로 커밋돼 있으므로(`git ls-files crates/kebab-parse-image/assets/`) skip 가드는 붙이지 않았다 — 조용히 안 도는 테스트가 이 이슈가 경고하는 바로 그 함정이다.
위쪽은 상수 옆의 `const _: () = assert!(REC_MIN_WIDTH <= 16, …)` 가 잡는다. **컴파일이 안 된다** — 100 으로 바꾸면 `error[E0080]: evaluation panicked: REC_MIN_WIDTH is past the measured no-loss ceiling (16)`.
### 실측 (도그푸딩 말뭉치 이미지 240 장)
위쪽 단언이 없으면 테스트가 한 방향으로만 샌다. 초안이 그랬다 — 상수를 100 으로 바꿔도 통과했다. 위의 두 단언은 상수가 커질수록 **더 잘** 통과하기 때문이다(가드가 더 많이 잡아 주고, 세션은 폭이 클수록 잘 돈다). 그 상태면 높이 48 기준 폭 100 미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다 — 이 항목이 없애려는 손실과 같은 종류다. 상한 16 은 위에서 잰 "5~16 은 잉크가 있어도 빈 문자열, 40 은 제대로 읽음" 에서 온 숫자이고, 테스트가 아니라 컴파일 시점에 건 이유는 상수를 만지는 사람이 테스트를 돌리기 전에 막히는 편이 낫기 때문이다.
`corpus/images/` 전량을 수정 전후로 같은 엔진(`ppocrv5-mobile-kor-1b55f062d055`)·같은 설정(score 0.3 / unclip 1.5 / max_boxes 1000 / max_pixels 2048)으로 돌렸다.
테스트는 `ModelPaths::from_default_dir()` 대신 `CARGO_MANIFEST_DIR` 에서 경로를 직접 만든다. 전자는 `KEBAB_IMAGE_OCR_MODEL_DIR` 을 타므로, 자기 모델 디렉토리를 export 해 둔 사람이 `cargo test` 를 돌리면 상수를 엉뚱한 모델에 대고 재게 된다. 이 테스트의 일은 상수를 **배포되는** 모델에 붙들어 두는 것이다.
모델 에셋이 in-tree 로 커밋돼 있으므로(`git ls-files crates/kebab-parse-image/assets/`) skip 가드는 붙이지 않았다 — 조용히 안 도는 테스트가 이 이슈가 경고하는 바로 그 함정이다.
### 실측 (도그푸딩 말뭉치 이미지 240 개 파일)
`corpus/images/` 전량(jpg 172 · png 63 · jpeg 4 · tif 1 = 240 개 파일. `kebab ingest` 가 집는 것은 tif 를 뺀 239 개지만, 여기서는 OCR 엔진을 파일 목록에 직접 물렸다)을 수정 전후로 같은 엔진(`ppocrv5-mobile-kor-1b55f062d055`)·같은 설정(score 0.3 / unclip 1.5 / max_boxes 1000 / max_pixels 2048)으로 돌렸다.
| | 수정 전 | 수정 후 |
|---|---|---|
@@ -69,7 +75,7 @@ git history.
부작용이 없다는 것도 확인했다: 원래 성공하던 204 장의 인식 글자 수가 **한 장도 변하지 않았다**. 이 수정은 순수 가산이다.
36 장 중 4 장은 수정 후에도 0 자인데, 글자가 없는 사진(예: `photos/Aphid_2007_1.jpg`, 진딧물 접사)이라 정상이다. 이 이슈로 인한 손실과 "원래 글자가 없어서 비는 문서"를 혼동하면 안 된다 — 성공한 204 장 중에서도 31 장은 det 가 박스를 못 찾아 정상적으로 0 자다. KB 쪽에서 영향 문서를 셀 때는 본문 길이가 아니라 `provenance_json LIKE '%Invalid input shape%'` 로 걸러야 한다.
36 장 중 4 장은 수정 후에도 0 자인데, 글자가 없는 사진(예: `photos/Aphid_2007_1.jpg`, 진딧물 접사)이라 정상이다. 이 이슈로 인한 손실과 "원래 글자가 없어서 비는 문서"를 혼동하면 안 된다 — 성공한 204 장 중에서도 31 장은 det 가 박스를 못 찾아 정상적으로 0 자다. KB 쪽에서 영향 문서를 셀 때는 본문 길이가 아니라 `provenance_json LIKE '%Invalid input shape%'` 로 걸러야 한다. 단, **v0.33.0 이전에 색인된 스캔 PDF 는 이 필터에 안 걸린다** — PDF 경로가 노트를 `err={}` 로 찍었고 anyhow 의 기본 Display 는 가장 바깥 context 하나(`rec session run`)만 내보내기 때문이다. 이미지 경로는 처음부터 `{err:#}` 라 체인 전체가 들어간다. 이번에 PDF 쪽도 `{e:#}` 로 맞춰 두 경로의 노트 형식을 통일했으므로 앞으로 색인되는 것은 양쪽 다 걸린다. 이전 색인분까지 세려면 `OR provenance_json LIKE '%err=rec session run%'` 을 함께 걸어야 한다.
### 재색인: 버전 두 개를 올렸다
@@ -78,9 +84,15 @@ git history.
- `image-meta-v1` → **`image-meta-v2`**
- `pdf-text-v2` → **`pdf-text-v3`** (스캔 PDF 도 같은 `run_rec` 을 타므로 같은 손실을 겪었다)
**재처리 비용은 생각보다 싸다.** OCR 산출물은 `derivation_cache` 에 **소스 바이트** 키로 들어가 있어서(§3.4, v0.31.0 #217) `parser_version` 캐스케이드와 분리돼 있다. 그리고 실패한 OCR 은 캐시에 **저장되지 않는다** — `Err` 분기가 `derivation_cache_put` 앞에서 빠져나간다(이미지 `ingest.rs:1750`, PDF `pdf_ocr_apply.rs:475`). 그래서 다음 `kebab ingest` 는 모든 이미지·PDF 문서를 다시 추출하되, 이미 성공했던 것들은 OCR 캐시에 히트해 비싼 엔진 호출을 건너뛰고, **실제로 다시 OCR 되는 건 이 버그로 실패했던 문서뿐**이다.
**비싼 OCR 은 대부분 다시 안 돈다.** OCR 산출물은 `derivation_cache` 에 **소스 바이트** 키로 들어가 있어서 `parser_version` 캐스케이드와 분리돼 있다(`docs/ARCHITECTURE.md:35` 의 derivation_cache 행, v0.31.0 #217). 그리고 실패한 OCR 은 캐시에 **저장되지 않는다** — `Err` 분기가 `derivation_cache_put` 앞에서 빠져나간다(이미지 `ingest.rs:1750`, PDF `pdf_ocr_apply.rs:475`). 그래서 이미 성공했던 문서는 캐시에 히트해 엔진 호출을 건너뛰고, **실제로 다시 OCR 되는 건 이 버그로 실패했던 문서뿐**이다.
### 스냅샷 낙수
**하지만 나머지 비용은 전부 든다.** `id_for_doc` 이 접는 것은 composite 가 아니라 **base** PARSER_VERSION 이므로(`kebab-parse-image/src/lib.rs`, `kebab-parse-pdf/src/lib.rs` 의 `extract`; composite 는 그 뒤에 `canonical.parser_version` 에만 찍힌다) 이 bump 는 **모든 이미지·PDF 문서의 doc_id 를 바꾼다**. 그러면 `ingest.rs` 의 `purge_workspace_path_for_parser_bump` 가 돌아 documents 행이 지워지고(blocks·chunks·embedding_records CASCADE) 해당 chunk_id 의 Lance 벡터도 전량 삭제된 뒤, 재파싱·재청킹·재임베딩·재삽입이 이어진다. 임베딩은 파생물 캐시에 히트하지만 행은 다시 쓴다. doc_id 는 wire 필수 필드이자 `kebab inspect doc <id>` 의 핸들이라, 파일을 하나도 안 고쳤는데 전부 한꺼번에 바뀐다.
그리고 이 비용은 **버그가 닿지 않는 KB 에도** 걸린다. 기본 OCR 엔진은 `ollama-vision` 이고 이미지 OCR 은 기본 off, `PdfOcrCfg::defaults()` 도 `enabled: false, always_on: false` 라, paddle-onnx 를 안 쓰는 KB 는 얻는 것 없이 이미지·PDF 를 다시 색인하게 된다.
**그래도 base 를 올리는 쪽을 택했다.** 대안은 `ingest_config_signature` 의 `ocr_engine_version_for_sig` 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화하는 것이고, 그러면 doc_id 도 유지되고 ollama-vision·OCR off KB 도 안 건드린다. 더 정확하지만 #232 가 세운 선례와 다른 새 무효화 경로를 하나 더 만드는 일이고, 이 저장소는 단일 사용자용이라 실제로 손해 보는 KB 가 사용자 자신의 것 하나다. 캐스케이드 규칙이 이미 있는데 그 옆에 두 번째 규칙을 세우는 값을 치를 만큼은 아니라고 봤다. 다중 사용자 배포로 가면 다시 볼 결정이다.
### 스냅샷도 함께 움직였다
`pdf-text-v3` 는 `vector_pdf_canonical.json` 을 움직인다. #232 때와 같은 형태임을 확인했다 — 바뀐 것은 **파생 식별자와 버전 문자열뿐**이고 본문 텍스트·inlines·`source_span`·metadata 는 동일하다.
@@ -95,7 +107,9 @@ git history.
### 고치지 않고 남긴 것
`recognize` 안의 `self.run_rec(&crop)?` (`paddle_onnx.rs:299`) 는 그대로 뒀다. 폭 가드가 알려진 유일한 방아쇠를 닫았고, `T = ceil((w-4)/8) >= 1` 이 `w >= 5` 에서 항상 성립하므로 폭 때문에 이 경로가 다시 터지는 일은 없다. 여기를 박스 단위 `continue` 로 바꾸면 바로 아래 클래스 수 검사(`rec output has {c} classes`)까지 함께 삼키게 되는데, 그건 입력과 무관한 **설정 오류**(rec 모델과 dict 짝이 안 맞음)라서 지금처럼 즉시 실패하는 편이 맞다. 조용한 빈 OCR 로 바꿀 이유가 없다.
`recognize` 의 박스 루프 안 `self.run_rec(&crop)?` 는 그대로 뒀다. 폭 가드가 알려진 유일한 방아쇠를 닫았고, `T = ceil((w-4)/8) >= 1` 이 `w >= 5` 에서 항상 성립하므로 폭 때문에 이 경로가 다시 터지는 일은 없다.
박스 단위로 살려 두려면 그 `?` 를 `continue` 로 바꿔야 하는데, 그러면 `run_rec` 안의 클래스 수 검사(`rec output has {c} classes`)까지 함께 삼킨다. 그건 입력과 무관한 **설정 오류**(rec 모델과 dict 짝이 안 맞음)라 즉시 실패하는 편이 맞다. 다만 이게 `?` 를 유지할 결정적 이유는 아니다 — 클래스 차원은 정적 그래프 메타데이터라(번들 rec 출력 shape 가 `[-1, -1, 11947]`) 세션 로드 시점에 읽을 수 있고, 그렇다면 그 검사는 `from_paths` 의 `dict.len() != DICT_LINES` bail 옆으로 옮기는 편이 더 낫다. 잘못된 모델이 박스가 검출되는 이미지를 기다릴 것 없이 엔진 생성에서 바로 터지기 때문이다. 이번에 안 한 건 #239 의 방아쇠와 무관한 별개 정리이기 때문이고, 옮길 때는 `continue` 에 `tracing::warn!` 을 반드시 함께 달아야 한다. 맨 `continue` 는 이 항목이 없애려는 조용한 손실을 다른 자리로 옮기는 것에 지나지 않는다.
### 이슈 본문과 다른 점 하나