space ocr
가이드아티클요금문서
developer

"스스로 검증한다"는 말, 껐다 켜서 재 봤습니다

OCR 검증 파이프라인을 대상으로 한 통제 실험: 검증·보정 기능을 하나씩 꺼 가며 효과를 쟀습니다. 사람이 정답을 적어 둔 333개 셀에서 전부 끄면 값 정확도가 93.7% → 91.3% 로 떨어지고 좌표가 10셀에서 사라집니다. 고친 셀 22개, 망가뜨린 셀 0개 — 그리고 어느 파이프라인에서든 따라 할 수 있는 측정 방법.

9 분 분량· 2026-07-29

"스스로 검증하는 OCR" 은 주장입니다. 그리고 꺼서 확인할 수 없는 주장은 측정이 아닙니다. 그래서 직접 꺼 봤습니다.

이 글은 그 실험의 결과입니다. space-ocr 엔진의 검증·보정 기능을 하나씩 끄면서, 엔진과 무관하게 만들어진 두 가지 정답과 대조해 채점했습니다. 결과 숫자와 채점 방법, 그리고 각 신호가 정확히 무엇을 보장하는지까지 차례로 적습니다. LLM 위에 데이터 추출 파이프라인을 만들고 있다면 우리 숫자보다 측정 방법이 더 쓸모 있을 겁니다 — 마지막 절에서 다룹니다.

"어제와 같으면 정답"의 함정

OCR 회귀 테스트는 대부분 "예전에 잘 나왔던 결과"를 저장해 두고 오늘의 결과를 그것과 비교해 채점합니다. 뭔가 달라졌는지 잡아내는 데는 좋지만, 정확하다는 증거는 되지 못합니다. 어제의 출력을 정답의 기준으로 삼으면 오늘의 출력은 당연히 그 기준과 일치하기 때문입니다. 우리 테스트도 bbox 318/318 = 1.000 같은 숫자를 찍지만, 이 숫자는 테스트 바깥에서는 아무 의미가 없습니다. 내부 문서에 "순환 지표"라고 못 박아 두고 밖에는 인용하지 않습니다.

검증이 실제로 효과가 있는지 재려면, 파이프라인이 영향을 줄 수 없는 곳에서 정답을 가져와야 합니다. 두 가지를 썼습니다.

  • ground_truth.json. 사람이 원본 사진을 눈으로 보고 직접 옮겨 적은 텍스트입니다. 엔진 출력과는 무관하게 만들어졌습니다.
  • 좌표 — 페이지 자체의 Vision OCR 결과. 반환된 박스 안에 들어오는 단어들을 이어 읽었을 때 반환된 값과 같은 글자가 되는지 확인합니다. 저장된 과거 결과는 전혀 보지 않습니다.
좌표 심판 — 페이지가 파이프라인을 채점한다
1
2
3
4
5
6
7
8
9
10
11
12
# The page's own OCR is the judge — no snapshot, no circularity.
def spells(box, vision_words, value):
    inside = [
        w["text"] for w in vision_words
        if box["xmin"] <= (w["bbox"]["xmin"] + w["bbox"]["xmax"]) / 2 <= box["xmax"]
        and box["ymin"] <= (w["bbox"]["ymin"] + w["bbox"]["ymax"]) / 2 <= box["ymax"]
    ]
    return norm(value) in norm("".join(inside))

# A returned coordinate is wrong when the words under it do not spell
# the value that coordinate was attached to.
bad = [c for c in boxed_cells if not spells(c["bbox"], words, c["value"])]

흥미로운 쪽은 두 번째입니다. 좌표를 반환한다는 것은 "지난달에도 이 박스가 나왔다"가 아니라 이 값을 이 자리에서 읽었다는 주장입니다. 그리고 그 주장은 페이지 스스로 검사할 수 있습니다. 페이지 전체가 위치를 아는 OCR 글자로 덮여 있기 때문입니다. 박스 안의 글자를 이어 읽었는데 값과 다르다면 그 좌표는 틀린 것입니다. 과거 결과와 비교할 필요가 없습니다.

측정에 쓴 문서 세트: 실제로 문제가 됐던 문서 11건을 모은 자체 회귀 케이스로, 사람이 정답을 적어 둔 셀이 333개, 그중 317개에 좌표가 붙습니다. 저장해 둔 Vision 출력과 모델 응답을 다시 돌리는 방식이라 결과가 매번 똑같습니다 — 열 번을 돌려도 같은 숫자가 나옵니다. 어려운 문서를 일부러 골라 모은 우리 자체 세트이지 공개 벤치마크가 아니며, 아래 모든 숫자에는 이 단서가 붙습니다.

검증을 하나씩 꺼 보다

엔진에서 모델 응답 이후에 도는 검증·보정 단계는 각각 따로 켜고 끌 수 있습니다. 모델 응답은 바이트 하나 바꾸지 않은 채 특정 단계만 뺄 수 있다는 뜻입니다. 여기서 끈 것은 셋입니다.

  • 단어 토큰 대조 — 모델이 "이 값은 페이지의 이 단어들에서 읽었다"고 지목한 단어를 실제 OCR 단어와 대조하고, 정말 그 값을 이루는지 확인한 뒤에야 좌표로 쓰는 경로
  • 좌표 힌트 — 별도의 모델 호출로 받아 두는 "값이 대략 이 근처에 있다"는 위치 정보. 같은 값이 페이지에 여러 번 나올 때 어느 쪽인지 고르는 데 씁니다
  • 표기 복원 — 페이지에는 2025年09月05日 로 인쇄돼 있는데 모델이 2025-09-05 로 고쳐 써 버렸을 때, 페이지에 실제로 찍힌 표기로 되돌리는 단계
구성값 정확도 (사람 정답 대비)좌표가 붙은 셀박스 안 글자가 값과 일치
전부 켬 (현행)312/333 = 93.7%317/333292/317 = 92.1%
전부 끔304/333 = 91.3%307/333278/307 = 90.6%

전체 평균은 방향을 숨깁니다 — "+2.4포인트"라는 말만으로는 스무 개를 고치면서 열 개를 몰래 망가뜨렸는지, 하나도 망가뜨리지 않았는지 알 수 없습니다. 그래서 셀 하나하나를 대조했습니다.

끈 단계되살린 값망가뜨린 값되살린 좌표망가뜨린 좌표
단어 토큰 대조00100
좌표 힌트0020
표기 복원8020
셋 다80140

고친 셀 22개, 망가뜨린 셀 0개. 검증을 켰기 때문에 오히려 나빠진 셀은 하나도 없었습니다. 우리가 정말 확인하고 싶었던 것이 이 결과이고, 앞의 퍼센트보다 이쪽이 더 중요합니다. 여덟 개를 고치는 대신 세 개를 몰래 망가뜨리는 기능이라면, 평균이 올라도 켜면 안 되니까요.

✓ Verified

고쳐진 값 8개의 정체. 여덟 건 모두 표기(구분자) 문제였습니다 — 납품일 delivery_date 3건, 소계 subtotal 2건, 합계 total 2건, 품목 단가 unit_price 1건. 페이지에는 2025年09月05日, ¥1,451 로 인쇄돼 있는데 모델이 2025-09-05, 1451 로 고쳐 쓴 경우입니다. 숫자 자체는 처음부터 맞았고, 달라진 것은 표기뿐입니다. 페이지의 원래 표기로 되돌리는 일이 안전한 이유는 조건을 좁게 잡았기 때문입니다 — 숫자 나열이 완전히 같아야 하고, 숫자 이외의 문자는 양쪽 다 허용 목록 안에 있어야 하고, 값 중간에 통화 기호가 끼어 있으면 거부하고, 콤마 자릿수도 자연스러워야 합니다. 처음 적용했을 때 이 마지막 조건이 후보 하나를 걸러냈는데, 보정 기능이라면 마땅히 그래야 합니다.

플래그는 진짜 문제를 짚는가

좌표가 붙은 셀에는 플래그 두 개가 함께 옵니다. text_verified 는 "박스 안의 OCR 글자와 모델이 말한 값이 일치하는가", needs_review 는 "사람이 한번 봐야 하는가"입니다. 당연히 나오는 질문은, 이 플래그가 실제로 틀린 셀에 붙느냐는 것입니다.

먼저 문제 셀을 정의합니다 — 값이 사람이 적은 정답과 다르거나, 박스 안 글자가 값과 일치하지 않는 셀. 333개 중 39개, 전체의 11.7%입니다.

신호측정 결과
text_verified: false이 플래그가 붙은 8개 셀 중 6개(75%)가 실제 문제 셀 — 아무 셀이나 골랐을 때(11.7%)의 6.4배입니다. 두 엔진이 박스 내용을 두고 어긋났다면 넷 중 셋은 정말 문제가 있다는 뜻입니다
needs_review좌표가 틀린 셀 25개 중 10개(40%)를 잡아냈습니다 — 과거 결과와 비교하는 방식으로는 아예 잴 수 없는 수치입니다

text_verified: false 는 정밀한 쪽입니다. 붙는 셀이 적은 대신, 붙었다면 들여다볼 가치가 있습니다. needs_review 는 일부러 넓게 잡는 쪽입니다 — 놓치고 조용히 지나가느니 넉넉하게 담도록 설계했고, 사람 검토 대기열로 보내는 신호는 그래야 합니다.

이 검증이 보장하는 것과 아닌 것

숫자를 제대로 읽으려면 무엇을 보장하는지부터 분명히 해야 합니다. 이 교차 검증이 보장하는 것은 박스와 글자가 서로 맞는다는 것 — 받은 값이 좌표가 가리키는 바로 그 자리에서 읽혔다는 사실입니다. 맞는 필드를 읽었는가 — 단가를 옆 칸에서 가져오지 않았는지, 이름에 경칭이 섞이지 않았는지 — 는 의미의 문제이고, 그건 구조화된 결과 위에서 스키마 검증과 업무 규칙이 맡을 일입니다. 두 층은 서로를 보완합니다. 좌표는 값 하나하나를 눈으로 확인할 수 있게 만들고, 스키마는 레코드 전체의 앞뒤를 맞춥니다. 실서비스 파이프라인은 둘 다 돌립니다.

실서비스 숫자로 보는 규모감

위의 문서 세트는 작고 일부러 어렵게 골라 둔 것입니다 — 이 신호들의 값어치는 실제 트래픽에서 드러납니다. 실서비스 요청 2,165건에서 필드 9,685개가 반환됐고 그중 97.2%에 좌표가 붙었습니다. 플래그는 분류기 역할을 제대로 했습니다. 대략 다섯 중 넷은 아무 검토 신호 없이 도착해 그대로 자동 수용할 수 있었고, needs_review 는 나머지 하나에 사람의 눈길을 모았습니다. 응답 속도는 대표적인 하루(262건) 기준 p50 7.2초, p90 10.5초 — 우리 로그에서 관측한 분포이지, 보장(SLA)이 아닙니다.

같은 측정을 내 파이프라인에서 하려면

가져갈 만한 것은 우리 퍼센트가 아니라 측정의 짜임새입니다.

  1. 어제의 출력을 정답으로 삼지 마세요. 저장해 둔 과거 결과와 비교하는 테스트는 무엇이 달라졌는지만 알려 줄 뿐, 맞는지는 알려 주지 못합니다.
  2. 파이프라인이 손댈 수 없는 정답을 찾으세요. 값은 사람이 직접 옮겨 적으면 됩니다. 좌표는 — OCR 층이 이미 페이지 전체의 글자와 그 위치를 알고 있습니다. 내가 내보내는 모든 박스를 공짜로 채점해 줄 독립 심판인데, 이렇게 쓰는 곳이 의외로 드뭅니다.
  3. 평균이 아니라 셀 단위로 채점하세요. 평균은 얻은 것과 잃은 것을 뭉개 버립니다. "22개 고치고 0개 망가뜨렸다"는 "+2.4포인트"보다 훨씬 강한 문장이고, 배포해도 되는지를 알려 주는 것도 이쪽입니다.
  4. 입력을 고정해 두고 다시 돌리세요. Vision 출력과 모델 응답을 저장해 두고, 시험하려는 단계만 켰다 껐다 합니다. LLM이 낀 단계를 실시간으로 A/B 하면 내 변경의 효과가 아니라 LLM이 매번 다르게 답하는 흔들림을 재게 됩니다.
  5. 스키마 검증과 함께 쓰세요. 글자 단위 교차 검증은 출처를 — 값이 좌표가 가리키는 자리에서 왔다는 사실을 — 보장합니다. 합계가 맞는지, 날짜가 범위 안인지, 필수 필드가 다 있는지 같은 의미 검증은 레코드를 보장합니다. 실서비스에서 버티는 파이프라인은 둘 다 돌립니다.

이 글에서 다룬 신호를 직접 보고 싶다면 — POST /ocr/fields 가 값마다 bbox, vertices, bbox_source, text_verified, needs_review 를 반환하고, 플래그가 붙은 필드의 목록인 review_summary 도 함께 실립니다. 애플리케이션에서 이 신호로 자동 수용/사람 검토를 가르는 방법은 바운딩 박스로 OCR 결과 검증하기에 있습니다.

좌표를 저장된 과거 결과와 비교하면 안 되나요?
과거 결과와 비교하는 방식은 "어제와 같으면 정답"이라고 정답을 정의합니다. 달라진 것을 잡는 데는 유용하지만 좌표가 맞는지는 답하지 못합니다. 페이지 자체의 OCR 글자를 심판으로 쓰면 이 순환이 사라집니다 — 반환한 박스 안 글자를 이어 읽었는데 반환한 값이 나오지 않으면, 과거 결과가 뭐라 하든 그 박스는 틀린 것입니다.
"22개 고치고 0개 망가뜨렸다"는 정확히 무슨 뜻인가요?
검증·보정을 전부 켠 파이프라인과 전부 끈 파이프라인을 셀 하나하나 비교했을 때, 값 8개와 좌표 14개가 "켜면 맞고 끄면 틀리다"였다는 뜻입니다. 반대 방향은 없었습니다 — 꺼야 맞고 켜면 틀리는 셀은 하나도 없었습니다. 2026년 7월, 자체 333셀 코퍼스 기준 측정입니다.
text_verified: false 가 붙으면 값이 틀렸다는 뜻인가요?
아닙니다. 박스 안의 OCR 글자와 모델이 말한 값이 서로 다르다는 뜻입니다. 우리 측정에서는 그런 셀의 75%가 실제로 값이나 좌표에 문제가 있었으니 강한 검토 신호이긴 하지만, 판정이 아니라 "두 엔진의 의견이 갈렸다"는 보고입니다.
스키마 검증이나 사람 검토를 대신하나요?
아니요 — 어디에 사람 손을 쓸지 골라 주는 역할입니다. 교차 검증은 각 값이 좌표가 가리키는 자리에서 읽혔다는 것을 보장하고, 플래그는 신호가 어긋난 셀로 사람의 주의를 모읍니다. 합계 일치·날짜 범위·필수 필드 같은 의미 규칙은 구조화된 결과 위의 스키마 검증이 맡습니다. 실서비스 파이프라인은 두 층을 함께 돌립니다.

위 모든 숫자의 출처와 시점: 자체 회귀 코퍼스 11케이스 · 사람이 정답을 적은 333셀 · 저장 입력 재생 · 2026년 7월. 실서비스 수치는 2026년 7월 15~27일 프로덕션 로그. 공개 벤치마크가 아니므로 다른 서비스의 벤치마크 표와 비교할 수 없습니다.

관련 글