`extract_dctdecode_page_image` 는 페이지의 image XObject 중 `/Filter` 가
정확히 DCTDecode 인 것 하나만 받는다. 실제 스캔본에서 흔한 CCITTFaxDecode·
JBIG2Decode·FlateDecode·JPXDecode, `[FlateDecode, DCTDecode]` 같은 체인,
Internet Archive 계열의 "배경 + /ImageMask" 분리 구조가 전부 걸러진다.
텍스트 게이트는 정상 동작했다. `needs_ocr` 판정을 통과했다는 건 "이 페이지는
스캔본이라 OCR 이 필요하다" 고 올바르게 본 것이다. 판정은 맞았고 래스터를 못
꺼냈을 뿐인데, 결과가 조용한 내용 손실이었다 — 색인은 성공으로 끝나고,
검색이 안 되는 시점에야 알게 되며, 그때 원인이 PDF 인코더라는 걸 역추적할
방법이 없다.
페이지를 렌더링한다 (`page_render::PageRenderer`, pdfium). 지원할 필터도,
고를 XObject 도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다.
이슈가 지적한 "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 에서 `..` 로 버려지고 있었다. wire 이벤트는 원인을
구분해 싣는데 사람이 보는 출력이 "no DCTDecode or engine fail" 로 뭉갰다.
이제 no_renderer / render_error / ocr_error 를 구분해 찍는다.
`IngestReport.ocr_skipped_pages` 를 추가하고(additive) 사람용 요약에도
`ocr-skipped N` 으로 낸다 — stderr 한 줄로 흘리면 대량 ingest 에서 지나간다.
parser_version cascade: pdf-text-v1 → pdf-text-v2. 안 올리면 이미 색인된
스캔본에 적용되지 않는다 (파일이 안 바뀌었으니 해시가 같고 Unchanged 로
건너뛴다). 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게
아니다. 스냅샷 둘이 따라 움직였고 바뀐 것이 파생 식별자뿐임을 확인했다 —
본문 텍스트·inlines·source_span·metadata 는 동일.
구현 중 발견: pdfium 은 동시 사용이 안전하지 않다. 테스트를 병렬로 돌리자
`double free or corruption` 으로 프로세스가 죽었고, `thread_safe` 기능만으로는
부족했다. ingest 는 PDF 를 하나씩 처리하니 오늘은 문제가 없지만 `Arc` 는
공유해도 된다고 광고하는 타입이라, `PageRenderer` 안에 뮤텍스를 두고
`RenderedPdf` 가 문서 수명 동안 잡게 했다 (필드 선언 순서가 load-bearing —
doc 이 guard 보다 먼저 드롭돼야 한다). 지금 비용 0, 병렬화되는 날 메모리
손상 대신 대기가 된다. `set_target_width` 만 주면 긴 스캔에서 pdfium 이 C++
length_error 로 프로세스를 죽여서(exceptions 비활성 빌드라 Err 로 못 받는다)
양변을 set_maximum_* 으로 묶었다. 바인딩도 run 당 1회여야 한다.
실측 (govdocs1-000157-ccitt.pdf, 22쪽 중 1쪽이 CCITT 스캔, gemma3:4b):
렌더러 없음 렌더러 있음
OCR ⊘ 건너뜀 — 인코딩을 읽을 수 없다 ✓ 101 chars, 6489ms
chunk 35 36
글자 수 35,994 36,095
요약 ocr-skipped 1 (없음)
렌더링 자체는 여섯 필터 계열 전부 확인 — CCITT / JBIG2 / Flate / JPX /
혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 페이지당 40~145 ms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
242 lines
9.2 KiB
JSON
242 lines
9.2 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 failed. \"ocr_error\" — the OCR engine itself failed. Absent when the page succeeded."
|
|
},
|
|
"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."
|
|
}
|
|
}
|
|
}
|