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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 항목.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 주입.
|
||||
|
||||
## 외부 의존
|
||||
|
||||
@@ -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` 는 이 항목이 없애려는 조용한 손실을 다른 자리로 옮기는 것에 지나지 않는다.
|
||||
|
||||
### 이슈 본문과 다른 점 하나
|
||||
|
||||
|
||||
Reference in New Issue
Block a user