리뷰가 이 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
242 lines
9.4 KiB
JSON
242 lines
9.4 KiB
JSON
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$id": "https://kb.local/wire/v1/ingest_progress.schema.json",
|
|
"title": "IngestProgressEvent v1",
|
|
"description": "Streaming progress event emitted by `kebab ingest --json`. One event per line (line-delimited JSON). Discriminated by `kind`. The terminal events are `completed` and `aborted` — every ingest run ends with exactly one of them. The final stdout line of a `--json` ingest is still the existing `ingest_report.v1` for backwards compatibility; progress events stream above it. `sweep_*` events (v0.32.1) cover the deleted-file sweep that runs between the scan and the asset loop; before them that phase emitted nothing and a long sweep was indistinguishable from a hang (issue #228).",
|
|
"type": "object",
|
|
"required": [
|
|
"schema_version",
|
|
"kind",
|
|
"ts"
|
|
],
|
|
"properties": {
|
|
"schema_version": {
|
|
"const": "ingest_progress.v1"
|
|
},
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": [
|
|
"scan_started",
|
|
"scan_completed",
|
|
"asset_started",
|
|
"asset_finished",
|
|
"asset_chunked",
|
|
"asset_phase",
|
|
"asset_timings",
|
|
"embed_batch_started",
|
|
"embed_batch_finished",
|
|
"pdf_ocr_started",
|
|
"pdf_ocr_finished",
|
|
"sweep_started",
|
|
"sweep_progress",
|
|
"sweep_completed",
|
|
"completed",
|
|
"aborted"
|
|
]
|
|
},
|
|
"ts": {
|
|
"type": "string",
|
|
"format": "date-time",
|
|
"description": "RFC 3339 timestamp at the moment the event was emitted."
|
|
},
|
|
"root": {
|
|
"type": "string",
|
|
"description": "scan_started: workspace root being walked."
|
|
},
|
|
"total": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "scan_completed / asset_started / asset_finished: total assets discovered. sweep_started / sweep_progress: total stored paths the deleted-file sweep will examine (the store's paths minus the ones this scan covered)."
|
|
},
|
|
"idx": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"description": "asset_started / asset_finished: 1-based index of the current asset within the scan. sweep_progress: 1-based index of the current sweep candidate."
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "asset_started: workspace-relative path of the asset being processed. sweep_progress: workspace path of the candidate examined."
|
|
},
|
|
"media": {
|
|
"type": "string",
|
|
"description": "asset_started: media kind label (e.g. `markdown`, `pdf`, `image`)."
|
|
},
|
|
"result": {
|
|
"type": "string",
|
|
"enum": [
|
|
"new",
|
|
"updated",
|
|
"skipped",
|
|
"error"
|
|
],
|
|
"description": "asset_finished: per-asset outcome (mirrors `ingest_report.v1.items[].kind`)."
|
|
},
|
|
"chunks": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_finished / asset_chunked (v0.24.0): chunk count produced for this asset."
|
|
},
|
|
"phase": {
|
|
"type": "string",
|
|
"enum": [
|
|
"ocr",
|
|
"caption",
|
|
"embed"
|
|
],
|
|
"description": "asset_phase (v0.26.1): the slow internal phase the asset just entered. Short phases (parse/chunk/store) are not emitted."
|
|
},
|
|
"model": {
|
|
"type": [
|
|
"string",
|
|
"null"
|
|
],
|
|
"description": "asset_phase (v0.26.1): model performing the phase — vision LLM id for ocr/caption, embedder model_id for embed. null when the phase runs without a configured model."
|
|
},
|
|
"parse_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Emitted by markdown / image / PDF paths."
|
|
},
|
|
"chunk_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Emitted by markdown / image / PDF paths."
|
|
},
|
|
"expansion_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.24.0, additive): retained for wire compatibility but always 0 — doc-side expansion was removed (HOTFIXES 2026-06-03)."
|
|
},
|
|
"embed_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.24.0, additive): embed + vector phase wall-clock (ms) — embedding, vector upsert, and stale-vector purge."
|
|
},
|
|
"store_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only."
|
|
},
|
|
"ocr_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.26.1, additive, default 0): image/PDF OCR phase wall-clock (ms). 0 on the markdown path (no OCR)."
|
|
},
|
|
"caption_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings (v0.26.1, additive, default 0): image caption phase wall-clock (ms). 0 on markdown / PDF paths."
|
|
},
|
|
"n_chunks": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "embed_batch_started / embed_batch_finished: chunks in this embedding batch."
|
|
},
|
|
"ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "embed_batch_finished / pdf_ocr_finished: wall-clock duration (ms). pdf_ocr_finished skip path 의 의미는 mixed (DCTDecode 부재 시 0, engine 실패 시 latency-before-bail). sweep_completed: wall-clock of the whole sweep phase."
|
|
},
|
|
"chars": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "pdf_ocr_finished: char count of OCR result. Skip 시 0."
|
|
},
|
|
"page": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"description": "pdf_ocr_started / pdf_ocr_finished: 1-based PDF page number under OCR."
|
|
},
|
|
"ocr_engine": {
|
|
"type": "string",
|
|
"description": "pdf_ocr_finished: engine_name (e.g. 'ollama-vision')."
|
|
},
|
|
"skipped": {
|
|
"type": "boolean",
|
|
"description": "pdf_ocr_finished: true 일 시 OCR 미수행 (DCTDecode 부재 또는 engine 실패). chars=0 만으로는 skip 과 0-char result 구분 불가."
|
|
},
|
|
"image_byte_size": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "pdf_ocr_finished (optional, v0.20.x): raster image byte size."
|
|
},
|
|
"image_width": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "pdf_ocr_finished (optional, v0.20.x): raster image width px."
|
|
},
|
|
"image_height": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "pdf_ocr_finished (optional, v0.20.x): raster image height px."
|
|
},
|
|
"failure_reason": {
|
|
"type": "string",
|
|
"description": "pdf_ocr_finished: why the page was skipped. \"no_renderer\" — no page renderer configured and the page is not a single DCTDecode image, so no raster could be produced (issue #232). \"render_error\" — a renderer was configured and rasterizing this page failed. \"unopenable_pdf\" — a renderer was configured but could not open the PDF at all. \"ocr_error\" — a raster was produced but the OCR engine failed on it. Absent when the page succeeded. The first three are the ones counted in ingest_report.ocr_skipped_pages."
|
|
},
|
|
"counts": {
|
|
"type": "object",
|
|
"description": "completed / aborted: aggregate counters at the moment the run ended (mirrors fields on `ingest_report.v1`).",
|
|
"properties": {
|
|
"scanned": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"new": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"updated": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"skipped": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"errors": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"chunks_indexed": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
},
|
|
"embeddings_indexed": {
|
|
"type": "integer",
|
|
"minimum": 0
|
|
}
|
|
}
|
|
},
|
|
"removed": {
|
|
"type": "boolean",
|
|
"description": "sweep_progress: true when the file was truly absent and its document was purged; false when it is still on disk (left untouched) or the purge failed."
|
|
},
|
|
"checked": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "sweep_completed: sweep candidates examined."
|
|
},
|
|
"purged": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "sweep_completed: documents removed because their source file is gone."
|
|
},
|
|
"cache_hit": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings: chunks whose embedding came from the derivation cache. Embeddings only — the OCR and caption derivations share the table but are not counted. Emitted on the markdown / image / PDF paths; the code path does not emit asset_timings at all."
|
|
},
|
|
"cache_miss": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings: chunks whose embedding had to be computed. Same scope as cache_hit."
|
|
},
|
|
"cache_ms": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "asset_timings: what the derivation-cache path cost — cache-key hashing, lookup, payload decode, insert, and the last_used_at touch — excluding the embedder call the misses trigger. Rounded UP to the millisecond, because an asset's cache work is routinely sub-millisecond and truncating would make any sum a systematic undercount. INCLUDED IN embed_ms rather than additional to it: the embed timer spans the whole vector phase, so summing the phase fields double-counts this one."
|
|
}
|
|
}
|
|
}
|