24화 2026-06-21

1.2B 파서를 형사기록에 들이밀다 — 점수와 실사용의 거리

지난 화 끝에 적었듯, 구독자 한 분이 한국딥러닝의 KDL-Frontier-Parser-nano를 알려주셨다. 12억(1.2B) 매개변수의 초경량 문서 파싱 모델인데, 글로벌 문서 파싱 벤치마크 ParseBench에서 좋은 점수를 받았고 외부 서버 없이 로컬로 쓸 수 있다고 했다. 이 글이 줄곧 지켜온 완전 로컬 처리와 결이 맞아, 같은 형사기록을 이 모델로 다시 돌려보기로 했다. 23화에서 qwen3-vl이 흐트러뜨린 표 형식이나 못 읽은 손글씨가, 표에 특화됐다는 이 파서로는 나아지는지가 궁금했다.

미리 말하면, 답은 갈렸다. 줄글은 빠르고 정확했다. 표는 내용은 맞았지만 형식이 오히려 더 무너졌고, 그 이유가 흥미로웠다. 손글씨는 23화보다 더 위험하게 틀렸다. 그리고 이 과정에서 오픈웨이트 모델을 실제로 쓸 때 마주치는 한 가지 간극을 보게 됐다. 순서대로 적는다.


Ollama로는 받을 수 없는 모델

먼저 모델을 받으려다 벽을 만났다. 지금까지 OCR이든 채팅이든 모델은 전부 Ollama로 받았다. ollama pull 한 줄이면 끝이었다. 그런데 이 모델은 그 길이 막혀 있었다.

이유는 파일 형식이다. Ollama가 받는 모델은 대개 GGUF라는 양자화 파일로 포장돼 있다. 그런데 KDL이 공개한 것은 Safetensors라는 다른 형식의 원본 가중치다. 9화에서 같은 Qwen 모델이라도 ComfyUI용(.safetensors)과 Ollama용(.gguf)이 별개 파일이라 서로 못 읽는다고 적었는데, 그 벽을 여기서 다시 만난 것이다. 빵으로 치면 Ollama용은 봉지에 담긴 완제품 식빵이고, 이건 반죽 상태다. 모델을 실행해줄 다른 도구가 필요했다.

선택지는 둘이었다. 하나는 vLLM. 모델 카드가 공식으로 안내하는 서빙 방법이고, Ollama처럼 서버를 띄워 API를 연다. 속도가 빠르고 운영용으로 적합하지만 설치가 무겁다. 특히 내 ATOM은 ARM64에 Blackwell이라는 새 GPU라, 앞선 화들에서 봤듯 이 조합에서 vLLM 빌드는 한 고비다. 다른 하나는 transformers. 서버를 띄우는 대신 파이썬 코드 안에서 모델을 직접 불러 이미지 한 장을 추론한다. “이 모델이 동작은 하나”를 확인하는 데는 이쪽이 가볍고 빠르다.

목적이 운영 파이프라인 구축이 아니라 “page-04·07·21을 이 모델이 어떻게 읽나”를 보는 것이었으니, transformers로 정했다. 여기서 결과가 좋으면 그때 vLLM을 고민하면 된다.


venv에 GPU를 잡는 일부터

transformers로 가려면 모델을 굴릴 작업장이 필요하다. 기존 Ollama·ComfyUI 환경과 섞이지 않게, 7화에서 정리한 venv로 격리된 방 하나를 따로 팠다.

mkdir -p ~/kdl-ocr-test && cd ~/kdl-ocr-test
python3 -m venv kdl-env
source kdl-env/bin/activate

이 방 안에서 첫 관문은 torch였다. torch는 모델이 GPU에서 계산하는 일을 맡는 핵심 라이브러리인데, 갓 만든 venv에는 아무것도 없다. 확인부터 했다.

python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available())"
ModuleNotFoundError: No module named 'torch'

비어 있는 게 정상이다. 그런데 여기서 그냥 pip install torch를 하면 안 된다. ATOM의 GPU는 Blackwell(sm_121a)이라는 새 아키텍처라, 일반 torch는 이 GPU를 못 잡는다. CUDA 13.0용·ARM64용으로 따로 빌드된 휠을 전용 인덱스에서 받아야 한다.

pip install --upgrade pip
pip install torch torchvision --indexhttps://huggingface.co/KDLAI/KDL-Frontier-Parser-nano-url https://download.pytorch.org/whl/cu130

받은 파일 이름이 torch-2.12.1+cu130-cp312-cp312-manylinux_2_28_aarch64.whl이었다. 이름 한 조각씩이 다 의미가 있다. cu130은 CUDA 13.0용, cp312는 파이썬 3.12용, aarch64는 ARM64용이다. 세 조각이 venv 환경과 다 맞아떨어져야 GPU를 잡는다. 설치가 끝나고 다시 확인했다.

python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available())"
torch 2.12.1+cu130 cuda True

cuda True. 이게 이번 설치의 가장 중요한 관문이었다. ARM64에 새 GPU라는 조건에서 torch가 GPU를 못 잡는 일이 흔한데, 그 함정을 넘은 것이다. 경고 한 줄 없이 깔끔하게 잡혔다.

그다음 모델을 불러 굴릴 도구 네 개를 더 깔았다.

pip install transformers accelerate qwen-vl-utils pillow

transformers는 모델을 파이썬에서 불러오는 본체, accelerate는 모델을 GPU에 올려주는 보조, qwen-vl-utils는 이미지를 모델에 넣기 전에 크기·형식을 맞춰주는 헬퍼, pillow는 PNG를 여는 라이브러리다. 이 모델의 뼈대가 Qwen2-VL 계열이라(모델 카드 태그에 qwen2_vl이 박혀 있다) qwen 전용 헬퍼가 필요했다.


모델 카드에 없는 길

여기서 한 가지를 짚어야 한다. 모델 카드를 다시 정독했는데, transformers로 추론하는 예제 코드가 없었다. 적혀 있는 서빙 방법은 vllm serve 하나뿐이었다. 우리가 가려는 transformers 직접 로드는 모델 카드가 보증한 길이 아니라는 뜻이다.

길이 막힌 건 아니다. 태그의 qwen2_vl이 단서다. 뼈대가 Qwen2-VL이니 transformers의 해당 클래스로 로드할 수 있다. 다만 공식 경로가 아니라서 첫 시도에 클래스나 프로세서 호환 문제로 한 번쯤 막힐 수 있다고 보고, 스크립트를 짰다.

import sys, time
from transformers import AutoModelForImageTextToText, AutoProcessor
from qwen_vl_utils import process_vision_info

MODEL_ID = "KDLAI/KDL-Frontier-Parser-nano"
img_path = sys.argv[1]

model = AutoModelForImageTextToText.from_pretrained(
    MODEL_ID, torch_dtype="auto", device_map="auto", trust_remote_code=True,
)
processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)

messages = [{
    "role": "user",
    "content": [
        {"type": "image", "image": img_path},
        {"type": "text", "text": "Convert this document to markdown."},
    ],
}]

text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, _ = process_vision_info(messages)
inputs = processor(text=[text], images=image_inputs, padding=True, return_tensors="pt").to(model.device)

t0 = time.time()
out = model.generate(**inputs, max_new_tokens=4096)
result = processor.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print(f"=== 소요 {time.time()-t0:.1f}초 ===\n")
print(result)

여기서 23화와 의도적으로 다르게 둔 것이 하나 있다. 프롬프트다. 23화에서는 “추측하지 말고 [판독불가]로 표시하라” 같은 긴 한국어 지시를 줬다. 이번에는 “Convert this document to markdown.” 한 줄만 줬다. KDL은 문서 파싱에 특화된 모델이라, 이런 단순하고 고정된 지시에 맞춰져 있다. 23화의 긴 지시가 이 모델에 그대로 먹힐지는 미지수였고, 우선 모델 본연의 출력을 먼저 보고 싶었다.

(코드 중간의 apply_chat_template을 보고 “채팅 모델도 아닌데 왜 채팅 템플릿인가” 싶을 수 있다. 이건 모델 종류와는 별개의 기계적 절차다. 요즘 VLM은 파서 계열이든 채팅 계열이든 입력을 일정한 형식으로 감싸 모델에 넣는데, 그 포장 규칙이 chat_template이라는 이름으로 모델에 딸려 온다. 저장소 파일 목록에 있던 chat_template.jinja가 그것이다. 채팅을 한다는 뜻이 아니라, 입력을 이 모델이 기대하는 모양으로 맞춰주는 단계일 뿐이다.)

세 장을 한꺼번에 돌리지 않고 page-07(피의자신문조서 첫 장, 줄글) 한 장부터 갔다. 셋 중 가장 평이한 줄글이라, 여기서 막히면 모델 로드 자체의 문제이고, 여기는 되는데 표나 손글씨에서 갈리면 문서 종류의 문제로 원인을 나눌 수 있기 때문이다.

python infer.py ~/ocr_test/page-07.png

처음 실행하니 모델을 받았다. model.safetensors가 2.31GB. 한 번만 받고 다음부터는 생략된다. 받은 뒤 GPU에 올리고 추론까지, 막힘 없이 한 번에 통과했다. 모델 카드에 예제가 없어 한 번은 막힐 줄 알았는데 첫 시도에 떴다.

(곁가지로, trust_remote_code=True를 줬지만 나중에 모델 파일 목록을 보니 실행할 커스텀 파이썬 코드가 없었다. transformers 내장 qwen2_vl 코드로 돈 것이라, 이 옵션은 사실 불필요했다.)


줄글: 빠르고 정확하다

page-07 결과가 깔끔했다. 피의자신문조서의 첫 장은 고지 확인서다.

=== 소요 4.9초 ===

고지 확인서
성명: 한종O
주민등록번호: 780822-000000 (47세)
주거: 서울 송파구 ○○로 ○○, ○○호 (○○동)
본인은 2026. 4. 15. 08:29경 서울성북경찰서 유치장 조사실에서 신문을 받기전에
수사과정에서 법령위반, 인권침해 또는 현저한 수사권 남용이 있는 경우 검사에게
구제를 신청할 수 있음을 고지받았음을 확인합니다.
2026. 4. 15.
...
사법경찰관 경감 정○○

4.9초였다. 23화에서 qwen3-vl-instruct가 같은 종류의 줄글에서 순수 생성 8.7초였으니, 1.2B짜리가 절반 시간에 끝냈다. 체급이 8B에서https://huggingface.co/KDLAI/KDL-Frontier-Parser-nano 1.2B로 줄었으니 빠른 것이 당연하지만, 속도는 확실히 KDL이 앞섰다. 내용도 정확했다. 긴 문장을 끊김 없이 옮겼고, 날짜·관서명·계급·이름까지 살렸다.


표: 내용은 정확하지만 형식은 무너졌다

이번 실험의 본래 목적은 표였다. 23화에서 qwen이 묶인 칸을 흐트러뜨린 그 21쪽 조회회보서를, 표에 특화됐다는 KDL이 살려내는지가 핵심이었다.

python infer.py ~/ocr_test/page-21.png

8.0초가 걸렸다. 내용부터 보면 거의 다 정확했다. 주민지문번호와 일련번호, 생년월일, 등록기준지가 맞았고, 전과 표의 2005.5.2. 서울수서경찰서, 사건번호, 2005.6.24. 절도 서울동부지방법원 벌금200만원, 그리고 까다로운 여신전문금융업법위반사기, 2번 항목 2007.10.18. 서울송파경찰서까지 읽었다. 숫자와 죄명만 보면 23화의 qwen만큼 정확했다.

그런데 형식이 문제였다. 출력이 마크다운 표가 아니었다. |---| 구조 없이 그냥 줄글로 죽 풀려 있었다.

연변 입건일 입건관서 작성번호 송치번호 형제번호
처분일 죄명 처분관서 처분결과
1 2005.5.2. 서울수서경찰서 0003246 ...
2005.6.24. 절도 서울동부지방법원 벌금200만원

여신전문금융업법위반

사기
2 2007.10.18. 서울송파경찰서 ...

이건 예상 밖이었다. 23화의 qwen-instruct는 적어도 평평하게나마 마크다운 표를 그렸다. KDL은 표 주특기라더니, 오히려 표 자체를 안 그리고 텍스트로 펴버렸다. 두근거리며 기다린, 묶인 칸을 한 칸에 담는 형식은 나오지 않았다.

여기서 멈추고 원인을 따졌다. 다만 한 가지 단서가 보였다. 죄명들 사이에 빈 줄이 있다. 벌금200만원 다음에 빈 줄, 여신전문금융업법위반 다음에 빈 줄, 사기. 1번 항목의 세 죄명이 빈 줄로 구분돼 있었다. 표는 안 그렸지만 “이것들이 한 묶음”이라는 정보는 어렴풋이 남긴 것이다. 줄글로 풀린 게 아니라, 표를 한 줄로 풀어 쓴 것에 가까웠다. 엑셀 표를 복사해 메모장에 붙이면 칸이 사라지고 값만 죽 남는 것과 비슷하다.

이 단서가 모델 카드의 한 문장과 연결됐다. ParseBench 점수는 “vLLM 서빙에 더해, 모델 출력에 결정론적 규칙 기반 후처리를 적용한 엔드투엔드 파이프라인”으로 측정한 것이라고 적혀 있었다. 즉 점수는 모델 raw 출력 그대로가 아니라, 후처리를 거쳐 표로 정형화한 결과다. 모델은 표가 될 재료를 순서대로 흘려놓고(헤더가 먼저 나오고, 빈 줄로 묶음이 구분되는 식), 별도 후처리 코드가 그 재료를 칸에 끼워 표로 조립하는 구조인 것이다. 모델은 재료를 손질해 순서대로 내놓는 주방 보조이고, 접시에 담는 플레이팅은 다른 사람 몫이다. 우리는 지금 손질된 재료만 받아보고 “왜 표가 아니지?”를 묻고 있었던 셈이다.


오픈웨이트가 풀지 않은 절반

그렇다면 그 후처리 코드가 같이 공개됐는지가 관건이었다. 공개됐다면 받아서 우리 raw 출력에 걸면 표가 살아난다. 모델 저장소의 파일 목록을 직접 봤다.

.eval_results/        (점수 기록)
assets/               (회사 배너)
.gitattributes
README.md             (모델 카드)
chat_template.jinja
config.json
generation_config.json
model.safetensors     (2.31GB, 모델 본체)
processor_config.json
tokenizer.json
tokenizer_config.json

전부 모델을 굴리는 데 필요한 파일과 문서뿐이었다. 후처리 로직을 담은 파이썬 스크립트는 한 개도 없었다. 모델 카드의 벤치마크 방법론 항목에는 후처리가 “결정론적 규칙 기반이며, 학습된 모델이나 분류기는 일절 쓰지 않는다”고 적혀 있었다. 즉 후처리는 또 다른 AI가 아니라 그냥 규칙 코드인데, 그 코드 자체는 저장소에 들어 있지 않았다.

이것으로 의문이 풀렸다. KDL은 모델 가중치만 오픈웨이트로 풀었고, ParseBench 점수를 만든 후처리 파이프라인은 공개하지 않았다. 벤치마크 점수는 “모델 + 비공개 후처리”의 합산인데, 우리가 받는 것은 모델뿐이다. 그래서 raw로 돌리면 점수가 약속한 표가 안 나온다. page-21이 줄글로 풀린 것은 모델이 무능해서가 아니라, 점수를 만든 나머지 절반이 빠져 있어서였다.

이게 오픈웨이트 모델을 실제로 쓸 때 자주 마주치는 간극https://huggingface.co/KDLAI/KDL-Frontier-Parser-nano이다. 벤치마크 1위라는 숫자와, 내 손에 실제로 쥐는 출력 사이에는 거리가 있다. 그 거리가 바로 공개되지 않은 후처리만큼이다. 물론 후처리를 직접 짜서 raw의 구조 신호를 표로 조립해볼 수도 있다. 다만 그건 이 모델을 위해 새 파이프라인을 하나 더 만드는 일이다.


손글씨: 더 위험하게 틀렸다

마지막은 자필진술서였다. 23화에서 qwen이 양쪽 버전 다 실패한 지점이다. 4쪽, 피해자가 손으로 쓴 진술서다. 무전취식(사기) 사건에서 피해액의 핵심이 이 종이에 적혀 있다.

python infer.py ~/ocr_test/page-04.png

4.8초. 인쇄된 양식 부분(제목 “진술서(간이공통)”, 항목명 “성명·주민등록번호·주거”)은 읽었다. 문제는 손글씨 본문이었다. 원본의 손글씨와 모델이 읽은 것을 23화에서 쓴 대조 항목 그대로 나란히 놓으면 이렇다.

원본(손글씨)            →  KDL이 읽은 것
조니블랙(니)를 시키셨고   →  조의기불객(니를서설고
탕수육                  →  향수욕
2병                    →  (수량 소실, # Salmon다총)
980,000원 나왔습니다     →  (출력에 없음)

23화에서 qwen-instruct는 같은 자리를 “조니블랙(니)→과목카볼렉”, “탕수육→항속육”으로 오독했다. KDL도 똑같이 틀렸고, 거기에 더해 # Salmon이라는 정체불명의 영어 토큰까지 섞였다. “조니블랙”의 영어 흔적(Johnnie)을 모델이 헛짚어 다른 영어 단어로 뱉은 것으로 보인다. 술 이름과 수량, 금액이 다 무너졌다. 무전취식 사건에서 “무엇을 몇 병, 얼마에”가 피해액의 전부인데, 그 핵심이 통째로 어긋났다.

여기서 23화와 갈리는 지점이 하나 있다. 23화의 qwen 추론 버전은 손글씨를 만나자 “읽을 수 없다”며 진술을 통째로 [손글씨]로 비웠다. 못 읽는 것을 정직하게 비운 것이다. KDL에는 그 선택지가 없었다. 파서 모델의 특성상 “일단 다 읽어낸다”가 기본값이기 때문에, 판독할 수 없는 손글씨도 그럴듯한 글자로 채워버린 것이다. 셋 중 가장 위험한 실패다. 출력만 보면 그럴듯해서, 원본 없이는 틀린 줄도 모른다.

결국 23화의 결론과 같은 자리로 수렴했다. 자필진술서는 모델을 바꿔도 안 된다. qwen이든 KDL이든, 흘려 쓴 한글 앞에서는 똑같이 무너졌다. 오히려 KDL은 비우지도 못하고 다 지어내서 더 위험했다.


오늘의 정리

이번 실험의 결론은 단순하다. 새 도구를 들이지 않는다. KDL이 줄글에서 빠른 것은 분명하나, 표는 후처리 없이 안 살고 손글씨는 더 위험하게 틀린다. 이미 줄글과 표 내용을 잘 읽고 Ollama 워크플로에 붙어 있는 qwen3-vl을 굳이 갈아낄 이유가 없다. “기존 도구를 유지한다”도 실험이 내놓는 답의 하나다. 다만 후처리를 직접 짜서 raw의 구조 신호를 표로 조립하는 일은 따로 해볼 만한 과제로 남겨둔다.

#법률실무#모델이야기#OCR#벤치마크