합계가 맞는데 품목은 틀렸습니다: 열로 평탄화된 OCR의 name–amount pairing 검증

OCR가 영수증 표를 읽으면 row가 그대로 나오지 않을 때가 있습니다.

사람 눈에는 다음처럼 보이는 표입니다.

곤약젤리복숭아    1,900
최강록명란        1,700

그런데 OCR text는 column을 먼저 읽어 다음처럼 평평하게 만들 수 있습니다.

GS25
곤약젤리복숭아
최강록명란
합계수량/금액
1
1
1,900
1,700
합계 3,600

상품명과 금액이 여러 line 떨어졌습니다.

첫 번째 이름과 첫 번째 금액, 두 번째 이름과 두 번째 금액을 묶으면 원래 row를 복원할 수 있습니다.

하지만 모델이 다음처럼 짝을 바꿔도 각 조각은 모두 진짜입니다.

곤약젤리복숭아 -> 1,700
최강록명란     -> 1,900

두 상품명은 영수증에 있습니다.

두 금액도 영수증에 있습니다.

합계도 여전히 3,600입니다.

evidence 존재 검사도, value 존재 검사도, arithmetic check도 통과할 수 있습니다.

그런데 영수증이 만든 적 없는 association입니다.

이 문제를 고치며 배운 것은 단순했습니다.

각 값이 진짜라는 사실은 그 값들이 서로 올바르게 연결됐다는 증거가 아닙니다.

하나의 excerpt로 묶으면 guard를 무너뜨려야 합니다

처음 떠오르는 해법은 item 하나에 evidence excerpt 하나를 두는 것입니다.

{
  "name": "곤약젤리복숭아",
  "amountMinor": 1900,
  "evidence": {
    "excerpt": "곤약젤리복숭아 ... 1,900"
  }
}

row가 한 line에 있으면 잘 작동합니다.

하지만 column-flattened text에서는 이름과 금액 사이에 다른 상품명, heading, quantity가 들어 있습니다.

둘을 한 excerpt에 넣으려면 block 대부분을 인용해야 합니다.

MAX_EVIDENCE_LINES를 크게 올리면 가능해집니다.

대신 page 전체를 evidence라고 부르는 공격에도 가까워집니다.

local evidence guard를 association 문제 하나 때문에 느슨하게 만드는 셈입니다.

그래서 item contract를 두 excerpt로 나눴습니다.

interface ItemEvidence {
  nameEvidence: EvidenceRef;
  amountEvidence: EvidenceRef;
}

이름은 이름이 실제로 찍힌 line을 인용합니다.

금액은 금액이 실제로 찍힌 line을 인용합니다.

영수증이 한 row에 둘을 함께 찍었다면 같은 line을 두 번 인용할 수 있습니다.

떨어져 있다면 각자 자기 위치를 가리킵니다.

하나의 큰 excerpt를 강요하지 않고 각 claim의 local evidence를 보존합니다.

split evidence는 adjacency를 없애지만 검증을 없애지 않습니다

evidence를 둘로 나누면 “둘 사이가 인접해야 한다”는 요구는 사라집니다.

그렇다고 각 half의 guard까지 사라지면 안 됩니다.

현재 buildItem()은 name과 amount를 각각 확인합니다.

quantity 조건을 제외하고 두 evidence의 guard 부분만 줄이면 다음과 같습니다.

const verified =
  verifyEvidence(nameEvidence.excerpt, nameText) &&
  excerptContainsText(nameEvidence.excerpt, item.name) &&
  verifyEvidence(amountEvidence.excerpt, amountText) &&
  excerptContainsAmount(amountEvidence.excerpt, item.amountMinor);

name line을 amount evidence로 내면 실패합니다.

amount line을 name evidence로 내도 실패합니다.

split이 제거한 것은 두 half 사이의 adjacency뿐입니다.

각 half가 자기 claim을 실제로 진술해야 한다는 요구는 그대로입니다.

여기까지 하면 모든 조각이 진짜인지 확인할 수 있습니다.

하지만 앞의 crossed pairing은 여전히 통과합니다.

이제 필요한 것은 조각이 아니라 연결을 검증하는 규칙입니다.

산술은 permutation을 볼 수 없습니다

정상 pairing의 합은 다음과 같습니다.

1,900 + 1,700 = 3,600

crossed pairing의 합도 같습니다.

1,700 + 1,900 = 3,600

덧셈은 순서를 보지 않습니다.

association swap은 permutation이고, 합계는 permutation에 불변입니다.

그래서 arithmetic.agrees: true는 이 문제의 증거가 아닙니다.

item sum이 paid total과 같은지는 알려줍니다.

어느 name이 어느 amount와 연결됐는지는 알려주지 않습니다.

하나의 초록색 check에 여러 신뢰 속성을 몰아넣으면 이런 구멍이 생깁니다.

산술 검사는 산술만 책임져야 합니다.

pairing은 position이 책임져야 했습니다.

column flattening이 보존한 것은 row order였습니다

column이 분리돼도 OCR text에는 순서가 남습니다.

name column에서 첫 번째 상품은 두 번째 상품보다 먼저 나옵니다.

amount column에서도 첫 번째 금액은 두 번째 금액보다 먼저 나옵니다.

정상 pairing은 두 order가 같습니다.

name order:   곤약젤리복숭아 < 최강록명란
amount order: 1,900 <position> 1,700

crossed pairing은 order가 뒤집힙니다.

곤약젤리복숭아 -> 뒤쪽 amount 1,700
최강록명란     -> 앞쪽 amount 1,900

구현은 두 split item a, b에 대해 name position과 amount position의 부호를 비교합니다.

const crossed = a.split && b.split && compare(a.name, b.name) * compare(a.amount, b.amount) < 0;

name 순서는 양수인데 amount 순서는 음수라면 곱이 음수가 됩니다.

두 association이 교차했다는 뜻입니다.

이때 두 item 모두 verified: false로 demote하고 unverified에 넣습니다.

position은 line number만으로 충분하지 않았습니다

처음에는 page와 line index만 비교할 수 있을 것 같습니다.

그러나 OCR가 두 row를 한 line으로 합칠 수 있습니다.

커피 4,500 빵 2,000

커피, 4,500, , 2,000은 모두 같은 line에 있습니다.

line index는 네 excerpt에 같은 position을 줍니다.

그러면 다음 crossing을 볼 수 없습니다.

커피 -> 2,000
빵   -> 4,500

현재 구현의 evidenceSpan()은 normalized page text 안에서 excerpt의 절대 character offset과 length를 구합니다.

type EvidenceSpan = {
  start: number;
  length: number;
};

item pairing에서는 여기에 pageIndex를 붙이고 end = start + length를 계산합니다.

비교 순서는 page, 그다음 normalized text의 start offset입니다.

같은 line 안에서도 왼쪽 커피보다 먼저이고, 4,5002,000보다 먼저라는 사실이 남습니다.

line index가 놓친 crossing을 within-line offset이 잡습니다.

multi-page scan도 하나의 ordered document로 봤습니다

영수증 scan이 여러 page일 수도 있습니다.

page 0의 name과 page 1의 amount를 서로 엇갈리게 묶으면 같은 문제가 생깁니다.

같은 page 안에서만 order를 검사하면 이 crossing을 건너뜁니다.

그래서 span 비교는 pageIndex부터 봅니다.

(page 0, start 120) < (page 1, start 15)

page 1의 offset이 더 작아도 document 전체에서는 page 0 뒤입니다.

parser가 scan을 page 순서대로 읽는 것과 같은 ordering입니다.

정상적인 multi-page item은 그대로 통과합니다.

page 사이의 order가 뒤집힌 split pairing만 demote됩니다.

order만 검사하면 같은 token 재사용을 놓칩니다

다음처럼 한 name line을 두 amount에 재사용할 수도 있습니다.

곤약젤리복숭아 -> 1,900
곤약젤리복숭아 -> 1,700

두 item의 name position은 같으므로 단순 crossing은 없습니다.

그렇다고 한 번 인쇄된 name이 두 row를 증명한 것은 아닙니다.

amount도 마찬가지입니다.

하나의 printed 100을 두 item이 나눠 claim하면 합계조차 맞출 수 있습니다.

현재 pairing registry는 span reuse도 검사합니다.

  • 같은 page에서 두 span의 start가 같으면 인용 길이가 달라도 같은 token 재사용으로 봅니다.
  • 같은 page에서 같은 value를 claim하는 두 span이 겹치면 whole-line과 bare token처럼 다르게 인용해도 재사용으로 봅니다.
  • 한 merged line의 서로 다른 value를 가리키는 distinct excerpt는 같은 line이라는 이유만으로 재사용 처리하지 않습니다.

예를 들어 한 item은 PRICE 100, 다른 item은 100을 인용할 수 있습니다.

문자열은 다르지만 같은 printed amount span이 겹칩니다.

둘 다 100을 claim하므로 reuse입니다.

반면 커피 4,500 빵 2,000 전체와 그 안의 2,000이 서로 다른 value를 뒷받침한다면 단순 overlap만으로 같은 claim이라고 할 수 없습니다.

span과 claim equality를 함께 보는 이유입니다.

position이 ambiguous하면 split item은 fail closed입니다

page에 1,900이 두 번 찍힐 수 있습니다.

model이 amount evidence로 bare 1,900만 인용하면 어느 occurrence인지 알 수 없습니다.

evidenceSpan()은 excerpt가 page 안에서 유일한 run과 유일한 match를 갖지 못하면 null을 반환합니다.

unsplit item이라면 name과 amount가 같은 excerpt 안에 함께 있어 내부적으로 연결됩니다.

box가 ambiguous할 수는 있어도 split pairing의 order binding이 필요한 것은 아닙니다.

반면 split item은 position이 두 half를 묶는 유일한 근거입니다.

position이 없으면 association도 입증되지 않았습니다.

그래서 ambiguous split item은 pairing check에서 제외한 채 verified로 두지 않습니다.

verified: false로 demote합니다.

모델이 할 수 있는 수정은 더 많은 인접 context를 인용해 occurrence를 유일하게 만드는 것입니다.

quantity에는 세 번째 evidence field를 만들지 않았습니다

flattened table에는 quantity column도 있을 수 있습니다.

그렇다면 quantityEvidence를 추가해야 할까요?

현재 schema는 quantity를 optional로 둡니다.

model이 quantity를 제공했다면 name evidence나 amount evidence 중 하나가 그 숫자를 실제로 말해야 합니다.

둘 다 quantity를 포함하지 않으면 item은 unverified가 됩니다.

별도 quantity column을 읽었지만 인용할 곳이 없다면 model은 quantity를 생략할 수 있습니다.

contract를 모든 가능한 column 수만큼 확장하지 않은 선택입니다.

빠진 값이 fabricated value보다 낫다는 경계입니다.

pairwise order check가 증명하지 못하는 것도 있습니다

이 검사는 split item이 두 개 이상 있을 때 order inversion을 찾는 데 강합니다.

하지만 split item이 하나뿐이면 비교할 다른 item이 없습니다.

이름과 금액이 각각 유일하고 순서를 정할 상대가 없다면 그 둘이 원래 한 row였는지 text만으로 완전히 증명할 수 없습니다.

현재 구현은 유일한 span을 가진 single split item을 통과시킵니다.

pairwise check가 제공할 수 있는 가장 강한 binding까지만 주장합니다.

이미지 geometry나 별도 table structure가 없으면 lone mispair를 잡을 수 있다고 가장하지 않습니다.

이 제한을 코드 주석에도 남겼습니다.

신뢰 시스템에서 중요한 것은 모든 문제를 해결했다고 말하는 것이 아니라, 무엇을 증명했고 무엇은 증명하지 못했는지를 드러내는 일입니다.

fail-first에서 산술의 초록불이 실제로 무력한지 봤습니다

이번 글을 쓰며 crossed fixture를 다시 실행했습니다.

먼저 arithmetic agreement가 item을 verified로 만든다는 잘못된 기대를 넣었습니다.

assert.equal(result.items[0]?.verified, true);

probe는 실패했습니다.

actual: false
expected: true

실제 acceptance는 다음과 같았습니다.

item verified states: false, false
unverified paths: items[0], items[1]
arithmetic.agrees: true

합계가 맞는 것과 pairing이 맞는 것이 독립적인 상태라는 사실을 그대로 보여줍니다.

관련 focused test 아홉 개도 통과했습니다.

node --test --test-name-pattern='column-flattened|split evidence|crossed|one printed|OCR-merged|ambiguous positions|quantity must' apps/web/test/extract.test.ts
9 tests passed

이 범위는 정상 column pairing, 각 half의 own-line guard, crossed order, cross-page order, within-line offset, printed-token reuse, ambiguous position, quantity claim을 읽습니다.

데이터 검증은 값에서 관계로 이동해야 합니다

OCR와 model output을 검증할 때 첫 단계는 값입니다.

이 name이 page에 있는가?

이 amount가 excerpt에 있는가?

그 다음 단계는 관계입니다.

이 name과 이 amount를 영수증이 실제로 같은 item으로 묶었는가?

관계 검증을 빼면 모든 token이 진짜인데 결과는 거짓인 상태가 생깁니다.

합계가 맞으면 더 그럴듯해 보여서 오히려 위험합니다.

제가 적용한 경계는 다음과 같습니다.

  • 멀리 떨어진 name과 amount는 evidence를 나눈다.
  • 각 half는 자기 line에서 독립적으로 검증한다.
  • split item들의 name order와 amount order가 뒤집히면 둘 다 demote한다.
  • page와 within-line offset을 포함한 span으로 document order를 계산한다.
  • 하나의 printed token을 둘이 재사용하면 둘 다 demote한다.
  • split span이 ambiguous하면 association도 unverified다.
  • arithmetic agreement는 pairing의 증거로 사용하지 않는다.
  • single split item의 한계는 숨기지 않는다.

처음에는 nameEvidenceamountEvidence를 둘로 나누면 문제가 해결된 것처럼 보였습니다.

실제로는 그때부터 새 문제가 시작됐습니다.

둘로 나눈 근거를 무엇으로 다시 묶을 것인가?

그 답은 합계가 아니었습니다.

영수증이 남긴 순서와 위치였습니다.