fix(parse-image): #239 얇은 검출 박스가 이미지 OCR 전체를 날리던 문제 #240

Merged
altair823 merged 5 commits from fix/paddle-onnx-rec-min-width into main 2026-08-29 01:52:14 +00:00

5 Commits

Author SHA1 Message Date
5815f749a5 chore: PR #240 회차 4 리뷰 반영 — 노출 구간을 v0.28.0 으로 정정
회차 3 에서 내가 맞는 리뷰를 잘못된 측정으로 뒤집었다. paddle-onnx 를 PDF OCR
엔진으로 고를 수 있게 된 릴리스를 v0.31.0 이라고 적었는데 v0.28.0 이 맞다.

원인은 세는 방법이었다. `crates/kebab-app/src/ingest.rs` 를 태그별로 grep 했는데
그 파일 자체가 v0.31.0 에서 생겼다 — 그 전에는 `build_pdf_ocr_engine` 이
`crates/kebab-app/src/lib.rs` 에 있었다. 내가 본 0 은 기능이 없다는 뜻이 아니라
파일이 없다는 뜻이었다. 파일을 먼저 찾은 뒤 세면 v0.28.0 · v0.30.0 · v0.30.1 ·
v0.31.0 · v0.32.0 전부 paddle 분기가 있다.

v0.28.0 에서 실제로 도달 가능했다는 것도 확인했다: `KEBAB_PDF_OCR_ENGINE` env 와
config 파일 양쪽으로 켤 수 있었고(같은 태그의 테스트가
`c.ingest.pdf.ocr.engine == "paddle-onnx"` 를 고정), `pdf_ocr_apply.rs` 가 이미
`engine.recognize(...)` 를 부르며 `err={}` 로 찍었고, `REC_MIN_WIDTH` 는 어느
태그에도 없다. 더 이르지도 않다 — v0.26.2 는 PDF OCR 엔진이
`Option<OllamaVisionOcr>` 로 타입이 박혀 있어 선택 자체가 불가능했다.

다음 사람이 같은 실수를 안 하도록 "ingest.rs 만 훑으면 v0.31.0 처럼 보인다,
파일부터 찾아라" 를 문장에 적어 뒀다. 직전 커밋(478dafe) 메시지에도 같은 잘못된
집계가 남아 있다.

문단의 나머지(v0.32.0 까지 단일 DCTDecode 페이지 한정, #232 가 대상을 넓힌
것이지 만든 것이 아니라는 점, OR 필터)는 검증 결과 정확하다.

이번 변경은 문서 한 줄이라 코드·테스트에 영향 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:50:19 +09:00
478dafeab6 chore: PR #240 회차 3 리뷰 반영 — 회차 2 가 고치면서 새로 만든 둘
회차 3 은 3 건으로 수렴했고 그중 둘이 회차 2 가 만든 것이다.

1. 릴리스 경계를 바로잡다가 반대쪽으로 과하게 밀었다. "이 버그가 망칠 수
   있었던 스캔본은 사실상 전부 v0.33.0 이 만든 기록" 이라고 썼는데, 페이지
   렌더링(#232)이 v0.33.0 신규인 건 맞지만 스캔 PDF OCR 자체는 그보다
   오래됐다. `build_pdf_ocr_engine` 의 paddle 분기를 태그별로 세면 v0.28.0=0,
   v0.30.0=0, v0.31.0=1, v0.32.0=1 이다. v0.31.0 부터 PDF OCR 엔진으로
   paddle-onnx 를 고를 수 있었고 그때 `run_rec` 에는 폭 가드가 없었다. 다만
   v0.32.0 까지는 단일 DCTDecode 이미지 페이지만 대상이었다. #232 는 래스터
   경로를 임의 인코딩까지 넓힌 것이지 만든 것이 아니다.
   (리뷰는 v0.28.0 이라고 했으나 태그별로 직접 세어 v0.31.0 로 적었다.)

2. 색인 후에 쓸 대안 쿼리의 단위가 틀렸다. 앞 문장은 영향 **문서** 수를
   세라는데 `pdf_ocr_events` 는 페이지마다 한 행이고 유니크 제약도 없다 —
   40 쪽을 잃은 문서 하나가 40 을 더하고, 수정 전 색인을 두 번 돌렸으면 또
   두 배다. `count(DISTINCT doc_id)` 가 둘 다 접는다(이 경로는 doc_id 를
   무조건 넘기므로 NULL 로 빠지는 행이 없다). 그리고 `'ocr_error'` 는
   `recognize()` 의 모든 실패를 받는 통칭이라 기본 엔진 ollama-vision 을 쓰는
   KB 도 #239 와 무관한 행을 쌓는다. V008 에 `ocr_engine` 컬럼이 이미 있으니
   조건으로 거른다. paddle-onnx 안에서도 상한이라는 점은 문장으로 밝혔다.

3. ARCHITECTURE.md 에서 이 PR 이 다시 쓴 줄이 하필 이 PR 이 "조용히
   무시된다" 고 문서화한 옛 키(`[image.ocr] engine`)를 쓰고 있었다. 같은 표
   바로 아래 PDF 행은 이미 `[ingest.pdf.ocr]` 형태다.

곁다리: 회차 3 델타가 mock_ocr.rs 에 rustfmt 어긋남을 1 건 만들었다
(main 0 → 1). 이 PR 은 새 어긋남 0 을 유지해 왔으므로 되돌렸다.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 무경고,
손댄 파일의 rustfmt 어긋남이 main 과 동일(새로 만든 것 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:31:44 +09:00
b60148b123 chore: PR #240 회차 2 리뷰 반영 — DOGFOOD 스니펫이 조용히 무시되는 옛 키였다
가장 나쁜 것부터.

1. 회차 1 에서 DOGFOOD §1.2 에 새로 넣은 config 스니펫이 `[image.ocr]` 였다.
   옛 키 자동 이관은 파일의 `schema_version` 이 5 보다 낮을 때만 돌고
   (`kebab-config/src/lib.rs` 의 `unwrap_or(1)` 게이트), `deny_unknown_fields`
   가 없어서 현행 v5 파일에 붙여넣으면 serde 가 통째로 버리고 경고도 안 낸다.
   `kebab doctor` 도 "config up to date" 라고 답한다. 격리 KB 로 확인: v5 +
   옛 키 → OCR 단계 자체가 없음(`parse 1ms · chunk 723ms · embed 0ms`),
   `[ingest.image.ocr]` 로 바꾸면 `ocr(ppocrv5-mobile-kor)` 가 돈다.
   그래서 같은 커밋이 추가한 1.2.f 가 no-op 이 될 뻔했다 — OCR 이 꺼지면 모든
   이미지가 "본문 0 자 + provenance 깨끗" 으로 나오는데, 이건 문서가 "글자
   없는 사진이라 정상" 이라고 읽으라고 적어 둔 바로 그 모양이다. 키를 고치고,
   같은 함정을 다음 사람이 안 밟게 §1.4 가 쓰는 형식의 주의 문단을 붙였다.
   1.2.f 에도 "진행 출력에 ocr 단계가 찍히는지 먼저 보라" 를 넣었다.

2. HOTFIXES 의 "v0.33.0 이전에 색인된" 이 한 릴리스 어긋났다. v0.33.0 태그가
   이 PR 의 base(5596d41) 자체이고 그 커밋이 `err={}` 를 담고 있다. 게다가
   스캔 PDF 페이지 렌더링(#232)이 v0.33.0 에서 처음 나갔으므로, 이 버그가
   망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 문장대로
   감사하면 첫 LIKE 만 돌려 0 건을 보고 정반대 결론에 닿는다.

3. 상수 주석이 아직도 테스트가 위쪽 방향까지 잡는다고 말했다. 회차 1 은
   문장을 길게 바꿨을 뿐 주장은 그대로 뒀다. 위쪽은 `const _` 가 잡고 테스트는
   못 잡는다는 걸 그대로 적었다.

4. `{e:#}` 를 고정하는 테스트가 없었다. mock 오류가 단층이라 anyhow 가 `{}` 와
   `{:#}` 에서 똑같이 찍었고, 그래서 되돌려도 통과했다. mock 을 실제와 같은 두
   층으로 만들고 안쪽 원인을 단언한다. `{}` 로 되돌리면 실패하는 것 확인.

나머지:
- `pdf_ocr_apply.rs:475` 가 회차 1 반영 커밋 자신에 밀려 어긋났다 (같은 종류가
  한 PR 에서 두 번). 줄 번호를 빼고 함수명으로 고정했다.
- const-assert 주석이 안 잰 구간(17~39)을 잰 것처럼 말했다. 게다가 17 로
  올리면 새로 버려지는 건 폭 16 — 직접 비어 있다고 잰 폭이다.
- "재처리 대상은 PDF 전부" 정정이 HOTFIXES 에서 멈춰 ARCHITECTURE 와 컴포넌트
  README 에는 아직 "스캔본" 이라고 적혀 있었다.
- 컴포넌트 README 의 `engine_id() / run(...)` 은 존재한 적 없는 시그니처다.
  산문과 mermaid 다이어그램 양쪽을 실제 trait 에 맞췄다.
- §1.2 verify 의 `err=rec session run` 은 이미지 경로가 안 내는 문자열이다
  (KB 전체 집계용을 이미지 전용 절에 복사해 왔다).
- 영향 문서 집계 쿼리에 실행 시점이 빠져 있었다. bump 가 documents 행을
  purge 하므로 한 번 색인한 뒤에는 항상 0 이 나온다. "색인 전에 세라" 와,
  이미 색인했을 때 쓸 `pdf_ocr_events` 대안을 적었다.
- Gitea PR 본문을 현재 브랜치에 맞게 다시 썼다.

알고 남긴 것: 커밋 d6654ab 메시지의 "실제 하한은 4 였다" 는 틀렸다 (하한은 5,
4 는 가장 큰 실패 폭 — 같은 메시지 두 문단 뒤와 자기모순). 코드와 문서는 전부
정확하다. 고치려면 리뷰 중인 브랜치에 강제 푸시가 필요해서 두었다.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 무경고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:08:38 +09:00
77ed432af8 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>
2026-08-28 17:39:53 +09:00
d6654ab3da fix(parse-image): #239 얇은 검출 박스가 이미지 OCR 전체를 날리던 문제
`run_rec` 이 크롭을 높이 48 로 리사이즈하면서 폭을 1 이상으로만 보장했다.
PP-OCRv5 rec 백본은 `T = ceil((w-4)/8)` 개의 CTC 타임스텝을 내므로 `w <= 4`
에서 특징맵이 0 열로 접히고, ORT 가 세션 실행 전체를 실패시킨다. 그 오류가
`recognize` 의 `?` 를 타고 나가면서 이미 인식해 둔 나머지 박스까지 전부
버려졌다 — 색인은 성공으로 끝나므로 검색이 0 건 나올 때까지 안 드러나는
조용한 손실이었다.

`REC_MIN_WIDTH = 5` 미만이면 세션에 넣지 않고 빈 문자열로 돌려보낸다. 바로
아래 `text.is_empty()` 분기가 그 박스만 버리고 나머지를 살린다.

이슈는 16 을 제안했지만 번들 모델을 직접 스윕하니 실제 하한은 4 였다(1~4
전부 실패, 5~48 전부 성공, 픽셀 내용과 무관하게 경계가 딱 떨어짐). 16 을
써도 잃는 건 사실상 없지만(5~15 구간은 잉크가 있어도 빈 문자열을 낸다),
5 가 그래프의 진짜 하한이라 상수 이름과 주석이 거짓이 되지 않는다.

회귀 테스트는 양방향을 고정한다. `1..REC_MIN_WIDTH` 는 세션에 닿지 않아야
하고(가드를 지우면 실패), 정확히 `REC_MIN_WIDTH` 는 진짜 세션을 통과해야
한다(상수가 모델의 실제 하한보다 낮으면 실패). 모델 에셋이 in-tree 라
skip 가드는 붙이지 않았다.

parser_version cascade: 코드만 고치면 이미 색인된 문서에 닿지 않는다.
`image-meta-v1` → `image-meta-v2`, `pdf-text-v2` → `pdf-text-v3` (스캔 PDF
도 같은 `run_rec` 을 탄다). 실패한 OCR 은 derivation_cache 에 저장되지
않으므로, 다음 ingest 는 전량 재추출하되 이미 성공한 것은 캐시 히트로
엔진을 건너뛰고 이 버그로 실패했던 문서만 실제로 다시 OCR 된다.

실측 (도그푸딩 말뭉치 이미지 240 장, 같은 엔진·같은 설정):
- OCR 실패 36/240 (15.0%) → 0/240
- 되살아난 본문 11,747 자 (최대 3,950 자, `Clickpath_Analysis.png` 은 얇은
  조각 하나 때문에 이미 인식된 87 개 영역을 통째로 잃고 있었다)
- 원래 성공하던 204 장은 인식 글자 수 변화 0 — 순수 가산

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:51:05 +09:00