Files
kebab/docs/components/parse
altair823 e76e909f56 chore: PR #238 회차 1 리뷰 반영 — render_dpi 가 동작하지 않았다
리뷰가 이 PR 의 핵심을 무너뜨리는 결함을 잡았다.

1) render_dpi 가 아무 일도 안 하고 있었다 (HIGH)

   `set_maximum_*` 만 걸었는데, pdfium-render 에서 maximum 은 초과할 때만
   줄이는 클램프이고 스케일이 아니다. 타깃도 배율도 없으면 스케일 1.0 —
   1 pt → 1 px, 즉 **72 DPI** 로 렌더된다. 300 을 주든 1200 을 주든 산출물이
   같았다. 렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다.

   실측 (govdocs1-000157-ccitt.pdf 5쪽):
     maximum_* 만 (초안)        621×801 px    72 DPI
     target + maximum_* (수정)  1588×2048 px  184 DPI

   같은 뿌리로 종횡비도 깨져 있었다. 클램프만 걸리는 경로는
   `do_maintain_aspect_ratio = false` 라 가로·세로가 독립적으로 잘린다.
   600×800pt 페이지를 600px 예산으로 렌더하면 600×600 으로 세로가 25%
   눌린 채 나왔고, 긴 변만 보던 테스트는 초록불이었다.

   `render_dpi_changes_the_rendered_size` 와
   `the_pixel_budget_is_respected_without_distorting_the_page` 로 고정했다.
   `set_target_width` 한 줄을 되돌리면 둘 다 실패하는 것을 확인했다.

   덧붙여 render_dpi 는 **요청**이고 max_pixels 가 이긴다. PDF 기본값
   2048 이면 A4 는 175 DPI 언저리에서 잘린다. 기본값 300 이 그대로 나오지
   않는다는 뜻이라 config·README·SMOKE 문구를 실제와 맞췄다.

2) /MediaBox 를 직접 파싱하고 있었다 (MEDIUM)

   `/MediaBox` 는 상속 속성이고 대부분의 생산자가 `/Pages` 노드에 한 번만
   쓴다. lopdf 0.32 에는 상속 해석 헬퍼가 없어서 그런 PDF 는 전부 조용히
   A4 폴백을 탔다. `/UserUnit` 도 미반영이었다.

   pdfium 이 이미 페이지 크기를 안다. 거기서 받으니 40여 줄이 사라지고
   상속·UserUnit 문제가 함께 없어졌으며, kebab-app 이 lopdf 딕셔너리를
   뒤지던 레이어링도 정리됐다.

3) 렌더러가 있으면 오히려 손해 보는 경우가 있었다 (MEDIUM)

   페이지 하나만 렌더에 실패하면 곧장 skip 이었고 DCTDecode 경로를 시도하지
   않았다. "렌더러 우선 + 폴백" 이 렌더러 유무 수준에서만 성립했던 것이다.
   페이지 단위 폴백을 넣었다.

4) 렌더러를 설정한 사용자에게 틀린 지시가 나갔다 (MEDIUM)

   pdfium 이 PDF 자체를 못 열면 모든 페이지가 no_renderer 로 보고되면서
   "render_library 를 지정하라" 고 안내했다. `unopenable_pdf` 로 갈랐다.

5) ⊘ 줄 수와 ocr-skipped 카운트가 안 맞았다 (MEDIUM)

   카운트는 래스터 실패만 세는데 OCR 엔진 실패도 화면에는 똑같이 ⊘ 로
   찍혔다. 사유를 라벨에 적어 둘을 구분한다 — 이 구분이 바로 아래 도그푸딩
   에서 실제로 값을 했다.

6) 잔가지 (LOW)

   docs 의 pdf-text-v1 잔재 3곳, doctor hint 의 줄 이음이 무너져 생긴 여백.

정답 있는 한국어 스캔으로 인식률을 쟀다 (CCITT 3건, qwen2.5vl:3b):

  namu-beulenda…   8쪽  CER 15.65%
  namu-bihaengdae  8쪽  CER 12.55%
  namu-gu-anoli    6쪽  CER 15.08%

전 페이지 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였다.

엔진 선택이 결과를 가른다는 것도 알게 됐다. 처음에는 이 머신에 있던
gemma3:4b 로 쟀는데 래스터는 정상인데 출력이 원문과 무관한 환각이었고,
해상도가 올라가자 밀집 한국어 페이지에서 180초 타임아웃이 났다. 범용
멀티모달 모델은 OCR 엔진이 아니다 — 이때 5번의 새 라벨이 "래스터 없음"이
아니라 "OCR 엔진 실패"로 찍어 줘서 원인이 바로 갈렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 02:45:49 +09:00
..

Parse

미디어 타입별 추출기 — markdown / PDF / image 의 raw bytes 를 다음 단계로 흘려보낼 형태로 변환한다. 세 crate 가 같은 도메인 (Extractor 또는 ParsedBlock 출력) 에 속하지만 출력 단계가 일관되지 않다는 점이 핵심.

구성 crate

Crate 역할 출력 형태
kebab-parse-md Markdown frontmatter + body parsing (P1-2/3) Vec<ParsedBlock> + Metadata (pure 함수)
kebab-parse-pdf text-based PDF per-page 추출 (P7-1) CanonicalDocument 직접 (Extractor impl)
kebab-parse-image 이미지 메타 + EXIF + 차원 + OCR + caption (P6-1/2/3) CanonicalDocument 직접 (Extractor impl)

구조

classDiagram
    class Extractor {
        <<trait kebab-core>>
        supports(MediaType) bool
        parser_version() ParserVersion
        extract(ctx, bytes) CanonicalDocument
    }
    class MdParser {
        <<pure functions>>
        parse_frontmatter(bytes) (Metadata, Span, Warnings)
        parse_blocks(body) (Vec~ParsedBlock~, Warnings)
    }
    class PdfTextExtractor {
        PARSER_VERSION = "pdf-text-v2"
        new() Self
    }
    class ImageExtractor {
        PARSER_VERSION = "image-meta-v1"
        MAX_DECODE_DIM = 16384
        new() Self
    }
    class OcrEngine {
        <<trait kebab-parse-image>>
        engine_id() str
        run(image_bytes, langs) OcrText
    }
    class OllamaVisionOcr {
        endpoint, model, max_pixels
    }
    class CaptionFns {
        caption_image(lm, prep, opts) ModelCaption
        apply_caption(block, lm, opts)
    }
    Extractor <|.. PdfTextExtractor
    Extractor <|.. ImageExtractor
    OcrEngine <|.. OllamaVisionOcr
    ImageExtractor ..> OcrEngine : applied via apply_ocr
    ImageExtractor ..> CaptionFns : applied via apply_caption

Data flow

세 parser 의 출력 stage 가 다른 점이 가장 중요. Markdown 만 ParsedBlock IR 을 거쳐 kebab-normalize 가 lift; PDF / Image 는 추출기 안에서 CanonicalDocument 까지 한 번에.

flowchart LR
    Bytes["raw bytes<br/>(RawAsset)"]
    subgraph MD ["Markdown"]
        MdFM["parse_frontmatter<br/>(YAML/TOML)"]
        MdBlocks["parse_blocks<br/>(pulldown-cmark + line span)"]
        Pblock["Vec~ParsedBlock~<br/>+ Metadata"]
    end
    subgraph PDF ["PDF"]
        PdfLoad["lopdf::Document::load_mem<br/>encrypted/corrupt 거부"]
        PdfPages["per-page extract_text<br/>SourceSpan::Page"]
        PdfDoc["CanonicalDocument<br/>(page = paragraph block)"]
    end
    subgraph IMG ["Image"]
        ImgDims["dims::probe<br/>(format + WxH, ≤ 16384)"]
        ImgExif["exif_extract<br/>(whitelist)"]
        ImgBlock["ImageRefBlock<br/>(ocr=None, caption=None)"]
        ImgOcr["OcrEngine.run<br/>(p6-2)"]
        ImgCap["caption_image<br/>(p6-3, LanguageModel)"]
        ImgDoc["CanonicalDocument<br/>(single block)"]
    end
    Bytes --> MdFM --> Pblock
    Bytes --> MdBlocks --> Pblock
    Pblock --> Normalize["kebab-normalize<br/>(다음 그룹)"]
    Bytes --> PdfLoad --> PdfPages --> PdfDoc
    Bytes --> ImgDims --> ImgBlock
    Bytes --> ImgExif --> ImgBlock
    ImgBlock --> ImgOcr -.optional.-> ImgDoc
    ImgBlock --> ImgCap -.optional.-> ImgDoc
    ImgBlock --> ImgDoc
    PdfDoc --> Chunk["kebab-chunk<br/>(다음 그룹)"]
    ImgDoc --> Chunk
    Normalize --> Chunk

주요 type / trait / 함수

Markdown (kebab-parse-md):

  • parse_frontmatter(bytes) -> (Metadata, Option<FrontmatterSpan>, Vec<Warning>) — YAML/TOML 둘 다 인식. 파싱 실패 → WarningKind::MalformedFrontmatter.
  • parse_blocks(body) -> (Vec<ParsedBlock>, Vec<Warning>)pulldown-cmark 위에서 heading path 추적 + 1-indexed SourceSpan::Line.
  • BodyHints { title, lang } — frontmatter 누락 시 caller 가 fallback 제공 (p9-fb-07 title fallback chain 의 entry).

PDF (kebab-parse-pdf):

  • PdfTextExtractorExtractor 구현체. lopdf::Document::load_mem 로 한 번 파싱, encrypted 면 즉시 bail.
  • PARSER_VERSION = "pdf-text-v2" — version cascade entry (issue #232 에서 v1 → v2, 페이지 렌더링 도입으로 기존 색인 스캔본 재처리 유발). (HOTFIXES P7-2 의 chunker_version pdf-page-v1 와 별개.)
  • 빈 페이지 / extract 실패 → Block::Paragraph 빈 inlines + ProvenanceKind::Warning("scanned candidate"). OCR fallback 미구현.

Image (kebab-parse-image):

  • ImageExtractorExtractor 구현체. MAX_DECODE_DIM = 16384 초과 거부 (decode bomb 방어).
  • OcrEngine (trait) — engine_id() / run(...) -> OcrText. OcrText.engine 필드로 trust level 분기.
  • OllamaVisionOcr { endpoint, model, max_pixels } — v1 유일 구현. apply_ocr(block, engine, langs)ImageRefBlock.ocr 슬롯 채움.
  • caption_image(lm: &dyn LanguageModel, prep, opts) -> Result<ModelCaption>LanguageModel.generate_stream 의 vision 입력 (GenerateRequest.images) 사용. apply_caption 이 block 에 in-place 주입.

외부 의존

  • crate dep:
    • 모든 parser → kebab-core (Extractor trait, Block, Metadata, id_for_*).
    • kebab-parse-mdpulldown-cmark, serde_yaml_ng. (ParsedBlock/ParsedPayload/Warning 등 옛 kebab-parse-types 는 v0.19.0 에 kebab-parse-md::types 모듈로 흡수.)
    • kebab-parse-pdflopdf.
    • kebab-parse-imageimage (decode), kamadak-exif (EXIF), kebab-core::LanguageModel (caption).
  • 외부 서비스:
    • PDF: 없음 (in-process).
    • Image OCR / caption: Ollama HTTP (default gemma4:e4b).

핵심 결정

  • Markdown 만 ParsedBlock IR 사용. : §3.7b 가 "parser intermediate" 추상을 markdown 의 frontmatter / heading path 추적용으로 도입. PDF / image 는 source 자체가 단순 (PDF=페이지 평면, image=단일 블록) 이라 IR 거치지 않고 CanonicalDocument 바로 만드는 게 자연스러움. 결과: ingest pipeline 의 분기가 비대칭 — kebab-app 의 라우팅이 두 path 를 같이 처리 (HOTFIXES P7-3 가 둘의 storage 처리 통일 작업).

  • PDF encrypted → hard fail (auto-decrypt 안 함). : 자동 decryption 은 사용자의 키/뷰어 환경 가정. kebab-parse-pdf 는 "사용자가 외부에서 qpdf --decrypt 후 ingest" 명시. encrypted PDF 가 silently 빈 doc 으로 들어가는 게 더 위험.

  • PDF 빈 페이지 = Block::Paragraph 빈 inlines + Warning provenance. : scanned PDF 식별. 빈 문자열로 chunk 만드는 비용 무시 가능 + OCR fallback (P+) 가 같은 doc 위에 in-place 추가 가능. 페이지 ordinal 보존.

  • Image OCR 기본 = Ollama vision LM (Tesseract 거부). : spec literal 의 Tesseract 가 시스템 dep (libtesseract + 언어 모델 다운로드) 를 요구해서 single-binary 약속을 깸. Ollama 가 이미 LLM 으로 깔려 있으면 추가 install 0. OcrEngine trait 으로 Tesseract / Apple Vision adapter 가 future swap 가능. (HOTFIXES P6-2 의 결정.)

  • Caption 기본 OFF (image.caption.enabled = false). : caption 은 model-generated → low trust. 매 이미지마다 모델 호출 비용 (= ingest 시간) 도 큼. opt-in. ModelCaption.model_version + caption.prompt_template_version 필드가 wire payload 로 흘러서 eval 단계에서 prompt 변화 감지 가능.

  • GenerateRequest.images: Vec<String> 필드 신설. : 기존 LanguageModel trait 가 text-only. P6-3 caption 이 vision 입력 필요해서 images (base64) 필드 추가. 기존 caller 모두 images: Vec::new() 로 마이그레이션 + #[serde(default)] 로 snapshot 호환. (HOTFIXES P6-3 의 결정.)

  • Image decode size 캡 (MAX_DECODE_DIM = 16384). : decode bomb (e.g. 100k×100k PNG) 가 메모리 즉시 OOM. 16384 = 16k px, 사진/문서 스캔 정상 케이스 충분. 초과 시 dims::DimOutcome::Failed + warning provenance.

관련 spec / HOTFIXES

  • frozen 설계 §3.4 (Block enum), §3.7a (OcrText / ModelCaption), §3.7b (ParsedBlock IR), §9 (parser_version cascade), §9.1 (image policy), §9.2 (PDF text extraction): docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
  • task specs: 삭제됨(2026-06-27 doc-reorg) — 설계는 frozen 계약, 동작은 tasks/HOTFIXES.md, 상세 git history.
  • HOTFIXES (P6-2 OCR 기본, P6-3 caption + GenerateRequest.images, P7-2 chunk_id 충돌, P7-3 storage UNIQUE bug, p9-fb-07 title fallback): tasks/HOTFIXES.md