fix(parse-image): #239 얇은 검출 박스가 이미지 OCR 전체를 날리던 문제 #240
Reference in New Issue
Block a user
Delete Branch "fix/paddle-onnx-rec-min-width"
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?
Closes #239.
요약
OnnxPaddleOcr::run_rec이 크롭을 높이 48 로 리사이즈하면서 폭을 1 이상으로만 보장했다. PP-OCRv5 rec 백본은T = ceil((w-4)/8)개의 CTC 타임스텝을 내므로w <= 4에서 특징맵이 0 열로 접히고, ORT 가 세션 실행 전체를 실패시킨다. 그 오류가recognize의?를 타고 나가면서 이미 인식해 둔 나머지 박스까지 전부 버려졌다. 바로 아래 줄(if text.is_empty() { continue; })이 "박스 하나가 비면 나머지는 살린다"는 의도를 담고 있는데 오류 경로에만 그 방어가 없었다.색인 자체는 성공으로 끝나므로 조용한 손실이었다 — 이미지는 본문에 파일명만 남고, 스캔 PDF 페이지는 청크가 0 이 된다.
REC_MIN_WIDTH = 5미만이면 세션에 넣지 않고 빈 문자열로 돌려보낸다.이슈가 제안한 16 대신 5 를 쓴 이유
번들된
korean_ppocrv5_mobile_rec.onnx를 직접 스윕했다. 서로 다른 경로로 세 번 쟀고 같은 표가 나왔다.Invalid input shape: {1,0}경계는 딱 떨어지고 픽셀 내용과 무관하다(흰 이미지·체커보드·글자 렌더 동일).
T = ceil((w-4)/8)이w <= 4에서 0 이 되고 오류의{1,0}이 그 0 이다. 잰 44 개 폭 전부가 이 식에 맞았다.16 을 써도 잃는 건 사실상 없다(폭 40 대조군은
"1"0.97 /"3"0.999 를 읽는데 516 구간은 잉크가 있어도 전부 빈 문자열이었다). 다만 5 가 그래프의 진짜 하한이라 상수의 이름과 주석이 거짓이 되지 않고, 지금 정상 동작하는 폭 515 구간을 새로 버리지도 않는다.검사는
crop.width()가 아니라 리사이즈 이후의new_w에 건다. 3×200 크롭은 폭이 3 인데new_w가 1 이고, 3×10 크롭은 같은 폭 3 인데new_w가 14 다.parser_version cascade — 비용을 정확히
코드만 고치면 이미 색인된 문서에 닿지 않는다. 이미지 실효
parser_version은image-meta-v1|chunk:…|ocr:1:paddle-onnx:<모델 blake3>인데 이미지 파일도 모델 에셋도 안 바뀌었으니 서명이 같고try_skip_unchanged가 건너뛴다. #232 의 원칙 그대로다.image-meta-v1→image-meta-v2pdf-text-v2→pdf-text-v3(스캔 PDF 도 같은run_rec을 탄다)비싼 OCR 은 대부분 다시 안 돈다. 실패한 OCR 은
derivation_cache에 저장되지 않으므로(Err분기가derivation_cache_put앞에서 빠져나간다), 이미 성공했던 문서는 소스-바이트 키로 캐시에 히트해 엔진 호출을 건너뛴다. 실제로 다시 OCR 되는 건 이 버그로 실패했던 문서뿐이다.하지만 나머지 비용은 전부 든다.
id_for_doc이 접는 건 composite 가 아니라 base PARSER_VERSION 이라, 이 bump 는 모든 이미지·PDF 문서의 doc_id 를 바꾼다. 그러면purge_workspace_path_for_parser_bump가 돌아 documents 행이 지워지고(blocks·chunks·embedding_records CASCADE) Lance 벡터도 전량 삭제된 뒤 재파싱·재청킹·재임베딩·재삽입이 이어진다. 그리고 이 비용은 버그가 닿지 않는 KB 에도 걸린다 — 기본 엔진은ollama-vision, 이미지 OCR 기본 off,PdfOcrCfg::defaults()도enabled: false, always_on: false다.그래도 base 를 올리는 쪽을 택했다. 대안은
ingest_config_signature의ocr_engine_version_for_sig에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화하는 것이고, 그러면 doc_id 도 유지된다. 더 정확하지만 #232 가 세운 선례 옆에 두 번째 무효화 경로를 만드는 일이고, 이 저장소는 단일 사용자용이라 손해 보는 KB 가 사용자 자신의 것 하나다. 다중 사용자 배포로 가면 다시 볼 결정이고, HOTFIXES 에 그렇게 적어 뒀다.곁다리로 고친 것: PDF OCR 실패 기록이 원인을 잘라먹고 있었다
PDF 경로가 provenance 노트를
err={}로 찍었는데 anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 그래서 저장된 문자열이err=rec session run에서 끝나고 ORT 원인이 사라졌다 — 이슈 진단에 쓰는provenance_json LIKE '%Invalid input shape%'가 스캔 PDF 를 한 건도 못 찾는 이유다. 이미지 경로는 처음부터{err:#}였다.{e:#}로 맞춰 두 경로의 형식을 통일했다.이 수정 이전에 나간 모든 릴리스가(v0.33.0 포함) 잘린 노트를 썼다. 하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 기존 KB 를 셀 때는
OR provenance_json LIKE '%err=rec session run%'을 함께 걸어야 한다.고치지 않고 남긴 것
recognize의 박스 루프 안self.run_rec(&crop)?는 그대로 뒀다. 폭 가드가 알려진 유일한 방아쇠를 닫았고T >= 1이w >= 5에서 항상 성립한다. 박스 단위로 살리려면 그?를continue로 바꿔야 하는데 그러면 클래스 수 검사까지 삼킨다. 다만 그게?를 유지할 결정적 이유는 아니다 — 클래스 차원은 정적 그래프 메타데이터라from_paths로 옮기는 편이 낫고, 옮길 때는continue에tracing::warn!을 반드시 달아야 한다. #239 의 방아쇠와 무관한 별개 정리라 이번에는 안 했다.검증
실측 — 도그푸딩 말뭉치 이미지 240 개 파일. 같은 엔진(
ppocrv5-mobile-kor-1b55f062d055), 같은 설정(score 0.3 / unclip 1.5 / max_boxes 1000 / max_pixels 2048)으로 수정 전후 비교.실패 36 장은 charts 8 · english-text 10 · korean-text 9 · photos 9 로 특정 종류에 몰려 있지 않았다. 되살아난 본문 합계 11,747 자(중앙값 37, 최대 3,950).
charts/Clickpath_Analysis.png은 얇은 조각 하나 때문에 이미 인식된 87 개 영역을 통째로 잃고 있었다(수정 후 1,116 자).부작용 없음: 원래 성공하던 204 장의 인식 글자 수가 한 장도 변하지 않았다.
회귀 테스트 —
rec_min_width_is_the_graph_floor가 상수를 위아래로 가둔다.1..REC_MIN_WIDTH는 세션에 닿지 않아야 하고 → 가드를 지우면 실패. 정확히REC_MIN_WIDTH는 진짜 세션을 통과해야 한다 → 상수가 모델의 실제 하한보다 낮으면 실패.const _: () = assert!(REC_MIN_WIDTH <= 16, …)→ 100 으로 올리면error[E0080]로 빌드가 안 된다.초안은 아래쪽만 있었고, 그래서 상수를 100 으로 바꿔도 통과했다 — 두 단언 모두 상수가 커질수록 더 잘 통과하기 때문이다. 그 상태면 폭 100 미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다.
테스트는
ModelPaths::from_default_dir()대신CARGO_MANIFEST_DIR에서 경로를 만든다. 전자는KEBAB_IMAGE_OCR_MODEL_DIR을 타므로 상수를 엉뚱한 모델에 대고 재게 된다. 모델 에셋이 in-tree 라 skip 가드는 없다.{e:#}노트도ocr_engine_failure_surfaces_as_warning이 고정한다. 원래 mock 이 단층 오류를 내서{}로 되돌려도 통과했다 — anyhow 는 원인 없는 오류를 두 형식에서 똑같이 찍는다. mock 을 실제와 같은 두 층 오류로 바꾸고 안쪽 원인까지 단언하게 했다.스냅샷 —
pdf-text-v3가vector_pdf_canonical.json을 움직인다. #232 때와 같은 형태로 파생 식별자와 버전 문자열만 바뀌고 본문·inlines·source_span·metadata 는 동일함을 diff 로 확인했다.전체 —
cargo test --workspace --no-fail-fast1301 passed / 0 failed / 64 ignored,cargo clippy --workspace --all-targets -- -D warnings경고 없음. (cargo fmt --check는 main 에도 275 곳이 어긋나 있고 CI 게이트가 아니라 손대지 않았다. 이 PR 이 새로 만든 어긋남은 0 곳.)리뷰 이력
회차 1 · 회차 2 모두 여러 관점으로 훑고 각 지적을 적대적으로 재검증한 뒤 반영했다. 회차 1 은 19 건, 회차 2 는 16 건이 검증을 통과했고 코멘트로 남겨 뒀다. 회차 2 가 잡은 것 중 무거운 것: DOGFOOD 에 새로 넣은 설정 스니펫이
schema_version = 5에서 조용히 무시되는 옛 키([image.ocr])라 시나리오가 no-op 이 될 뻔했고,{e:#}를 고정하는 테스트가 없었으며, 상수 주석이 여전히 테스트가 위쪽 방향까지 잡는다고 말하고 있었다.후속
parser_versioncascade 는 CLAUDE.md §Release 의 minor bump 트리거다(#232 선례). 릴리스 컷은 별도 커밋에서.회차 1 리뷰 — REQUEST_CHANGES 상당
5개 렌즈(정확성 / 버전 캐스케이드 / 테스트 / 문서 / 자잘한 지적)로 훑고, 나온 지적을 각각 적대적으로 재검증해 살아남은 것만 적는다. 32건 중 19건이 남았다.
병합을 막는 결함은 없다. 폭 하한은 독립 재측정으로 맞았고(
T = ceil((w-4)/8)이 실측 44개 폭 전부에 맞음, 5~48 전부 성공), 240장 수치 3건도 재현됐다(3950 / 1116자·87영역 / 1088자). 가드 위치(crop.width()아닌new_w), PDF 경로 커버, "실패한 OCR 은 캐시에 안 들어간다" 주장(양쪽 경로 모두 성립), 스냅샷 재생성,cargo fmt --check도 문제 없었다.무거운 것 둘
1. 테스트가 한 방향만 고정한다 —
crates/kebab-parse-image/src/paddle_onnx.rs:60, 1041REC_MIN_WIDTH를 100 으로 올려도 테스트는 green 이다(대조: 1 로 낮추면Invalid input shape: {1,0}로 실패).1..REC_MIN_WIDTH루프는 가드가 먹으므로 상수를 올릴수록 통과 범위만 넓어지고,run_rec(crop(REC_MIN_WIDTH))도 폭이 클수록 세션이 더 잘 돈다. 상수가 100 이면 높이 48 기준 폭 100 미만 크롭 — 글자 한두 개짜리 박스 전부 — 이 조용히 버려지는데, 이 PR 이 고치는 것과 정확히 같은 종류의 손실이다.:60 의 "
rec_min_width_is_the_graph_floorpins both sides of that boundary" 는 사실이 아니다.REC_MIN_WIDTH - 1을 실제 세션에 넣어 보는 건 가드가 같은 함수 안에 있어 값싸지 않으니, HOTFIXES 가 이미 기록한 실측("5~16 은 잉크가 있어도 빈 문자열")을 근거로 위쪽 상한을 한 줄 걸고 주석·테스트 이름을 실제 고정 범위에 맞출 것.2. 캐스케이드 비용 분석이 절반이다 —
tasks/HOTFIXES.md:81id_for_doc이 base PARSER_VERSION 을 접으므로(kebab-parse-image/src/lib.rs:109,kebab-parse-pdf/src/lib.rs:99— composite 는 그 뒤에canonical.parser_version에만 찍힌다) 이 bump 는 모든 이미지·PDF 문서의 doc_id 를 바꾼다. 그러면ingest.rs:996의purge_workspace_path_for_parser_bump가 돌아 documents 행 삭제(→ blocks/chunks/embedding_records CASCADE) + 해당 chunk_id 의 Lance 벡터 전량 삭제 후 재파싱·재청킹·재임베딩·재삽입이다. doc_id 는 wire 필수 필드이자kebab inspect doc <id>핸들이다.그리고 이 비용은 버그가 닿지 않는 KB 에도 걸린다 — 기본 엔진은
ollama-vision(kebab-config/src/lib.rs:606), 이미지 OCR 기본 off,PdfOcrCfg::defaults()도enabled: false, always_on: false.둘 중 하나로 명시할 것. (a) bump 를 유지하고 이 문단에 빠진 비용을 적는다. (b)
ingest_config_signature의ocr_engine_version_for_sig(ingest.rs:3669) 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화한다 — doc_id 유지, ollama-vision·OCR off KB 무영향, OCR 캐시 히트도 그대로.나머지
3.
crates/kebab-app/src/ingest.rs주석 4곳이 옛 버전을 현재형으로 말한다 — :1632 / :1679image-meta-v1, :2544 / :2581pdf-text-v2. #232(2871da1)가 자기 bump 때 PDF 두 줄을 정확히 이렇게 갱신한 선례가 있고(v0.26.2:접두사가 붙은 :2581 포함), 이 PR 은 문서와 테스트를 다 쓸었으므로 이 4곳만 남았다. HOTFIXES·release-notes·DOGFOOD:572 의 옛 문자열은 날짜 기록이라 건드리지 말 것.4.
crates/kebab-chunk/src/pdf_page_v1.rs:403이ParserVersion("pdf-text-v2")를 하드코딩한다. 아무것도 단언하지 않고id_for_doc입력으로만 쓰여 테스트는 통과하지만,kebab-chunk는 §8 경계상kebab-parse-pdf를 import 할 수 없어 실제 버전 문자열을 넣으면 다음 bump 마다 또 상한다."test-parser-v1"같은 버전 중립 리터럴로 바꿀 것.5. HOTFIXES 가 권한 진단 SQL 이 PDF 피해 문서를 하나도 못 찾는다 —
tasks/HOTFIXES.md:72. PDF 경로는 노트를err={}로 찍는데(pdf_ocr_apply.rs:449-455) anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 저장되는 문자열이err=rec session run에서 끝나고Invalid input shape가 안 들어간다. 이미지 경로는{err:#}라(ingest.rs:2090) 걸린다. PDF 쪽을{e:#}로 맞춰 두 경로 노트 형식을 통일하는 편이 안내 문구를 고치는 것보다 낫다.6.
paddle_onnx.rs:299는 이 PR 적용 전 줄 번호 —tasks/HOTFIXES.md:98. 이 커밋이 :51-58 에 상수 8줄을 넣어 실제 위치는 307 이고, 299 는 지금 박스 정렬 클로저 꼬리(});)다. HOTFIXES 는 머지 후 현재 진실이라 머지 시점에 이미 틀린 참조가 들어가면 안 된다. (같은 항목의ingest.rs:1750·pdf_ocr_apply.rs:475는 정확하다.)7. "클래스 수 검사가 fatal 이라
?를 유지한다" 는 논거가 약하다 —tasks/HOTFIXES.md:98. 클래스 차원은 정적 그래프 메타데이터다(번들 rec 출력 shape[-1, -1, 11947],Session::outputs[0].output_type.tensor_dimensions()로 로드 시점에 읽힘). 그러니 그 검사는from_paths의dict.len() != DICT_LINESbail(:173-179) 옆으로 옮길 수 있고, 그러면 잘못된 모델은 박스가 검출되는 이미지를 기다릴 것 없이 엔진 생성에서 즉시 터진다. 병합 차단은 아니고 후속으로 충분하지만 문단의 논거는 지금 형태로는 약하다. 옮긴다면 :307 의?를continue로 바꿀 때tracing::warn!을 반드시 함께 달 것 — 맨continue는 이 PR 이 없애려는 조용한 손실을 다른 자리로 옮기는 셈이다.8. 테스트가
ModelPaths::from_default_dir()을 쓴다 —paddle_onnx.rs:1034. 이건KEBAB_IMAGE_OCR_MODEL_DIRenv override 를 탄다(:113-115). 이 테스트의 목적은 상수를 번들된 모델에 붙들어 두는 것인데, 사용자가 자기 모델 디렉토리를 export 해 둔 셸에서cargo test를 돌리면 엉뚱하게 터지고 메시지가 원인을 못 가리킨다.tests/paddle_e2e.rs는 같은 조건에서 SKIP 으로 빠지므로 이 테스트만 노출돼 있다.9. DOGFOOD 에 남긴 변경이
pdf-text-v3문자열 하나뿐이다. 부록 A 가 "HOTFIXES 의 deviation 발견 시 관련 section 의 verify step 강화"를 요구하는데 87줄짜리 deviation 을 추가하면서 §1.2 Image ingest 에는 아무것도 안 했다. #232 는 같은 상황에서 §1.4 에 config 블록 + 이슈 번호 붙인 verify 항목 + 신규 시나리오 1.4.d 를 10줄 추가했다.paddle-onnx는 DOGFOOD.md 전체에 0번 등장하고, §13.4 Image corpus 는 아직 "(P6 dogfood — 향후 추가)" 한 줄이다. 이번 근거가 바로 그 코퍼스 스윕인데 카탈로그가 비어 있다.10.
docs/components/parse/README.md안에서 비대칭이 생겼다. PDF 불릿(:104)에는 #232·#239 사유를 다 적었는데 다이어그램의 Image 값만image-meta-v2로 올리고 Image 불릿(:107-112)에는 PARSER_VERSION 줄이 없다. 덧붙여 :110 의 "OllamaVisionOcr { … }— v1 유일 구현" 은 v0.27.0 의OnnxPaddleOcr(paddle_onnx.rs:220의impl OcrEngine) 때문에 틀렸고 :37-43 다이어그램도 백엔드가 하나뿐이다. ARCHITECTURE.md:23 은 "2 백엔드"라고 적고 있다. 이 PR 이 만든 staleness 는 아니지만 바로 그 두 줄 사이를 건드렸다.11.
(§3.4, v0.31.0 #217)는 따라갈 곳이 없는 참조 —tasks/HOTFIXES.md:81. 문서 이름이 없고, 남아 있는 설계 계약에서 §3.4 는 CanonicalDocument 다(그 번호가 가리키던 2026-05-31 스펙은 doc-reorg 에서 삭제됐다). 살아 있는docs/ARCHITECTURE.md:35를 가리키거나 절 번호를 뺄 것.12.
### 스냅샷 낙수—tasks/HOTFIXES.md:83.낙수는 저장소 전체에서 이 줄에만 있고 낙수 효과 아니면 落穗 로 읽힌다. 같은 항목 :81·:131 이 이미캐스케이드를 쓴다.13. "서로 다른 조사 레인 + 검증 레인" —
tasks/HOTFIXES.md:29. 저장소 독자가 알 수 없는 내부 용어이고grep -rn "레인" --include=*.md .결과 이 줄뿐이다. "서로 다른 경로로 세 번 재 봤고 같은 표가 나왔다" 로 풀 것.14. 소제목과 본문의 숫자가 엇갈려 읽힌다 —
tasks/HOTFIXES.md:27"하한은 16 이 아니라 5" vs :29 "실제 한계는 4". 둘 다 맞지만(4 = 마지막 실패 폭, 5 = 첫 성공 폭) 같은 "16 을 제안했지만 → 실제 X" 구조를 X 만 바꿔 반복해서 4 가 상수 값으로 읽힌다.회차 2 리뷰 — REQUEST_CHANGES 상당
세 갈래로 봤다. (1) 회차 1 의 11 개 항목이 실제로 해소됐는지 직접 재현, (2) 회차 2 델타만 새 코드로 보기, (3) 두 회차가 모두 놓친 것. 나온 지적을 각각 적대적으로 재검증해 23 건 중 16 건이 남았다.
회차 1 은 실험으로 확인했다.
REC_MIN_WIDTH를 100 과 17 로 각각 바꿔 빌드 → 둘 다error[E0080]: evaluation panicked: REC_MIN_WIDTH is past the measured no-loss ceiling (16)로 컴파일 실패. 가드만 지우고 테스트 →w=1 must never reach the rec session: … Invalid input shape: {1,0}. 상수를 1 로 내리고 테스트 →REC_MIN_WIDTH=1 must survive the rec session: …. 네 방향 모두 의도대로 잡힌다.KEBAB_IMAGE_OCR_MODEL_DIR을 빈 디렉토리로 export 한 채 테스트 → 통과(경로 고정 동작). 코퍼스 표도 실측과 한 글자도 안 틀린다(charts 50 / english-text 70 / korean-text 70 / photos 50, jpg 172 · png 63 · jpeg 4 · tif 1, 실패 8+10+9+9=36).무거운 것
1. 새로 넣은 DOGFOOD 설정 스니펫이 동작하지 않는 키다 —
docs/DOGFOOD.md:127이 PR 이 §1.2 에 추가한 블록이
[image.ocr]인데, 옛 키 자동 이관은 파일의schema_version이 5 보다 낮을 때만 돈다(crates/kebab-config/src/lib.rs:1307-1325,unwrap_or(1)).deny_unknown_fields가 없어서 현행schema_version = 5파일에 붙여넣으면 serde 가 통째로 버리고 경고 한 줄 없이 무시한다.kebab doctor조차 "config up to date (schema v5)" 라고 답한다.격리 KB 실측: v5 config + 이 스니펫 →
parse 1ms · chunk 723ms · embed 0ms, OCR 단계 자체가 없음.[ingest.image.ocr]로 바꾸면 →ocr(ppocrv5-mobile-kor)…+ocr 10ms. 도그푸딩 스토어의 실제 config 도schema_version = 5+[ingest.image.ocr]다.그래서 같은 PR 이 추가한 1.2.f 가 무력화된다 — OCR 이 꺼지면 모든 이미지가 "본문은 파일명뿐 + provenance 에 오류 없음" 으로 나오는데, 이건 문서가 "글자 없는 사진이라 정상적으로 0 자" 라고 읽으라고 적어 둔 바로 그 모양이다. 도그푸더가 no-op 을 성공으로 오독한다.
2. HOTFIXES 의 릴리스 경계가 한 칸 어긋났다 —
tasks/HOTFIXES.md:78"v0.33.0 이전에 색인된 스캔 PDF 는 이 필터에 안 걸린다" 인데,
refs/tags/v0.33.0=5596d41= 이 PR 의 base 이고 그 커밋의pdf_ocr_apply.rs:450이err={}를 그대로 쓴다.{e:#}는 이 PR 이 넣는 것이다. 게다가git merge-base --is-ancestor 2871da1 v0.32.0→ 아님,… 5596d41→ 맞음 — 즉 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔다.그래서 v0.33.0 은 영향 범위 "안" 에 있는 정도가 아니라, 페이지 렌더 스캔본에 관한 한 영향 범위 전부다. 이 문장대로 감사하면 첫 LIKE 만 돌려 보고 0 건이 나오니 "내 KB 는 깨끗하다" 는 정반대 결론에 닿는다.
3. 상수 주석이 아직도 테스트가 위쪽 방향까지 잡는다고 말한다 —
crates/kebab-parse-image/src/paddle_onnx.rs:60-62"
rec_min_width_is_the_graph_floorholds the constant from both directions" — 주어가 테스트 이름이므로 위쪽까지 그 테스트가 붙잡는다는 주장이다. 회차 1 이 지적한 바로 그 거짓 진술이고,git show 77ed432를 보면 "pins both sides of that boundary" 를 같은 뜻의 더 긴 문장으로 바꿨을 뿐이다. 세 문단 뒤 HOTFIXES 가 스스로 반박한다.4.
{e:#}를 고정하는 테스트가 없다 —crates/kebab-app/tests/pdf_ocr_apply.rs:310{:#}로 되돌려도ocr_engine_failure_surfaces_as_warning이 통과한다. mock 의 오류(mock_ocr.rs:55)가 단층이고, anyhow 는 원인 없는 오류를{}와{:#}에서 똑같이 찍기 때문이다(실측: 단층은 양쪽 다[mock failure], 두 층은[rec session run]vs[rec session run: Invalid input shape]). mock 을 두 층으로 만들고 안쪽 원인을 단언하면 한 줄로 막힌다.나머지
5.
pdf_ocr_apply.rs:475가 회차 1 반영 커밋 자신에 밀려 어긋났다 —tasks/HOTFIXES.md:87.d6654ab에서는 475 가continue;였는데77ed432가 같은Err분기에 주석 3 줄을 넣어 478 로 밀렸다. 지금 475 는image_height,다. 회차 1 의paddle_onnx.rs:299와 같은 종류가 한 PR 에서 두 번 났다. 숫자를 빼고 함수명으로 고정할 것.6. const-assert 주석이 안 잰 구간을 잰 것처럼 말한다 —
paddle_onnx.rs:67. "Above 16 the guard starts discarding crops the graph would have read" 인데 잰 건 516(빈 문자열)과 40(0.97+)뿐이고 1739 는 안 쟀다. 게다가 가드가new_w < REC_MIN_WIDTH를 버리므로 17 로 올리면 새로 버려지는 건 폭 16 — 프로젝트가 직접 비어 있다고 잰 폭이다. 첫걸음부터 반례다.7. "영향 범위는 전체 PDF" 정정이 HOTFIXES 에서 멈췄다 —
docs/ARCHITECTURE.md:27,docs/components/parse/README.md:109. 둘 다 아직 "기존 색인 스캔본 재처리 유발" 이라고 적는다.kebab-parse-pdf/src/lib.rs:99가 스캔 여부 판정 전에PARSER_VERSION을id_for_doc에 접으므로 재처리 대상은 PDF 전부다.8. 존재하지 않는 trait 시그니처 —
docs/components/parse/README.md:115의engine_id() / run(...) -> OcrText. 실제는ocr.rs:57/62/67/74의engine_name/engine_version/recognize/model이고engine_id는 저장소 전체에서 코드에 0 건이다. 같은 파일 37~40 줄 mermaid 다이어그램도 같은 거짓을 반복하는데, 회차 2 가OnnxPaddleOcr를 추가한 자리가 바로 그 블록 아래다. 이 PR 이 만든 것은 아니지만(문서 작성 시점부터 틀림) 바로 그 두 줄 사이를 건드렸다. 한 곳만 고치면 산문과 다이어그램이 서로 어긋난다.9. §1.2 verify 의 grep 문자열 절반이 이미지 경로에서 안 나온다 —
docs/DOGFOOD.md:136.err=접두사는pdf_ocr_apply.rs:453만 찍고, 이미지 경로는ingest.rs:2090의OcrFailed: OCR failed (engine=…): rec session run: … Invalid input shape형태다. KB 전체 집계용 문자열을 이미지 전용 절에 복사해 온 것.10. 집계 쿼리에 실행 시점이 빠졌다 —
tasks/HOTFIXES.md:78. 이 항목이 올린 bump 때문에 다음kebab ingest가 documents 행을 purge 하고 다시 쓰므로, 색인을 한 번 돌린 뒤에는 이 쿼리가 항상 0 을 준다. "업그레이드 → 색인 → 릴리스 노트 ���기" 순서면 안 당한 것처럼 보인다. PDF 쪽은pdf_ocr_events(V008)가 documents 에 FK 가 없어 살아남으므로WHERE success=0 AND reason='ocr_error'로 사후에도 셀 수 있다.11. PR 본문이 최초 작성 상태에 멈춰 있다. "재처리 비용은 싸다" 로 끝나는데 브랜치의 HOTFIXES 는 "하지만 나머지 비용은 전부 든다" 로 뒤집었다.
const _상한,{e:#}통일,CARGO_MANIFEST_DIR고정도 본문에 없다. 본문이 릴리스 노트의 재료라는 점(CLAUDE.md §Release)에서 정리할 값이 있다.12. 커밋
d6654ab메시지의 "실제 하한은 4 였다" — 하한은 5, 4 는 가장 큰 실패 폭. 같은 메시지 두 문단 뒤 "5 가 그래프의 진짜 하한" 과 자기모순이다. 트리는 전부 정확하다. 머지가Merge pull request형태라 그대로 두면 main 히스토리에 남지만, 고치려면 리뷰 도중 강제 푸시가 필요하므로 실행 영향 0 인 이 건만으로는 하지 않는 편이 낫다.확인했고 문제 없던 것:
const _는 무조건·비-cfgconst item 이라 모든 타깃·의존성 사용에서 평가된다(교차 컴파일 위험 없음).{:#}위치 인자 4 개가 정확히 대응하고 실제로 전체 체인을 뱉는다.pdf_page_v1.rs픽스처 변경은 아무 골든도 안 건드린다.ingest.rs주석 4 곳 전부 정확. det 쪽은det_target_dims가 양축을 32 로 바닥 처리해 같은 부류의 형제 버그가 없다. README/HANDOFF/INDEX/CHANGELOG 를 안 건드린 것도 CLAUDE.md 기준 정당하다(사용자 표면 무변경).회차 3 리뷰 — 지적 3 건, 병합 차단 없음
회차 2 의 11 개 항목 중 9 개가 해소됐고, 남은 것과 새로 찾은 것을 합쳐 11 건이 나와 검증 후 3 건이 남았다. 회차 1 은 19 건, 회차 2 는 16 건이었으니 수렴하고 있다. 두 렌즈 중 하나는 "지금 상태로 머지하겠다" 는 판정이다.
실행으로 확인한 것 (읽어서가 아니라 돌려서):
target/debug/kebab로 재현.[ingest.image.ocr] enabled/engine은 실제로 paddle-onnx 를 켜고ocr(ppocrv5-mobile-kor)를 찍는 반면,schema_version = 5에 옛[image.ocr]를 붙이면 경고도 OCR 단계도 없이 조용히 버려진다. 새 주의 문단이 말하는 그대로다.pdf_ocr_apply.rs:453을{:#}→{}로 되돌리면ocr_engine_failure_surfaces_as_warning이 실패한다. 두 층 mock 은fail=true호출부 한 곳만 쓰므로 다른 MockOcrEngine 테스트는 약해지지 않았다.w=12/13/45의 CTC 식, V008 DDL(FK 없음·컬럼명·'ocr_error'실제 기록·retention 30 일),ARCHITECTURE.md:35가 정말 derivation_cache 행인지, 인용한 두 함수명, 양쪽 경로의Err-before-derivation_cache_put,OcrCfg/PdfOcrCfg기본값, 번들 dict 11945 줄에 대한REC_CLASSES = DICT_LINES + 2. 전부 성립.cargo clippy --workspace --all-targets -- -D warningsexit 0.남은 3 건
1. 회차 2 가 과잉 수정했다 —
tasks/HOTFIXES.md:84"하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다" 는 전제는 맞지만 결론이 안 따라온다. 페이지 렌더링 이 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에는 폭 가드가 없다(git show v0.31.0:… | grep -c REC_MIN_WIDTH→ 0). 다만 v0.32.0 까지는extract_dctdecode_page_image경로라 단일 DCTDecode 이미지 페이지만 OCR 대상이었다. #232 는 래스터 경로를 임의 인코딩까지 넓힌 것이지 만든 것이 아니다.기록의 대부분이 v0.33.0 산인 건 맞���지만 "사실상 전부" 는 과하다. 마지막 문장(
OR …을 반드시 함께 걸어야 한다)은 그대로 맞다.2.
pdf_ocr_events대안 쿼리가 단위와 엔진 양쪽에서 어긋난다 —tasks/HOTFIXES.md:82앞 문장이 세라는 건 영향 문서 수인데(
provenance_json은documents에 있고 workspace_path 에 UNIQUE), 대안은pdf_ocr_events행을 센다. 이 표는 페이지마다 한 행이다(pdf_ocr_apply.rs:476이 페이지 루프 안에서failure_reason: Some("ocr_error")를 넣고continue,ingest.rs:2693이Finished마다 삽입). 40 쪽을 잃은 문서 하나가 첫 수치에 1, 둘째에 40 을 더한다.게다가
record_pdf_ocr_event는 맨 INSERT 이고 V008 에 유니크 제약이 없다. 실패 경로는derivation_cache_put앞에서 빠져나가므로 수정 전 색인을 여러 번 돌렸으면 매번 다시 실패하고 다시 삽입됐다.count(*)는 페이지 × 실행 횟수만큼 부풀고count(DISTINCT doc_id)가 둘 다 접는다. 이 경로는ingest.rs:2695가Some(&doc_id_for_log)를 무조건 넘기므로 NULL 로 빠지는 행은 없다.그리고
'ocr_error'는engine.recognize()의 모든 Err 를 받는 통칭이다(다른 값을 내는 곳은pdf_ocr_apply.rs:392의no_renderer/render_error/unopenable_pdf뿐이고, 주석에 적힌"timeout"은 생산자가 아예 없다). 그래서 기본 엔진인 ollama-vision 을 쓰는 KB — 같은 항목이 두 문단 아래에서 그게 기본이라고 적어 둔 그 KB — 도 #239 와 무관한 행을 쌓고 이 쿼리는 0 이 아닌 수를 준다. V008 에ocr_engine컬럼이 이미 있고engine.engine_name()으로 채워지므로 면책 문구 대신 조건으로 거를 수 있다.SELECT count(DISTINCT doc_id) FROM pdf_ocr_events WHERE success = 0 AND reason = 'ocr_error' AND ocr_engine = 'paddle-onnx'로 바꿀 것. paddle-onnx 안에서도 상한이라는 점은 한 구절로 밝히면 된다.3. 이 PR 이 다시 쓴 줄이 하필 이 PR 이 "조용히 무시된다" 고 문서화한 키를 쓴다 —
docs/ARCHITECTURE.md:23engine 선택은 [image.ocr] engine인데, 같은 표 바로 아래 행(:27, PDF parser)은 이미[ingest.pdf.ocr] render_library형태를 쓴다. 즉 문서 전반의 축약 표기가 아니라 홀로 남은 옛 키다.동작으로 재현했다:
Config::defaults()를 직렬화하면schema_version = 5파일이 나오고, 거기에[image.ocr] enabled = true / engine = "paddle-onnx"를 덧붙여Config::from_file로 읽으면 결과가enabled=false, engine=ollama-vision이다. 경고 한 줄 없다.migrate.rs:377의 image.ocr → ingest.image.ocr 이동이step_2_to_3에만 있고lib.rs:1311이from < CURRENT_SCHEMA_VERSION(=5) 일 때만 변환을 태우기 때문이다. 이 PR 이 이 함정을 DOGFOOD 에 적어 놓고 ARCHITECTURE 에서는 밟고 있다.(같은 파일 25 행의
image.caption.enabled와docs/SMOKE.md:714/747의image.ocr.enabled도 같은 옛 표기지만 이 PR 이 안 건드린 줄이라 범위 밖이다. 별건으로 쓸어도 좋다.)곁다리
회차 3 델타가
crates/kebab-app/tests/common/mock_ocr.rs에 rustfmt 어긋남을 1 건 새로 만들었다(main 0 → 1). 이 PR 은 그동안 새 어긋남 0 을 유지해 왔으므로 맞춰 두는 편이 낫다.무효 처리한 지적
"PR 본문이 아직 최초 상태" 라는 지적이 나왔으나 사실이 아니다 — API 로 확인하면 본문이 5,314 자로 갱신돼 있고
하지만 나머지 비용은 전부 든다/const _상한 / 리뷰 이력이 모두 들어 있다(updated 09:09:08Z). 검증 단계에서 걸러졌다.회차 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>회차 4 리뷰 — 지적 1 건 (반영 완료), 그 외 병합 가능
두 관점이 같은 한 건으로 모였고, 그 하나만 고치면 남는 게 없다는 판정이다. 회차별로 19 → 16 → 3 → 1 이다.
유일한 지적 — 회차 3 이 맞는 리뷰를 잘못된 측정으로 뒤집었다
tasks/HOTFIXES.md:84가 "PDF OCR 엔진으로 paddle-onnx 를 고를 수 있게 된 것이 v0.31.0" 이라고 적었으나 v0.28.0 이 맞다. 회차 2 리뷰어가 옳았고 회차 3 이 틀린 근거로 덮었다.원인은 세는 방법이다. 태그별로
crates/kebab-app/src/ingest.rs를 grep 했는데 그 파일이 v0.31.0 에서 생겼다 — 그 전에는build_pdf_ocr_engine이crates/kebab-app/src/lib.rs에 있었다(v0.28.0 은lib.rs:869, paddle 분기:890). 0 은 기능이 없다는 뜻이 아니라 파일이 없다는 뜻이었다. 파일을 먼저 찾고 세면:도입 커밋
901416d("feat(ocr): T7-T9 — config overrides + engine factory + signature cascade", 2026-06-04) 는git describe --contains로v0.28.0~1^2~4다. v0.28.0 에서 실제로 도달 가능했다는 것도 확인됐다 —KEBAB_PDF_OCR_ENGINEenv 와 config 파일 양쪽으로 켤 수 있었고(같은 태그 테스트가c.ingest.pdf.ocr.engine == "paddle-onnx"를 고정),pdf_ocr_apply.rs가 이미engine.recognize(...)를 부르며err={}로 찍었고,REC_MIN_WIDTH는 v0.28.0~v0.32.0 어느 태그에도 없다. 더 이르지도 않다 — v0.26.2 는 PDF OCR 엔진이Option<OllamaVisionOcr>로 타입이 박혀 선택 자체가 불가능했다.문단의 나머지(v0.32.0 까지 단일 DCTDecode 페이지 한정, #232 가 대상을 넓힌 것이지 만든 것이 아니라는 점,
OR필터)는 검증 결과 정확하다. →5815f74에서 반영, 다음 사람이 같은 실수를 안 하도록 "ingest.rs 만 훑으면 v0.31.0 처럼 보인다, 파일부터 찾아라" 를 문장에 남겼다.근거를 처음부터 다시 만들어 확인했다
수치를 읽어서가 아니라 수정 전 바이너리를
5596d41아카이브로, 수정 후를 HEAD 로 각각 새로 빌드해 같은 번들 모델·같은 설정으로 코퍼스를 다시 돌렸다. HOTFIXES 의 모든 headline 수치가 정확히 재현됐다.이전 회차들이 다시 유도하지 않았던 주장도 확인
id_for_doc이 base PARSER_VERSION 을 접는 것,PdfOcrCfg::defaults()의 enabled/always_on false,retention_days30, V008 에 documents FK 없고 유니크 제약도 없다는 것(그래서count(DISTINCT doc_id)가 맞는 모양),ocr_engine컬럼이TEXT NOT NULL이고engine_name()으로 채워진다는 것,doc_id가 이 경로에서 non-NULL 인 것,'ocr_error'가 모든recognize()Err 에 대한 단일 리터럴인 것, 양쪽Err분기가derivation_cache_put앞에서 빠져나가는 것, rec 그래프 출력이[-1,-1,11947]이라 클래스 검사 이전 방안이 성립한다는 것, §13.4 코퍼스 표(50/70/70/50, jpg 172 / png 63 / jpeg 4 / tif 1). 전부 성립.게이트
cargo clippy --workspace --all-targets -- -D warningsexit 0, 경고 0.cargo test --workspace --no-fail-fast188 개 바이너리에서 1301 passed / 0 failed / 64 ignored, exit 0. 이 PR 이 새로 만든 rustfmt 어긋남 0 (손댄 .rs 파일 전부 main 과 동일하거나 깨끗; 회차 2~4 가 건드린 테스트 파일은 깨끗하고, mock_ocr 편집은git diff -w가 비어 있는 공백 변경뿐이라 두 층 anyhow 오류는 그대로다).회차 3 항목 확인
pdf_ocr_events쿼리 →ocr_engine이 V008 의 실제TEXT NOT NULL컬럼이고paddle-onnx리터럴로 채워지며doc_id도 non-NULL 임을 확인, 새 문장이 "상한이라는 점은 남는다" 로 스스로 한계를 밝히는 것도 적절. 3.[ingest.image.ocr]정정 확인 + 이 PR 이 추가한 마크다운 줄의 config 키를 전부 훑어 살아 있는 키임을 확인(남은[image.ocr]한 곳은 이관 함정을 설명하려고 일부러 옛 키를 부르는 자리). 4. rustfmt 파리티는 요구 이상 — 손댄 10 개 파일이 HEAD 에서 rustfmt 깨끗.회차 5 리뷰 — APPROVE, 지적 0 건
회차 4 의 유일한 지적(노출 구간 v0.31.0 → v0.28.0)이
5815f74로 반영됐고, 그 한 줄만 확인했다.정정이 사실이다. paddle 분기를 넣은 커밋
901416d(2026-06-04) 에 대해git tag --contains의 첫 태그가 v0.28.0 이다.git grep paddle v0.26.2 -- crates/는 아무것도 안 나오고 저장소에 v0.27.x 태그는 없으므로,pdf.ocr.engine = "paddle-onnx"를 고를 수 있었던 첫 릴리스는 v0.28.0 이 맞다.덧붙인 괄호도 사실이다.
git ls-tree로 보면crates/kebab-app/src/ingest.rs는 v0.28.0 · v0.30.0 · v0.30.1 에 없고 v0.31.0 에서 처음 나타난다(00ead29, lib.rs → src/ingest 분리). 함수 위치는lib.rs:869(v0.28.0) →:926(v0.30.0, v0.30.1) →ingest.rs:727(v0.31.0) →:725(v0.32.0). "ingest.rs 만 훑으면 v0.31.0 처럼 보인다" 는 실재하는 함정이고, 적어 둔 범위("v0.28.0~v0.30.1 은 lib.rs")도 구간 내 모든 태그와 맞는다.새 시작점에서 전제도 성립한다. v0.28.0 시점에
pdf_ocr_apply.rs:180이 이미err={}로 노트를 찍었고paddle_onnx.rs:379에.context("rec session run")이 있었으며 v0.32.0 까지 그대로다. 그러니 그때부터 스캔 PDF 가 같은 조용한 손실을 겪을 수 있었다는 서술이 맞다.문서 한 줄뿐이다.
--stat이tasks/HOTFIXES.md1 파일 1 삽입 1 삭제. 코드·테스트·clippy·fmt 에 닿을 수 없다.문단이 어긋나지 않는다. 이제 v0.28.0 부터 선택 가능 → v0.32.0 까지는 단일 DCTDecode 페이지로 좁음 → v0.33.0 의 페이지 렌더링(#232)이 임의 인코딩까지 넓힘, 으로 단조롭게 읽힌다. 시작점을 앞당긴 것이 이웃 문장들과 충돌하지 않고, 오히려 뒤따르는 유보("기록의 대부분은 그 릴리스가 만든 것이겠지만, 전부는 아니다")를 더 정확하게 만든다. 항목 전체에서 시작 릴리스를 말하는 다른 문장은 없으므로 남은 v0.31.0 참조도 없다.
다섯 회차 요약
회차마다 여러 관점으로 훑고 각 지적을 적대적으로 재검증했다. 특히 회차 3 이 회차 2 의 맞는 지적을 잘못된 측정(존재하지 않는 파일을 grep)으로 뒤집었던 것을 회차 4 가 잡아냈고, 회차 5 에서 확정했다.
머지에 동의한다.