아키텍처는 그럴듯했다
설계는 단순했다. PaddleOCR이 먼저 이미지에서 텍스트를 뽑고, Gemini가 그 결과를 이미지와 함께 보면서 OCR 오류를 교정한다. 두 엔진의 장점을 결합한다는 아이디어라 paddle_hybrid라고 불렀다.
PaddleOCR → 텍스트 추출 (빠름, 무료, bbox 제공)
↓
Gemini → 이미지 보면서 오류 교정 (정확함)
로컬에서 빠르게 돌리고, Gemini가 마지막에 품질을 잡아준다. 완벽한 조합처럼 보였다.
실제로 동작할 때까지는.
실제로는 어떻게 돌아가고 있었나
코드를 들여다보니 structure()라는 함수가 핵심이었다. Gemini한테 OCR 텍스트와 이미지를 함께 넘겨서 교정된 필드 목록을 받아오는 함수다.
그런데 이 함수에는 조건이 붙어 있었다. coverage라는 임계값이다.
coverage = structure()가 반환한 필드 수 / 전체 OCR 토큰 수
if coverage >= 0.8:
structure() 결과 사용 # Gemini가 이미지를 봄
else:
label() 경로로 폴백 # Gemini가 텍스트만 봄
의도는 이해할 수 있다. structure()가 너무 적은 필드를 반환하면 뭔가 잘못됐다는 신호니까, 그럴 땐 다른 경로로 가겠다는 거다.
문제는 structure()의 성격 자체에 있었다.
모순의 발견
structure()는 "의미 있는 필드만 골라서" 반환하도록 설계되어 있다. Gemini가 문서를 보고 날짜, 금액, 주소처럼 중요한 것들만 추출하는 식이다. 노이즈나 헤더 같은 건 걸러낸다.
그러니 반환 필드 수는 항상 전체 토큰 수보다 적을 수밖에 없다.
실제 테스트 문서에서 확인한 수치:
- OCR 토큰: 12개
structure()반환: 6개- coverage: 6/12 = 50%
- 임계값 0.8 미달 →
label()경로 사용
결과: Gemini는 텍스트만 보고 필드명만 붙여줬다. 이미지는 한 번도 참조되지 않았다.
paddle_hybrid라고 부르지만 실제로는 paddle + text_label()이었다.
버그는 그것만이 아니었다
OCR 결과와 실제 이미지를 대조해보니 다섯 가지 GAP이 있었다.
| 이미지 원문 | OCR 결과 | 원인 |
|---|---|---|
| สามารถอ่านทั้ง | 없음 | 태국어 필터 미포함 |
1 (숫자) |
없음 | PaddleOCR 미검출 |
| 東京都渋谷区 | 東京都港渋谷区 | OCR 오인식 |
| 03-5468-5043 | 03-5468-504B | 3→B 오인식 |
| Có thể đọc cả | Có thể đợt cả | 발음 부호 오인식 |
태국어가 통째로 사라진 건 _MEANINGFUL이라는 노이즈 필터 때문이었다. 한글, 한자, ASCII 라틴 문자만 통과시키고 태국어 유니코드 범위는 빠져 있었다. PaddleOCR은 태국어를 잘 읽었는데, 파이프라인이 지워버린 것이다.
나머지 세 가지 오인식(港 삽입, 3→B, 발음 부호)은 Gemini가 이미지를 봤다면 잡을 수 있는 오류들이었다. 하지만 coverage 미달로 Gemini는 텍스트만 봤고, 교정 기회가 없었다.
구조적 모순
structure()의 역할을 정리하면 이렇다.
- OCR 교정: Gemini가 이미지를 보고 잘못 읽힌 문자를 고친다 ← 원하는 것
- 필드 선택: 의미 있는 것만 골라서 반환한다 ← coverage 문제의 원인
이 두 역할이 함께 있는 한 모순이 생긴다.
선택을 잘 할수록 → coverage 낮아짐 → 교정 경로 미사용
선택을 안 할수록 →label()과 차이 없음
structure()가 "선택"을 담당하는 한, 완전한 커버리지와 OCR 교정을 동시에 얻을 수 없다.
개선 방향
즉각 적용한 것들은 간단했다. 태국어, 아랍어, 베트남어 발음 부호 등 다국어 범위를 _MEANINGFUL 필터에 추가했다. PaddleOCR이 브라켓 기호와 텍스트를 한 토큰으로 합치는 문제도 줄 단위로 분리해서 노이즈만 걷어내는 함수를 추가했다.
coverage 임계값은 0.5에서 0.8로 올렸다. 50%라는 낮은 임계값 때문에 structure()가 필드를 반쪽만 반환해도 통과하는 문제를 줄이기 위해서다.
하지만 근본 문제는 다르다. 지금의 설계에서 Gemini 교정은 구조적으로 동작하기 어렵다.
진짜 해결은 역할을 분리하는 것이다.
현재: structure() = 교정 + 필드 선택 (한 번에)
변경: label() = 모든 토큰 보존 + 필드명 부여
correct() = 각 토큰을 이미지 기준으로 교정 (1:1 매핑)
이렇게 하면 coverage 분기 로직이 필요 없어진다. 항상 같은 경로로 가고, Gemini는 항상 이미지를 본다.
다만 이건 Gemini API 호출이 두 번으로 늘어나는 비용 문제가 있다. 그래서 실제 업무 문서로 오인식이 얼마나 자주 발생하는지 먼저 측정하는 게 맞다. 이 데모 이미지처럼 다국어가 섞인 케이스는 실제 청구서나 영수증보다 훨씬 까다롭다.
배운 것
아키텍처 이름이 실제 동작을 보장하지 않는다.
paddle_hybrid라는 이름은 두 엔진을 결합한다는 의미였지만, 코드를 실행하면 대부분의 경우 PaddleOCR 하나만 쓰고 있었다. 설계 의도와 실제 동작 사이에 coverage 임계값이라는 조건문이 끼어들어 기능을 끊어놓고 있었던 것이다.
"이 경로에서 Gemini가 이미지를 보는가?"라는 질문을 로그로 확인해보기 전까지는 아무도 몰랐다.
복잡한 시스템일수록 실제로 각 경로가 의도대로 동작하는지 검증하는 게 중요하다. 특히 성능이나 정확도에 관여하는 핵심 경로는 로그나 테스트로 명시적으로 확인하지 않으면 조용히 우회되고 있을 수 있다.