Files
kebab/docs/release-notes/v0.33.1-draft.md
altair823 a80f0ae0e3 chore: bump version 0.33.1 + release notes
#239 수정(PR #240) 을 릴리스로 컷한다. 사용자 표면(서브커맨드·플래그·config
키·wire 스키마)에 변화가 없어 patch 로 판정했다. 규칙 문면상으로는 minor 로
읽히는 지점이 있으므로 근거를 Cargo.toml 주석과 릴리스 노트 앞머리에 남겼다.

릴리스 노트를 두 관점으로 사실검증하면서 **이미 머지된 HOTFIXES 항목의 사실
오류 두 개**가 드러나 함께 고쳤다.

1. "폭이 한 자릿수인 입력은 폭 0 인 특징맵이 된다" — 실제로는 폭 4 이하만
   실패한다. 세 줄 아래 표(5~48 전부 성공)와 스스로 모순이었다. 이슈 본문의
   표현을 그대로 물려받은 것이다.

2. "이미 인식된 87 개 영역을 통째로 잃고 있었다" — 87 은 수정 **후** 나오는
   영역 수다. 계측으로 확인: Clickpath_Analysis.png 은 박스를 119 개 검출하고,
   수정 전에는 그중 3 개를 인식한 시점에 중단됐다
   (`ABORT regions_already_recognized=3 of detected_boxes=119`). 약 29 배
   과장이었고, 하필 "이미 인식한 것까지 버려진다" 는 기제를 설명하는 자리라
   숫자가 논지를 떠받치고 있었다.

릴리스 노트에서 추가로 바로잡은 것:

- 컴파일 가드는 "올리면 실패" 가 아니라 "16 을 넘기면 실패" 다. 6~16 은 그대로
  빌드되고 테스트도 통과한다.
- "16 으로 하면 폭 5~15 를 새로 버린다" 는 근거가 정반대였다. 말뭉치 240 개
  파일을 계측해 직접 확인: 세션에 투입된 박스 10,991 개 중 폭 5~16 은 403 개,
  그중 글자를 뱉은 것은 0 개이고 글자가 처음 나오는 폭은 17 이다. 5 를 고른
  이유는 손실이 아니라 상수 이름과 주석이 거짓이 되지 않기 때문이다.
- "실패했던 문서만 다시 OCR 된다" 는 무조건문이 아니다. OCR 파생 캐시는
  v0.31.0 에 들어왔는데 이미지 parser_version 은 v0.28.0 이후 안 바뀌었으므로,
  v0.31.0 이전에 색인하고 그 뒤 재처리된 적 없는 KB 는 캐시가 비어 성공했던
  이미지까지 전부 다시 돈다.
- 피해 집계를 절차 맨 뒤(5번)에 둬서, 순서대로 따르면 세기 전에 색인해 버리고
  창이 영구히 닫혔다. 0 번으로 올리고 "반드시" 로 바꿨다.
- `_external/` 로 넣은 문서는 `.kebabignore` 때문에 workspace 색인이 방문하지
  않으므로 자동 재색인 대상이 아니다.
- `pdf_ocr_events` 대안 쿼리는 30 일 보관 정리를 받고 `'ocr_error'` 가 모든 OCR
  실패를 받는 통칭이라 상한이다. 0 이 나와도 안 당한 게 아니다.
- doc_id 가 전부 바뀌면 저장해 둔 인용과 integrations/claude-code 스킬처럼
  doc_id 를 들고 있는 소비자가 헛번호가 된다.
- patch 판정 문단이 규칙의 patch 조항을 인용하지 않아 논거가 유리해 보였다.
  "문면상으로는 minor 이고 그럼에도 X 를 이유로 patch" 로 고쳤다.
- 머리글 수치(11,867 / 65,764 / 11.8%)가 이 저장소에서 재현 불가라 출처를
  이슈 #239 로 밝히고, 본문 실측(15.0%)과 기준이 다름을 명시했다.
- SQL 을 어디에 대고 돌리는지 실행 줄을 붙였다.

검증: 워크스페이스 1301 passed / 0 failed / 64 ignored,
clippy --workspace --all-targets -- -D warnings 무경고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:11:40 +09:00

11 KiB

v0.33.1 — 얇은 조각 하나가 이미지 OCR 을 통째로 날리던 문제 (#239)

이미지가 본문에 파일명만 남은 채 색인되던 결함을 고친 릴리스. 이슈 #239 를 등록할 때 실사용 지식 베이스(문서 11,867 / 청크 65,764)에서 잰 값으로는 이미지 문서의 11.8%(4,699 장 중 555 장)가 이 상태였다. 그 지식 베이스는 이 저장소에서 재현할 수 없으므로, 아래 실측은 전부 도그푸딩 말뭉치 240 개 파일 기준(15.0%)이다. 사용자 표면(명령·플래그·config 키·wire 스키마)에는 변화가 없다.

patch 로 판정한 근거

솔직히 적는다. CLAUDE.md §Release 의 문면대로는 minor 다. minor 조건에 "사용자가 받는 결과·동작의 변화" 가 있고 이 수정은 실패하던 문서의 OCR 결과를 0 자에서 실제 본문으로 바꾼다. patch 조건은 "기능·인터페이스 변경이 없을 때 … 즉 결과가 같고 새 명령/플래그/config 도 없으면 patch" 라는 두 조건의 AND 인데, 이 릴리스는 뒤 조건만 만족한다.

그럼에도 patch 로 컷했다. 이유는 사용자가 무엇을 다시 배워야 하느냐다 — 새로 익힐 서브커맨드도, 플래그도, config 키도, 깨지는 wire 소비자도 없고 "원래 되어야 했는데 안 되던 것이 된다" 뿐이다. 직전 v0.33.0 이 minor 였던 것과도 구분된다. 그 릴리스의 트리거는 parser_version cascade 단독이 아니라 신규 config 키(render_library / render_dpi)와 짝이었다.

다만 재색인 비용은 minor 급으로 든다. 버전 숫자만 보고 가볍게 넘기지 말고 아래 upgrade 절차를 읽을 것.

실측 evidence 는 tasks/HOTFIXES.md 의 2026-08-28 dated entry 에 있다.


얇은 검출 박스 하나가 그 이미지의 인식 결과를 전부 버렸다 (#239)

변경 사실. paddle-onnx OCR 은 검출한 글자 영역을 하나씩 인식 네트워크에 넣는다. 그런데 아주 얇은 영역 — 표 선 조각, 글자 사이 틈, 사진의 가는 무늬 — 이 하나라도 섞이면 ONNX 런타임이 세션 실행 전체를 실패시켰고, 그 오류가 위로 전파되면서 같은 이미지에서 그때까지 인식해 둔 결과까지 전부 버려졌다.

원인은 폭 하한이 없다는 것이었다. 인식 네트워크는 입력 폭을 반복해서 줄이므로 폭 4 이하 입력은 도중에 폭 0 인 텐서가 되고, 거기서 Conv 가 터진다. 번들된 모델을 폭 1 부터 48 까지 직접 훑어 보면 경계가 딱 떨어진다.

인식 입력 폭 (높이 48 기준) 결과
1 ~ 4 전부 실패 — Invalid input shape: {1,0}
5 ~ 48 전부 성공

이유도 유도된다. 인식 출력의 타임스텝 수가 ceil((폭 - 4) / 8) 이라 폭 4 이하에서 0 이 되고, 오류 메시지의 {1,0} 이 바로 그 0 이다. 그래서 폭 5 미만은 네트워크에 넣지 않고 그 박스만 버린다. 바로 옆 줄에 이미 "박스 하나가 비면 나머지는 살린다" 는 처리가 있었는데 오류 경로에만 그 방어가 없었다.

가장 나쁜 점은 조용했다는 것이다. 색인은 "성공" 으로 끝난다. 이미지 문서는 본문에 파일명만 남고 스캔 PDF 페이지는 청크가 0 이 되는데, 그 문서를 검색해서 0 건이 나올 때까지 아무 신호가 없다.

trade-off. 사실상 없다. 높이 48 기준 폭 4 이하는 1:12 보다 납작한 조각이라 글자가 들어갈 수 없고, 원래 잘 읽히던 이미지의 결과는 한 글자도 바뀌지 않는다(아래 실측 참조).

하한 값은 이슈가 제안한 16 대신 5 를 썼다. 여기서 오해하기 쉬운데, 16 을 써도 실제로 잃는 텍스트는 없다 — 말뭉치 전체에서 세션에 들어간 박스 10,991 개 중 폭 5~16 짜리는 403 개였고 그중 글자를 뱉은 것은 0 개다(빈 문자열을 내는 첫 구간이고, 실제로 글자가 나오기 시작하는 건 폭 17 부터다). 5 를 고른 이유는 손실이 아니라 정직함이다. 5 가 그래프의 실제 하한이므로 상수 이름(REC_MIN_WIDTH)과 그 주석이 거짓이 되지 않고, 지금 동작하는 범위를 임의로 좁히지도 않는다.

mitigation. 같은 손실이 다시 들어오지 못하도록 양쪽을 막았다. 하한 아래 폭이 네트워크에 닿으면 테스트가 실패하고, 하한을 16 보다 크게 올리면 컴파일이 안 된다(const _: () = assert!(REC_MIN_WIDTH <= 16, …)). 16 은 위에서 잰 "여기까지는 어차피 아무것도 안 나온다" 의 상한이므로, 그 안에서 값을 조정하는 것은 자유이고 그 위로 넘기려면 새로 측정해야 한다는 뜻이다.

PDF 쪽 OCR 실패 기록이 원인을 잘라먹던 것도 함께 고쳤다. 예전에는 err=rec session run 에서 끝나 실제 원인이 사라졌고, 그 때문에 "어느 문서가 당했나" 를 찾는 질의가 스캔 PDF 를 한 건도 못 찾았다.

실측. 도그푸딩 말뭉치의 이미지 240 개 파일을 같은 모델·같은 설정으로 수정 전후 비교했다.

수정 전 수정 후
OCR 성공 204 / 240 240 / 240
OCR 실패 36 (15.0%) 0

실패는 종류를 가리지 않았다 — 도표 8 · 영문 10 · 한국어 9 · 사진 9. 글자가 없는 사진도 얇은 박스는 검출되므로 똑같이 당했다. 되살아난 본문은 합계 11,747 자(중앙값 37 자, 최대 3,950 자)다.

한 장이 얼마나 손해 보고 있었는지는 charts/Clickpath_Analysis.png 이 잘 보여 준다. 이 이미지는 박스를 119 개 검출하는데, 수정 전에는 그중 3 개를 인식한 시점에 얇은 조각을 만나 세션이 죽고 그 3 개까지 함께 버려져 본문이 0 자였다. 수정 후에는 87 개 영역 1,116 자가 나온다.

부작용이 없다는 것도 확인했다. 원래 성공하던 204 장의 인식 글자 수가 한 장도 변하지 않았다.


upgrade 절차

0. 색인하기 전에, 당한 문서를 먼저 세라. 이 릴리스의 버전 bump 가 문서 행을 다시 쓰므로 한 번 색인하고 나면 아래 질의가 영구히 0 을 돌려준다. 순서를 지키지 않으면 "내 지식 베이스는 안 당했다" 는 정반대 결론에 닿는다.

sqlite3 ~/.local/share/kebab/kebab.sqlite   # --config 를 쓴다면 그 config 의 storage.data_dir
SELECT count(*) FROM documents
WHERE provenance_json LIKE '%Invalid input shape%'
   OR provenance_json LIKE '%err=rec session run%';

두 번째 조건이 필요한 이유는, 이번 수정 이전 릴리스에서 PDF 경로가 오류 원인을 잘라 기록했기 때문이다.

이미 색인해 버렸다면 PDF 쪽은 아래로 일부 셀 수 있다. 다만 두 가지 한계가 있다 — pdf_ocr_events 는 매 ingest 시작 시 logging.retention_days(기본 30 일)로 정리되므로 오래된 기록은 이미 없고, reason = 'ocr_error' 는 모든 OCR 실패를 받는 통칭이라 이 수치는 #239 피해의 상한이다. 0 이 나와도 "안 당했다" 는 뜻이 아니다.

SELECT count(DISTINCT doc_id) FROM pdf_ocr_events
WHERE success = 0 AND reason = 'ocr_error' AND ocr_engine = 'paddle-onnx';

1. 재색인은 자동이다. parser_version 이 image-meta-v1 → image-meta-v2, pdf-text-v2 → pdf-text-v3 로 올라가서 다음 kebab ingest 가 기존 이미지·PDF 를 다시 처리한다. --force-reingest 를 떠올릴 필요가 없다.

단, workspace 워크가 닿는 문서만 해당한다. kebab ingest <파일> 이나 stdin 으로 넣은 문서는 <workspace.root>/_external/ 로 복사되고 그때 _external/ 이 .kebabignore 에 자동 추가되므로, 이후 workspace 색인에서 방문되지 않는다. 그 경로로 넣은 이미지·PDF 는 원본을 다시 kebab ingest <파일> 해야 고쳐진다.

2. 재-OCR 범위는 지식 베이스마다 다르다. OCR 산출물은 소스 바이트를 키로 캐싱되고 실패한 OCR 은 캐시에 저장되지 않았으므로, 캐시 항목이 있는 문서는 다시 OCR 하지 않고 넘어간다. 그런데 OCR 파생 캐시는 v0.31.0 에서 들어왔고 이미지 parser_version 은 v0.28.0 이후 줄곧 image-meta-v1 이었다. 그래서 v0.31.0 이전에 이미지를 색인하고 그 뒤로 OCR 설정·모델을 바꾼 적이 없는 지식 베이스는 이미지 쪽 캐시가 비어 있고, 이번 bump 로 성공했던 이미지까지 전부 다시 OCR 된다. 이미지가 많다면 소요 시간을 넉넉히 잡을 것. PDF 는 v0.33.0 의 pdf-text-v2 bump 때 캐시가 채워졌으므로 사정이 낫다.

3. 나머지 비용은 무조건 든다. 문서 식별자(doc_id)가 파서 버전에서 파생되므로 이 bump 는 모든 이미지·PDF 문서의 doc_id 를 바꾼다. 기존 행이 지워지고 벡터도 지워진 뒤 재파싱·재청킹·재임베딩·재삽입이 이어진다. 임베딩은 캐시에 히트하지만 행은 다시 쓴다.

doc_id 는 --json 출력의 필수 필드이자 kebab inspect doc <id> 의 핸들이다. 이전 출력에서 갈무리해 둔 doc_id, 저장한 인용, integrations/claude-code 스킬처럼 doc_id 를 들고 있는 소비자는 이번 색인 이후 전부 헛번호가 된다. 파일은 하나도 안 고쳤는데 식별자가 한꺼번에 바뀐다.

4. 이미지 OCR 을 안 쓰는 경우에도 재색인은 일어난다. 기본 OCR 엔진은 ollama-vision 이고 이미지 OCR 은 기본 off 라, paddle-onnx 를 쓰지 않는 지식 베이스는 얻는 것 없이 이미지·PDF 를 다시 색인하게 된다. 엔진 범위로 무효화를 좁히는 대안도 있었으나, 기존 캐스케이드 규칙 옆에 두 번째 무효화 경로를 만드는 값이 더 크다고 보고 택하지 않았다.


어디까지 영향이 있었나

paddle-onnx 를 이미지 OCR 엔진으로 고를 수 있게 된 v0.28.0 이후 모든 릴리스가 이 결함을 갖고 있었다. 스캔 PDF 도 같은 인식 경로를 타므로 마찬가지인데, v0.32.0 까지는 단일 DCTDecode 이미지 페이지만 OCR 대상이었고 v0.33.0 의 페이지 렌더링(#232)이 대상을 임의 인코딩까지 넓혔으므로 스캔본 피해의 대부분은 v0.33.0 으로 색인한 기록일 가능성이 높다.

환경에 따라 오류 메시지가 다르게 보인다. 이슈에는 실패 노드가 Conv.33 으로 적혀 있는데 다른 컴퓨터에서는 p2o.pd_op.batch_norm_.1.0_nchwc 로 나온다. ONNX 런타임이 CPU 명령어 집합에 맞춰 그래프를 최적화하면서 노드 이름을 다시 붙이기 때문이고, 상태 메시지와 {1,0} 은 같다. 다른 문제가 아니다.


검증

  • 워크스페이스 전체 테스트 1301 passed / 0 failed / 64 ignored
  • cargo clippy --workspace --all-targets -- -D warnings 경고 없음
  • 도그푸딩 말뭉치 이미지 240 개 파일 수정 전후 비교 (위 표)
  • PR #240 은 5 회차 리뷰를 거쳤고 회차별 검증 통과 지적이 19 → 16 → 3 → 1 → 0 으로 수렴했다