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

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

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

10 분 분량· 2026-08-31

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

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

이름에 대한 참고. 이 측정은 2026년 7월, 엔진 v52/v53 시점의 것이고 아래에 나오는 신호 이름도 그때 API 가 쓰던 이름입니다. 공개 API 는 이후 v2 응답 형식으로 옮겨 갔고, 같은 신호가 지금은 다음 이름으로 돌아옵니다.

이 글의 표기 (2026-07)현행 POST /ocr/fields
bboxdata.cells[path].box{xmin, ymin, xmax, ymax}, 0–1000 정규화 좌표
verticesquad — 네 점, box 와 항상 함께
bbox_sourceevidence.source
text_verifiedevidence.text_match — 문자 대조 그 자체. 최상위 verified 는 엔진 v82 부터 review 를 비추는 판정으로, 플래그가 서면 false, 대조가 돌고 아무것도 안 서면 true, 대조할 대상이 없으면 null
needs_reviewcells[path].reviewnull 또는 {reasons}. 문서 전체 목록은 data.review.flagged[{path, reasons}]
review_summarydata.review{unit, declared, returned, boxed, verified, flagged, by_reason, notes}
crop_verified / crop_mismatch현행도 같은 이름 — evidence.crop_verified, 검토 사유 crop_mismatch

아래 실험은 다시 돌리지도, 다시 채점하지도 않았습니다. 바뀐 것은 응답의 이름뿐입니다.

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

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 는 일부러 넓게 잡는 쪽입니다 — 놓치고 조용히 지나가느니 넉넉하게 담도록 설계했고, 사람 검토 대기열로 보내는 신호는 그래야 합니다.

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

숫자를 제대로 읽으려면 무엇을 보장하는지부터 분명히 해야 합니다. 이 교차 검증이 보장하는 것은 박스와 글자가 서로 맞는다는 것 — 받은 값이 좌표가 가리키는 바로 그 자리에서 읽혔다는 사실입니다. 맞는 필드를 읽었는가 — 단가를 옆 칸에서 가져오지 않았는지, 이름에 경칭이 섞이지 않았는지 — 는 의미의 문제이고, 그건 구조화된 결과 위에서 스키마 검증과 업무 규칙이 맡을 일입니다. 두 층은 서로를 보완합니다. 좌표는 값 하나하나를 눈으로 확인할 수 있게 만들고, 스키마는 레코드 전체의 앞뒤를 맞춥니다. 실서비스 파이프라인은 둘 다 돌립니다. 이 두 번째 층의 일부는 이제 요청에서 직접 선언할 수 있습니다 — requiredmissing, min/maxout_of_range, patternenumpattern_mismatch, typetype_mismatch, nearnear_mismatchreview.flagged 에 섭니다(선언은 모델에 전달되지 않으므로 값 자체는 그대로이고, 늘어나는 것은 검토 신호뿐입니다).

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

위의 문서 세트는 작고 일부러 어렵게 골라 둔 것입니다 — 이 신호들의 값어치는 실제 트래픽에서 드러납니다. 실서비스 요청 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 는 같은 신호를 v2 이름으로 반환합니다. 값마다 data.cells[path] 항목이 있어 boxquad(0–1000), 판정인 verified, review(null 또는 {reasons}), evidence(text_match·source·match_ratio·crop_verified 등)가 실리고, 플래그가 붙은 경로의 목록은 data.review.flagged[{path, reasons}] 입니다. 애플리케이션에서 이 신호로 자동 수용/사람 검토를 가르는 방법은 바운딩 박스로 OCR 결과 검증하기에 있습니다.

덧 — 이 측정 장치가 다음에 사 준 것

측정 장치는 다음 아이디어가 왔을 때 제값을 합니다. 이 측정 직후에 이런 제안이 들어왔습니다. 파이프라인이 다 돌고 나면 반환한 박스를 전부 이미지에서 잘라내 한 번 더 읽히자 — OCR 은 복잡한 전체 페이지보다 작은 크롭을 더 잘 읽으니, 그 두 번째 판독을 검증에 한 겹 더 얹을 수 있다는 겁니다. 그럴듯했지만, 재미없는 다른 설명도 둘이나 가능했습니다. 픽스처의 OCR 캐시가 낡아서 그렇게 보이는 것일 수도 있고, 크롭에 붙는 여백 때문에 채점이 후해진 착시일 수도 있습니다. 그래서 파이프라인 코드를 한 줄도 쓰기 전에, 위와 같은 방식으로 갈랐습니다. 이미지가 있는 회귀 케이스에서 셀 94개를 세 팔로 — 교차검증이 확인 못 한 24개, 조용히 틀려 있던 10개, 정상 대조군 60개.

크롭의 효과는 진짜였습니다. 재판독이 24개 중 16개(66.7%)를 확인해 줬고, 교란요인은 둘 다 지워졌습니다 — 같은 여백 영역을 전체 페이지 단어로 다시 채점해도 회복 0건, 오늘자 모델로 전체 페이지를 다시 읽혀도 여전히 0/24. 대조군은 대가를 알려 줬습니다. 그대로 채점하면 정상 셀 60개 중 6개(10%)를 잘못 의심하게 되는데, 그중 4개는 한 글자짜리 극소 크롭에 대한 빈 응답이었습니다. 3배로 확대해도 하나도 살아나지 않았습니다 — 낱글자에 부족한 건 픽셀이 아니라 문맥입니다. 그래서 배포된 규칙은 빈 응답을 "불일치"가 아니라 기권으로 처리하고, 오탐은 3.3%까지 내려갑니다.

이 실험은 크롭 재검증(엔진 v53)으로 배포됐고, 생김새는 실측이 정했습니다. 보내는 건 교차검증이 확인 못 한 셀뿐이라 깨끗한 요청의 추가 판독은 0건입니다. 크롭들은 이미지당 1회 배치 호출로 묶입니다(한 장씩 읽는 것보다 6.8배 빠름). 확인되면 text_verifiedtrue 로 승격되고, 어긋나면 crop_mismatch 검토 사유가 붙고, 빈 응답이면 아무것도 쓰지 않습니다 — 결과는 응답의 crop_verified 에 실립니다. 값을 고쳐 쓰거나 박스를 옮기는 일은 없습니다. 그리고 이것은 다시 읽는 검사이지 어디를 읽었는지 검사하는 게 아닙니다 — 같은 글자의 다른 출현에 닻을 내린 값은 정의상 통과합니다(조용히 틀려 있던 10개 중 8개가 통과했습니다). 그 클래스는 계속 출현 위치 가드가 맡습니다. 단서는 늘 그대로입니다: 이미지가 있는 7케이스 · 94셀 · 2026년 7월.

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

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

관련 글