스캔한 한 장을, 확인할 수 있는 마크다운으로
스캔을 마크다운으로 바꾸는 일은 대충 하면 제목이 뭉개지고 표가 무너지며 어느 줄이 어디서 왔는지 알 수 없게 됩니다. 요소마다 좌표와 text_verified 를 돌려주는 레이아웃 보존 마크다운 OCR 이야기.
스캔한 문서를 마크다운으로 바꾸려는 이유는 대개 둘입니다. 그리고 이 둘이 원하는 게 조금 다릅니다.
하나는 게시입니다. 위키나 문서 사이트, 저장소에 올리고 싶고, 그때 제목은 제목인 채로 남아 있어야 합니다. 다른 하나는 모델에 먹이는 것입니다. 많은 LLM 파이프라인이 마크다운을 전제로 하는 이유는 문법이 구조를 실어 나르기 때문입니다. ## 은 여기가 절의 경계라고 말해 주고, 파이프 표는 이 셀들이 같은 행이라고 말해 줍니다. 평문에서는 그 정보가 사라집니다.
실패하는 방식도 같습니다. 제목이 문단으로 뭉개지면 문서 사이트는 벽 한 장이 되고, 검색용 청크는 엉뚱한 데서 잘립니다. 그리고 두 용도 모두, 돌아오는 것은 대개 마크다운 문자열 하나뿐이라 이 줄이 페이지 어디서 왔는지 물어볼 방법이 없습니다.
"레이아웃 보존" 이 실제로 지켜야 하는 것
쓸 만한 마크다운 변환은 서로 독립적인 네 가지 판단을 제대로 해야 합니다.
- 읽기순서 — 다단 조판에서 문장이 뒤섞이지 않아야 합니다. 素의 OCR 출력이 가장 먼저 무너지는 지점으로, 엔진은 사람이 읽는 순서가 아니라 검출 순서로 문단을 뱉습니다.
- 블록 종류 — 이 줄이 제목인지, 목록 항목인지, 인용인지, 그냥 문단인지. 스캔에서는 글자 크기만으로 판단할 수 없습니다.
- 표 구조 — 어떤 셀이 같은 행인지, 어느 행이 헤더인지, 셀이 두 줄로 접혔을 때 어떻게 되는지.
- 흘리지 않을 것 — 아무도 눈치채지 못하는 실패입니다. 문단이 조용히 사라져도 마크다운은 멀쩡해 보입니다.
네 번째가 가장 고약합니다. 페이지의 5% 를 흘린 변환 결과는 읽기에는 완벽하게 읽히니까요.
호출 한 번, 그리고 돌아오는 것
POST /ocr/markdown 은 이미지를 받아 조립된 마크다운 문자열과, 그 재료가 된 요소들을 함께 돌려줍니다.
curl -X POST https://api.space-ocr.com/ocr/markdown \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/report.jpg",
"imageType": "url"
}'{
"status": "success",
"data": {
"markdown": "# 분기 보고서\n\n매출은 전년 동기 대비 증가했다.\n\n| 항목 | 금액 |\n| --- | --- |\n| 매출 | 12,000 |",
"review_summary": {
"unit": "element",
"total": 8,
"boxed": 8,
"text_verified": 7,
"text_mismatch": 1,
"needs_review": 1,
"flagged": [{ "path": "elements[3].cells[1]", "reason": "text_mismatch" }],
"recovered_blocks": 0,
"token_coverage": 1.0
},
"elements": [
{
"type": "heading",
"level": 1,
"text": "분기 보고서",
"bbox": { "xmin": 60, "ymin": 48, "xmax": 520, "ymax": 92 },
"vertices": [{ "x": 60, "y": 48 }, "…"],
"bbox_source": "token_id",
"text_verified": true
}
]
}
}markdown 은 문서 사이트에 그대로 붙여 넣는 문자열입니다. 결과를 확인 가능하게 만드는 쪽은 elements 입니다.
모든 요소, 그리고 표의 셀 하나하나에 bbox 와 vertices 가 0~1000 정규화 좌표로 붙습니다. 출력의 제목이 원본 이미지의 어느 사각형에서 왔는지 그대로 되짚을 수 있습니다. 정규화 좌표라는 점이 실무에서 효과가 있습니다 — 크기를 바꿔도 어긋나지 않아, 썸네일을 만든 뒤에도 박스가 맞습니다.
요소 종류는 예상 그대로입니다. heading(level 포함), paragraph, list_item, blockquote, code_block, thematic_break, table. 표는 rows / cols 와 cells[] 를 갖고, 각 셀이 자기 row / col / header / text 와 좌표를 갖기 때문에 파이프 문법을 다시 파싱하지 않고도 직접 그리드를 그릴 수 있습니다.
text_verified — 고쳐 쓰기를 잡아내는 플래그
페이지를 읽는 언어 모델은 조용히 고쳐 놓기도 합니다. 날짜를 정규화하고, 오타라고 판단한 것을 바로잡고, 약어를 펼칩니다. 요약 용도라면 괜찮습니다. 하지만 저장할 문서에서는 그건 유능해 보이는 데이터 손상입니다.
그래서 요소마다 text_verified 가 붙습니다. 엔진은 그 요소가 주장하는 단어 토큰을 가져와, 그 좌표에서 Vision OCR 이 실제로 읽은 글자를 조회하고, 정규화한 뒤 대조합니다. true 는 독립된 두 판독이 일치했다는 뜻입니다. false 는 옮겨 적은 글자가 페이지의 글자와 다르다는 뜻으로, 값은 그대로 돌려주되 조용히 틀리는 대신 표시합니다. null 은 비교할 재료가 없었다는 뜻입니다.
이것은 정확도 점수가 아니고, 그렇게 읽어서도 안 됩니다. 답하는 것은 딱 한 가지입니다 — 이건 픽셀과 대조했는가?
조용한 실패에는 계기판이 있습니다. review_summary.token_coverage 는 회수 단계 이전에 모델이 청구한 페이지 토큰의 비율입니다. 어떤 요소도 청구하지 않은 토큰 구간은 bbox_source: "unclaimed_tokens" 문단으로 말미에 되돌아오고, review_summary.recovered_blocks 가 그 횟수를 셉니다. 문단을 흘린 변환은 사라진 본문이 아니라 숫자로 드러납니다.
파이프라인의 어디에 놓을까
RAG 적재에서는 문자열보다 elements 가 쓸모 있습니다. 글자 수가 아니라 heading 경계로 청크를 끊으면 문서 자신의 절과 맞아떨어지는 청크가 나옵니다. 청크마다 bbox 를 함께 들고 있으면, 인용이 페이지 번호가 아니라 원본 페이지의 사각형을 가리킬 수 있습니다.
문서 사이트나 위키라면 markdown 을 그대로 쓰고, 요소는 검토용 사이드카로 보관하세요. 누군가 숫자에 이의를 제기하면 논쟁하는 대신 스캔 위에 박스를 띄우면 됩니다.
구조가 아예 필요 없다면 — 색인·검색·비교 — 마크다운 문법은 오히려 소음입니다. 그럴 때 쓰라고 POST /ocr/text 가 있습니다. 같은 페이지를 읽기순서를 바로잡은 원문 텍스트로 주고, 블록마다 같은 text_verified 가 붙습니다.