PDF OCR 이 DCTDecode 아닌 스캔본을 전량 건너뜀 — 페이지 렌더링으로 교체 (사용자 수동 정규화 제거) #232

Closed
opened 2026-08-05 06:44:45 +00:00 by altair823 · 1 comment
Owner

증상

스캔 PDF 를 ingest 하면 전 페이지가 OCR 없이 건너뛰어지고, 문서가 사실상 빈 내용으로 색인된다. 조용한 내용 손실이다.

  📷 OCR page 61...
  ⊘ OCR page 61 skipped (no DCTDecode or engine fail, 0ms)
  📷 OCR page 62...
  ⊘ OCR page 62 skipped (no DCTDecode or engine fail, 0ms)
  ... (68페이지 전부 동일)

전 페이지 0ms → OCR 엔진은 호출조차 되지 않았다. 엔진 실패라면 실제 latency 가 찍히고 failure_reason: Some("ocr_error") 가 붙는다(crates/kebab-app/src/pdf_ocr_apply.rs:257, :393).

원인

extract_dctdecode_page_image(crates/kebab-parse-pdf/src/page_image.rs:66-76)는 /Filter정확히 DCTDecode(JPEG) 인 image XObject 만 받는다. 주석에 "v1 scope = DCTDecode passthrough only" 로 명시된 의도적 축소 범위다.

걸러지는 것:

/Filter 용도 현재
CCITTFaxDecode 흑백 팩스·스캔 — 스캔 PDF 에서 가장 흔함
JBIG2Decode 흑백 고압축 스캔
FlateDecode PNG 계열 무손실
JPXDecode JPEG2000
[FlateDecode, DCTDecode] 체인 ✗ (배열 길이 1 조건, :69)
DCTDecode 인데 JPEG magic 없음 손상/변형 ✗ (:82)

주목할 점: 텍스트 게이트는 정상 작동했다. needs_ocr = chars < min_char_count(20) || valid_ratio < 0.5(pdf_ocr_apply.rs:143)를 통과했다는 건 kebab 이 "이 페이지는 스캔본이라 OCR 이 필요하다"고 올바르게 판정했다는 뜻이다. 판정은 맞았고 래스터를 못 꺼냈을 뿐이다. always_on 기본값이 false(crates/kebab-config/src/lib.rs:826)이므로, 전 페이지 시도 = 전 페이지가 텍스트 추출에 실패했다는 확증이다.

사용자 수동 정규화로 두면 안 되는 이유

현재 코드 주석은 사용자가 pdftoppm/ghostscript 로 PDF 를 JPEG 기반으로 재래스터화하는 것을 전제한다("see release notes for normalization guidance", pdf_ocr_apply.rs:160). 이건 성립하지 않는다.

  • 사용자는 자기 PDF 의 /Filter 가 뭔지 알 수 없고, 알아야 할 이유도 없다.
  • 실패가 stderr 한 줄로만 흐르고 IngestReport 에 집계되지 않는다. 대량 ingest 에서는 지나간다.
  • 색인은 "성공"으로 끝난다. 검색이 안 되는 시점에야 알게 되고, 그때 원인이 PDF 인코더라는 걸 역추적할 방법이 없다.
  • workspace 를 통째로 거는 사용 방식(kebab ingest)에서 문서별 전처리는 현실적이지 않다.

ingest 는 넣은 파일을 알아서 처리해야 한다. 사용자가 PDF 내부 인코딩을 신경 쓰는 순간 도구가 진 것이다.

부수 결함 2건 (같은 코드 경로)

1. 페이지 내 image XObject 선택이 비결정적

for (_name, obj) in xobject.iter()(page_image.rs:44)로 순회하며 처음 만난 DCTDecode 이미지를 반환한다. lopdf 의 dictionary 순회 순서는 보장되지 않으므로:

  • 로고 JPEG + CCITT 스캔이 같이 있는 페이지 → 로고를 OCR 하고 본문 스캔은 버린다
  • 같은 PDF 를 두 번 돌려도 다른 이미지가 선택될 수 있다 → 재색인 비결정성

2. CLI 가 failure_reason 을 버린다

IngestEvent::PdfOcrFinished { page, ms, chars, ocr_engine, skipped, .. } => {   // ← `..` 로 폐기
    "  ⊘ OCR page {page} skipped (no DCTDecode or engine fail, {ms}ms)"

(crates/kebab-cli/src/progress.rs:357-372) wire 이벤트는 원인을 구분해 실어 보내는데(None = 래스터 부재, Some("ocr_error") = 엔진 실패) 사람이 보는 출력에서 둘을 뭉갠다. #228 과 같은 계열 — 신호는 있는데 표시 단계에서 소실.

제안

본안: DCTDecode passthrough 를 페이지 렌더링으로 교체

커버리지 비용 평가
A. 페이지 렌더링 (pdfium) 필터 무관 · 다중 이미지 · 벡터 · 텍스트 혼합 전부 네이티브 의존성 1개 권장
B. 필터별 디코더 추가 추가한 필터만 CCITT/JBIG2 러스트 구현 빈약, 계속 늘어남 트레드밀. 다중 이미지·벡터 페이지는 여전히 못 함
C. 외부 도구 shell out 전부 poppler 설치 필요 사용자가 신경 써야 함 = 요구사항 위반

A 로 가면 page_image.rs 의 XObject traversal(~90줄)이 통째로 삭제된다. 추가가 아니라 교체이고 순감이다. 부수 결함 1번(비결정적 선택)도 함께 사라진다.

렌더러 선택 주의:

  • pdfium — BSD-3-Clause. pdfium-render 크레이트. 이쪽을 쓸 것.
  • mupdf — AGPL-3.0 / 상용 듀얼. 이 repo 라이선스와 충돌하고 향후 공개 시 발목 잡는다. 쓰지 말 것.

DCTDecode 고속 경로를 남길지: 남기지 말 것을 권한다. 렌더링은 페이지당 수십 ms 수준이라 OCR 호출(초 단위)에 묻힌다. 경로를 둘로 유지하는 값이 없다.

부수

  1. render_dpi config 노브 추가 (기본 300). 기존 pdf.ocr.max_pixels(config/lib.rs:443)를 상한으로 존중.
  2. failure_reason 을 CLI 출력에 노출하고, 값에 /Filter 이름을 실어 원인이 바로 보이게.
  3. OCR skip 을 IngestReport 에 집계 (ocr_skipped_pages 등) — stderr 한 줄로 흘리지 말 것.

구현 시 주의 — parser_version cascade 필요

수정만 하면 이미 색인된 PDF 에는 적용되지 않는다. try_skip_unchanged(crates/kebab-app/src/ingest.rs:833)는 (내용 해시 + parser/chunker/embedding 버전) 4개가 모두 일치하면 Unchanged 로 건너뛴다. PDF 파일 자체는 안 바뀌었으니 해시가 같고, parser_version 을 올리지 않으면 재처리가 일어나지 않는다.

PDF parser_version bump 필수 (또는 사용자가 --force-reingest). CLAUDE.md §Versioning cascade 대상이고, 새 source 형식 처리 추가에 해당하므로 minor bump + 도그푸딩 트리거다.

도그푸딩 코퍼스에 필터별 fixture 를 추가할 것: corpus/pdf/ 아래 ccitt / jbig2 / flate / jpx / mixed-logo-and-scan / vector-only.

관련

  • #228 sweep 무표시 — 관측 가능한 신호가 사람이 보는 출력에서 버려지는 같은 패턴
  • 발단: 실사용 스캔 PDF 68페이지 전량 skip (0ms)
## 증상 스캔 PDF 를 ingest 하면 전 페이지가 OCR 없이 건너뛰어지고, 문서가 사실상 빈 내용으로 색인된다. **조용한 내용 손실**이다. ``` 📷 OCR page 61... ⊘ OCR page 61 skipped (no DCTDecode or engine fail, 0ms) 📷 OCR page 62... ⊘ OCR page 62 skipped (no DCTDecode or engine fail, 0ms) ... (68페이지 전부 동일) ``` 전 페이지 `0ms` → OCR 엔진은 호출조차 되지 않았다. 엔진 실패라면 실제 latency 가 찍히고 `failure_reason: Some("ocr_error")` 가 붙는다(`crates/kebab-app/src/pdf_ocr_apply.rs:257`, `:393`). ## 원인 `extract_dctdecode_page_image`(`crates/kebab-parse-pdf/src/page_image.rs:66-76`)는 `/Filter` 가 **정확히 `DCTDecode`(JPEG)** 인 image XObject 만 받는다. 주석에 "v1 scope = DCTDecode passthrough only" 로 명시된 의도적 축소 범위다. 걸러지는 것: | `/Filter` | 용도 | 현재 | |---|---|---| | `CCITTFaxDecode` | 흑백 팩스·스캔 — **스캔 PDF 에서 가장 흔함** | ✗ | | `JBIG2Decode` | 흑백 고압축 스캔 | ✗ | | `FlateDecode` | PNG 계열 무손실 | ✗ | | `JPXDecode` | JPEG2000 | ✗ | | `[FlateDecode, DCTDecode]` 체인 | — | ✗ (배열 길이 1 조건, `:69`) | | `DCTDecode` 인데 JPEG magic 없음 | 손상/변형 | ✗ (`:82`) | 주목할 점: **텍스트 게이트는 정상 작동했다.** `needs_ocr = chars < min_char_count(20) || valid_ratio < 0.5`(`pdf_ocr_apply.rs:143`)를 통과했다는 건 kebab 이 "이 페이지는 스캔본이라 OCR 이 필요하다"고 올바르게 판정했다는 뜻이다. 판정은 맞았고 래스터를 못 꺼냈을 뿐이다. `always_on` 기본값이 `false`(`crates/kebab-config/src/lib.rs:826`)이므로, 전 페이지 시도 = 전 페이지가 텍스트 추출에 실패했다는 확증이다. ## 사용자 수동 정규화로 두면 안 되는 이유 현재 코드 주석은 사용자가 `pdftoppm`/`ghostscript` 로 PDF 를 JPEG 기반으로 재래스터화하는 것을 전제한다("see release notes for normalization guidance", `pdf_ocr_apply.rs:160`). 이건 성립하지 않는다. - 사용자는 자기 PDF 의 `/Filter` 가 뭔지 알 수 없고, 알아야 할 이유도 없다. - 실패가 stderr 한 줄로만 흐르고 `IngestReport` 에 집계되지 않는다. 대량 ingest 에서는 지나간다. - 색인은 "성공"으로 끝난다. 검색이 안 되는 시점에야 알게 되고, 그때 원인이 PDF 인코더라는 걸 역추적할 방법이 없다. - workspace 를 통째로 거는 사용 방식(`kebab ingest`)에서 문서별 전처리는 현실적이지 않다. **ingest 는 넣은 파일을 알아서 처리해야 한다.** 사용자가 PDF 내부 인코딩을 신경 쓰는 순간 도구가 진 것이다. ## 부수 결함 2건 (같은 코드 경로) ### 1. 페이지 내 image XObject 선택이 비결정적 `for (_name, obj) in xobject.iter()`(`page_image.rs:44`)로 순회하며 **처음 만난** DCTDecode 이미지를 반환한다. lopdf 의 dictionary 순회 순서는 보장되지 않으므로: - 로고 JPEG + CCITT 스캔이 같이 있는 페이지 → **로고를 OCR** 하고 본문 스캔은 버린다 - 같은 PDF 를 두 번 돌려도 다른 이미지가 선택될 수 있다 → 재색인 비결정성 ### 2. CLI 가 `failure_reason` 을 버린다 ```rust IngestEvent::PdfOcrFinished { page, ms, chars, ocr_engine, skipped, .. } => { // ← `..` 로 폐기 " ⊘ OCR page {page} skipped (no DCTDecode or engine fail, {ms}ms)" ``` (`crates/kebab-cli/src/progress.rs:357-372`) wire 이벤트는 원인을 구분해 실어 보내는데(`None` = 래스터 부재, `Some("ocr_error")` = 엔진 실패) 사람이 보는 출력에서 둘을 뭉갠다. #228 과 같은 계열 — 신호는 있는데 표시 단계에서 소실. ## 제안 ### 본안: DCTDecode passthrough 를 페이지 렌더링으로 **교체** | 안 | 커버리지 | 비용 | 평가 | |---|---|---|---| | **A. 페이지 렌더링 (pdfium)** | 필터 무관 · 다중 이미지 · 벡터 · 텍스트 혼합 전부 | 네이티브 의존성 1개 | **권장** | | B. 필터별 디코더 추가 | 추가한 필터만 | CCITT/JBIG2 러스트 구현 빈약, 계속 늘어남 | 트레드밀. 다중 이미지·벡터 페이지는 여전히 못 함 | | C. 외부 도구 shell out | 전부 | poppler 설치 필요 | 사용자가 신경 써야 함 = 요구사항 위반 | A 로 가면 `page_image.rs` 의 XObject traversal(~90줄)이 **통째로 삭제**된다. 추가가 아니라 교체이고 순감이다. 부수 결함 1번(비결정적 선택)도 함께 사라진다. 렌더러 선택 주의: - **pdfium** — BSD-3-Clause. `pdfium-render` 크레이트. **이쪽을 쓸 것.** - **mupdf** — AGPL-3.0 / 상용 듀얼. 이 repo 라이선스와 충돌하고 향후 공개 시 발목 잡는다. 쓰지 말 것. DCTDecode 고속 경로를 남길지: **남기지 말 것을 권한다.** 렌더링은 페이지당 수십 ms 수준이라 OCR 호출(초 단위)에 묻힌다. 경로를 둘로 유지하는 값이 없다. ### 부수 1. `render_dpi` config 노브 추가 (기본 300). 기존 `pdf.ocr.max_pixels`(`config/lib.rs:443`)를 상한으로 존중. 2. `failure_reason` 을 CLI 출력에 노출하고, 값에 `/Filter` 이름을 실어 원인이 바로 보이게. 3. OCR skip 을 `IngestReport` 에 집계 (`ocr_skipped_pages` 등) — stderr 한 줄로 흘리지 말 것. ## 구현 시 주의 — parser_version cascade 필요 수정만 하면 **이미 색인된 PDF 에는 적용되지 않는다.** `try_skip_unchanged`(`crates/kebab-app/src/ingest.rs:833`)는 (내용 해시 + parser/chunker/embedding 버전) 4개가 모두 일치하면 `Unchanged` 로 건너뛴다. PDF 파일 자체는 안 바뀌었으니 해시가 같고, parser_version 을 올리지 않으면 재처리가 일어나지 않는다. → **PDF parser_version bump 필수** (또는 사용자가 `--force-reingest`). CLAUDE.md §Versioning cascade 대상이고, 새 source 형식 처리 추가에 해당하므로 **minor bump + 도그푸딩** 트리거다. 도그푸딩 코퍼스에 필터별 fixture 를 추가할 것: `corpus/pdf/` 아래 `ccitt` / `jbig2` / `flate` / `jpx` / `mixed-logo-and-scan` / `vector-only`. ## 관련 - #228 sweep 무표시 — 관측 가능한 신호가 사람이 보는 출력에서 버려지는 같은 패턴 - 발단: 실사용 스캔 PDF 68페이지 전량 skip (0ms)
Author
Owner

PR #238 로 닫는다.

본안 — 페이지 렌더링

이슈가 권한 안 A(pdfium 페이지 렌더링)로 갔다. 필터를 지원할 것도, XObject 중에 고를 것도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다. 부수 결함 1번(image XObject 선택이 비결정적)도 이 경로에서는 성립하지 않는다.

다만 교체가 아니라 '렌더러 우선 + DCTDecode 폴백' 이다. pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없어서, 링크하면 CLAUDE.md 가 규정한 단일 바이너리가 깨진다. 사용자와 상의해 런타임 동적 로드로 정했다 — 있으면 전 인코딩 커버, 없으면 예전 동작 + 왜 건너뛰었는지 명시. [ingest.pdf.ocr] render_library 로 경로를 지정하거나 로더 경로에 두면 되고, kebab doctorpdf_render 가 지금 어느 쪽인지 보고한다. 바이너리는 392.9 → 399.3 MB (+6.4 MB 글루), ldd 에 pdfium 없음.

부수 제안 2·3

failure_reason 이 CLI 에서 .. 로 버려지고 있었다. 이제 no_renderer / render_error / unopenable_pdf / ocr_error 를 구분해 찍는다. IngestReport.ocr_skipped_pages 를 추가하고 사람용 요약에도 ocr-skipped N 으로 낸다.

parser_version cascade

pdf-text-v1pdf-text-v2. 안 올리면 이미 색인된 스캔본은 파일이 안 바뀌었으니 그대로 건너뛴다 — 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게 아니다.

실측

정답 텍스트를 아는 한국어 합성 픽스처(CCITT 3건, 22쪽)를 config 기본 모델 qwen2.5vl:3b 로:

문서 페이지 CER
브렌다(메이즈 러너) 8 15.65%
비행대 스토리 8 12.55%
구 아놀리엔 6 15.08%

22쪽 전부 성공, 건너뜀 0. 이 PR 이전에는 세 문서 모두 본문 0 자였다. 렌더링 자체는 여섯 필터 계열(CCITT/JBIG2/Flate/JPX/혼합/DCT) 전부 확인했다.

구현 중 알게 된 것

이슈가 예상하지 못한 것 셋이 나왔다. pdfium 은 동시 사용이 안전하지 않다 — 테스트를 병렬로 돌리자 double free 로 프로세스가 죽었고, thread_safe 기능만으로는 부족했다. 뮤텍스로 직렬화했다. set_target_width 만 주면 긴 스캔에서 C++ length_error 로 프로세스가 죽는데 exceptions 비활성 빌드라 Err 로 받지도 못한다. set_maximum_* 만으로는 스케일이 안 된다 — 클램프일 뿐이라 초안이 72 DPI 로 렌더하고 있었고 render_dpi 가 아무 일도 안 했다. 리뷰가 잡았다.

그리고 범용 멀티모달 모델은 OCR 엔진이 아니다. 같은 픽스처를 gemma3:4b 로 읽혔더니 래스터는 정상인데 출력이 원문과 무관��� 환각이었고, 해상도가 올라가자 180초 타임아웃까지 났다. config 기본값이 qwen2.5vl:3b 인 이유가 이것이다.

안 한 것

"DCTDecode 고속 경로를 남기지 말 것" 은 따르지 않았다. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 렌더러가 있으면 그 경로는 타지 않는다.

PR #238 로 닫는다. ## 본안 — 페이지 렌더링 이슈가 권한 안 A(pdfium 페이지 렌더링)로 갔다. 필터를 지원할 것도, XObject 중에 고를 것도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다. 부수 결함 1번(image XObject 선택이 비결정적)도 이 경로에서는 성립하지 않는다. 다만 **교체가 아니라 '렌더러 우선 + DCTDecode 폴백'** 이다. pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없어서, 링크하면 CLAUDE.md 가 규정한 단일 바이너리가 깨진다. 사용자와 상의해 런타임 동적 로드로 정했다 — 있으면 전 인코딩 커버, 없으면 예전 동작 + **왜 건너뛰었는지 명시**. `[ingest.pdf.ocr] render_library` 로 경로를 지정하거나 로더 경로에 두면 되고, `kebab doctor` 의 `pdf_render` 가 지금 어느 쪽인지 보고한다. 바이너리는 392.9 → 399.3 MB (+6.4 MB 글루), `ldd` 에 pdfium 없음. ## 부수 제안 2·3 `failure_reason` 이 CLI 에서 `..` 로 버려지고 있었다. 이제 `no_renderer` / `render_error` / `unopenable_pdf` / `ocr_error` 를 구분해 찍는다. `IngestReport.ocr_skipped_pages` 를 추가하고 사람용 요약에도 `ocr-skipped N` 으로 낸다. ## parser_version cascade `pdf-text-v1` → `pdf-text-v2`. 안 올리면 이미 색인된 스캔본은 파일이 안 바뀌었으니 그대로 건너뛴다 — 사용자가 `--force-reingest` 를 떠올려야만 고쳐지는 수정은 고쳐진 게 아니다. ## 실측 정답 텍스트를 아는 한국어 합성 픽스처(CCITT 3건, 22쪽)를 config 기본 모델 `qwen2.5vl:3b` 로: | 문서 | 페이지 | CER | |---|---|---| | 브렌다(메이즈 러너) | 8 | 15.65% | | 비행대 스토리 | 8 | 12.55% | | 구 아놀리엔 | 6 | 15.08% | 22쪽 전부 성공, 건너뜀 0. **이 PR 이전에는 세 문서 모두 본문 0 자**였다. 렌더링 자체는 여섯 필터 계열(CCITT/JBIG2/Flate/JPX/혼합/DCT) 전부 확인했다. ## 구현 중 알게 된 것 이슈가 예상하지 못한 것 셋이 나왔다. **pdfium 은 동시 사용이 안전하지 않다** — 테스트를 병렬로 돌리자 double free 로 프로세스가 죽었고, `thread_safe` 기능만으로는 부족했다. 뮤텍스로 직렬화했다. **`set_target_width` 만 주면** 긴 스캔에서 C++ `length_error` 로 프로세스가 죽는데 exceptions 비활성 빌드라 Err 로 받지도 못한다. **`set_maximum_*` 만으로는 스케일이 안 된다** — 클램프일 뿐이라 초안이 72 DPI 로 렌더하고 있었고 `render_dpi` 가 아무 일도 안 했다. 리뷰가 잡았다. 그리고 **범용 멀티모달 모델은 OCR 엔진이 아니다**. 같은 픽스처를 `gemma3:4b` 로 읽혔더니 래스터는 정상인데 출력이 원문과 무관��� 환각이었고, 해상도가 올라가자 180초 타임아웃까지 났다. config 기본값이 `qwen2.5vl:3b` 인 이유가 이것이다. ## 안 한 것 "DCTDecode 고속 경로를 남기지 말 것" 은 따르지 않았다. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 렌더러가 있으면 그 경로는 타지 않는다.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#232