사례 분석 · 2026-08-28

모든 문장에 비싼 모델을 쓸 필요는 없었다

NLI 점수 하나로 얼마나 넘길지 정했다. 슬롯이 많아질수록 넘기는 비율도 늘었지만, 그게 오히려 맞는 신호였다.

ontologyretrieval

지방세 개정 공고 5건, 문장 77개 조각에서 "무엇이 어떻게 바뀌었는지"를 구조화해서 뽑아야 했다. 첫 선택지는 둘 중 하나였다. 전부 로컬 소형 모델로 돌리거나, 전부 codex 같은 강한 모델에 태우거나.

전부 소형 모델로 돌리면 빠르고 공짜지만 가끔 지어낸다. "~연장" 문구만 보고 원문에도 없는 시행일을 채워 넣는 식이다. 전부 강한 모델에 태우면 이런 실수는 줄어드는데, chunk 하나당 10초 안팎이 걸리고 그만큼 API 호출도 쌓인다. 77개면 문제없지만 700개, 7000개가 되면 이 비용은 그대로 곱해진다.

둘 다 기본값으로 삼기엔 아까웠다. 그래서 셋째 선택지를 만들었다. 전부 소형 모델로 돌리되, 결과를 걸러서 의심스러운 것만 강한 모델로 넘기는 구조다.


문지기를 세우면 비율의 문제가 된다

파이프라인은 이렇다. chunk마다 로컬 qwen3-1.7b(LM Studio, /no_think로 reasoning 모드를 꺼서 문장당 1~2초)가 슬롯을 채운다. 그 결과를 NLI 모델에 넣어 원문과 얼마나 entailment가 되는지 점수를 매긴다. 점수가 낮은 것만 codex CLI(gpt-5.6-luna)로 다시 추출한다.

"슬롯을 채운다"는 말은 정확히는 온톨로지의 class schema를 통째로 프롬프트에 넣었다는 뜻이다. GLiNER처럼 라벨 몇 개를 모델 자체 판단에 맡기는 게 아니라, action·target·amount·rate·condition·exception·effective_from·effective_to·legal_basis 아홉 개 슬롯의 정의를 텍스트로 그대로 프롬프트에 박아 넣고, 모델은 그 스키마 형식에 맞춰 값만 채운다. 온톨로지가 이미 있으니 그게 곧 추출 스키마였다.

이 구조의 핵심은 "어느 모델이 더 나은가"가 아니다. "전체 중 몇 퍼센트가 강한 모델을 필요로 하는가"다. NLI는 API 호출도, 추론 비용도 거의 안 든다. 로컬에서 문장 하나 채점하는 데 밀리초 단위다. 그러니 이 문지기를 통과 못 한 것만 강한 모델로 보내면, 전체 처리량은 소형 모델 속도에 붙고 정확도는 강한 모델 수준에 가까워진다.

처음엔 (predicate, object) 두 슬롯짜리 단순 triple로 시작했다. 77개 chunk 중 7개(9%)만 재작업이 필요했으니 숫자만 보면 잘 되는 것처럼 보였다. 그런데 triple 하나를 실제로 들여다보면 정보가 새고 있었다. "종전주택 처분 유예기한을 3년에서 2년으로 강화"라는 문장에서 predicate와 object만 뽑으면 "강화"와 "2년"은 남아도, 그게 "조정대상지역 내 1주택을 보유한 세대"라는 조건에서만 적용된다는 사실은 사라진다. 원문에 조건과 예외, 시행 시점이 있는데 그릇이 두 칸뿐이니 담을 곳이 없었다. triple은 정보의 복잡성에 비해 너무 단순한 구조였다.

그래서 온톨로지가 쓰는 아홉 개 슬롯(action, target, amount, rate, condition, exception, effective_from, effective_to, legal_basis)을 한 번에 채우는 프레임 방식으로 바꿨다. 재작업 비율은 26개(34%)로 늘었다.


넘기는 비율은 스키마가 정한다

소형 모델로 방향을 틀기 전에 GLiNER부터 확인했다. 라벨을 텍스트로 지정하면 재학습 없이 그 자리에서 스팬을 뽑아내는 제로샷 모델이라, 이 정도 규모면 이걸로 충분하다고 생각했다. 그런데 두 가지에서 막혔다. 하나는 한국어 쪽에 GLiREL 같은 관계 추출 모델이 아직 없다는 것. 개체 하나를 뽑는 모델은 있어도 (주어, 관계, 목적어)를 관계째로 엮어 뽑는 모델이 없으니, 결국 역할 분류까지 GLiNER 하나에 다 떠넘겨야 했다. 다른 하나는 그 분류 자체의 정확도였다. 온톨로지가 쓰는 슬롯 아홉 개를 GLiNER 라벨로 직접 줘봤더니, 숫자값 같은 스팬은 신뢰도 0.98 이상으로 잘 잡는데 "취득세"에 세율 라벨이, 날짜에 target 라벨이 붙는 식으로 역할 분류가 사실상 무작위에 가까웠다. 스팬을 찾는 것과 그 스팬의 역할을 아는 것은 GLiNER 안에서 같은 능력이 아니었다.

이 34%가 처음엔 결함처럼 보였다. 슬롯을 더 요구했더니 소형 모델의 성공률이 떨어진 거니까. 근데 따져보면 당연한 결과다. 슬롯 하나짜리 판단이 아니라 아홉 개 판단을 동시에 하는 일이고, 아홉 개 중 하나만 틀려도 그 프레임 전체가 걸린다.

여기서 채점 방식이 갈렸다. 슬롯마다 따로 점수를 매겼다면 34%라는 숫자는 안 나왔을 것이다. "action은 맞고 target도 맞으니 이 프레임은 통과"처럼 개별 슬롯의 정확도로 판단했을 테니까. 대신 아홉 슬롯을 전부 하나의 가설 문장으로 합쳐서("이 문장은 {condition}인 경우 {target} {action}({amount})... 를 의미한다") 원문과 통째로 대조했다. 슬롯 하나가 지어낸 값이면 문장 전체가 원문과 안 맞아 보이고, 점수가 같이 떨어진다.

실제로 이렇게 잡힌 사례가 있다. qwen3-1.7b는 "~연장" 문구만 보이면 effective_from에 "2026년 1월 1일" 같은 그럴듯한 날짜를 채워 넣었다. 원문 어디에도 없는 값이었다. 이런 할루시네이션 9건이 프레임 단위 채점에서 전부 0.001~0.021로 잡혔다. 원문에 날짜가 실제로 있던 2건은 0.94~0.96으로 정상 통과했다. 34%라는 폴백 비율은 실패율이 아니라, 채점 방식이 실제로 실수를 골라내고 있다는 증거였다.

문지기가 느슨하면 폴백 비율은 낮아 보이지만 지어낸 값이 그냥 통과된다. 문지기가 너무 빡빡하면 맞는 답도 자꾸 걸려서 강한 모델 비용이 다시 올라간다. 스윗스팟은 정확도와 비용 사이 어딘가가 아니라, "이 채점 기준이 실제 실수와 얼마나 겹치는가"에서 정해졌다.


숫자로 보면

같은 56문장, 같은 5개 공고를 방식별로 돌린 결과다. "NLI ≥ 0.5"는 최종 판정이 아니라 다음 단계(사람 검토 또는 온톨로지 등록)로 넘길 만한지 가르는 confidence 기준선이다.

방식추출 개수NLI ≥ 0.5
GLiNER (predicate, object)candidate 99개7개 (문장 기준 12.5%)
SLM 직접 triple, 문장 안 쪼갬78개70개 (89.7%)
SLM 직접 triple, 청크 후72개62개 (86.1%)
SLM 프레임(9슬롯), 청크 후, 폴백 전84개57개 (67.9%)
SLM 프레임, 폴백 후 (최종)90개86개 (95.6%)

GLiNER가 문장 기준 12.5%밖에 못 건진 자리에서, 소형 모델과 NLI 문지기 조합은 폴백을 거쳐 95.6%까지 올라갔다. 폴백에 들어간 시간은 chunk당 10초 안팎, 77개 중 26개면 5분 남짓. 77개 전부를 강한 모델에 태웠으면 걸렸을 시간의 3분의 1이다. 정확도는 오히려 더 높다. 강한 모델 하나로 전부 돌리면 얻는 정확도를 소형 모델과 NLI 문지기의 조합으로 더 적은 비용에 도달한 셈이다.


다음

이 구조(chunk, 소형 모델 추출, NLI 문지기, 낮은 신뢰도만 재작업)는 이제 기본 파이프라인으로 자리 잡았다. 다음엔 이걸로 지방세 개정 공고 몇 건을 훑는 데서 그치지 않고, 세금 관련 정보를 좀 더 구조화해보려 한다.