스크레이퍼가 깔끔한 행을 반환했습니다. 하지만 그것은 틀렸습니다.
요약
LLM을 이용한 데이터 추출 시 스키마 검증(Schema Check)만으로는 모델의 환각(Hallucination)을 막을 수 없음을 경고합니다. 데이터의 형식(Form)이 완벽하더라도 값(Value) 자체가 틀릴 수 있으므로, 값 수준의 검증 게이트(Value-level sanity gate) 도입이 필수적입니다.
핵심 포인트
- 스키마 체크는 데이터의 형태만 검증하며 내용의 진위는 보장하지 않음
- 구조화된 출력 모드는 유효한 JSON을 보장하지만 환각을 해결하지 못함
- 범위, 날짜, 필드 간 교차 검증 등 값 수준의 검증 로직이 필요함
- 형태적 오류(Source Drift)와 의미론적 오류(Hallucination)를 구분해야 함
그 행은 완벽해 보였습니다. rating: 7. 유효한 JSON, 올바른 타입, null 값 없음, 누락된 키 없음. 나의 스키마 체크 (schema check)는 이를 통과시켰습니다. 페이지는 HTTP 200을 반환했습니다. 셀렉터 (selectors)는 움직이지 않았습니다. 모든 것이 정상(green)이었습니다.
5점 만점 사이트에서 별점 7점은 불가능합니다. 모델 (model)이 이를 지어냈고, 올바르게 형식을 맞추었으며, 매우 확신에 찬 태도로 나에게 전달했습니다.
이것이 제가 이야기하고 싶은 실패 사례입니다. 요란하게 고장 나는 스크레이퍼 (scraper)가 아닙니다. 조용하고 그럴싸하게 틀린, 깔끔해 보이는 행을 건네주는 스크레이퍼 말입니다. 당신의 모든 체크 (check)를 통과해 버리는데, 그 이유는 당신의 체크가 모두 데이터의 '형태 (shape)'만을 보고 있고, 거짓은 '값 (value)'에 있기 때문입니다.
요약 (TL;DR)
- HTTP 200, 온전한 셀렉터 (selectors), 유효한 JSON은 **형식 (form)**이 괜찮다는 것을 알려줍니다. 하지만 **값 (value)**이 사실인지에 대해서는 아무것도 말해주지 않습니다.
- LLM이 지저분한 자유 형식 텍스트 (free-text)에서 정보를 추출할 때, 구조화된 출력 (structured-output) 모드는 유효한 JSON을 받는 것을 보장합니다. 하지만 콘텐츠가 실제라는 것을 보장하지는 않습니다. 모델은 불확실한 필드를 비워두기보다 채워 넣습니다. 스키마 (schema)가 완전한 행을 요구하기 때문입니다.
- 약 60줄 규모의 값 수준 검증 게이트 (value-level sanity gate) (범위, 날짜, 필드 간 교차 검증, 참조, 언어)는 명백한 거짓이 데이터베이스에 도달하기 _전_에 잡아냅니다. 아래에 실제 코드와 실제 출력을 제시합니다.
- 솔직한 한계: 이 게이트는 **규칙 위반 (rule violations)**을 잡아내지만, 허용된 범위 내의 그럴싸한 거짓말은 잡아내지 못합니다. 실제 값이
2인데rating: 4인 경우는 그대로 통과됩니다. 게이트가 어디에서 멈추는지 구체적으로 설명하겠습니다.
스크레이퍼가 당신에게 거짓말을 하는 두 가지 다른 방식
지난주에 소스 드리프트 (source drift)에 대해 썼습니다. 이는 _페이지_가 당신도 모르게 변경되어 30줄짜리 스키마 체크 (schema check)가 구조 변화를 잡아내는 경우를 말합니다. 그것은 입력 (input) 문제입니다. 소스가 변이되었고, 페이지와의 약속이 깨졌으며, 당신은 형태를 관찰함으로써 이를 감지합니다.
이것은 파이프의 반대쪽 끝입니다. 소스는 괜찮습니다. 페이지는 온전하고, 셀렉터 (selectors)는 정확하며, 구조는 당신이 예상한 그대로입니다. 당신에게 거짓말을 한 것은 바로 추출 (extraction) 단계에서 인간의 산문 한 단락으로부터 구조화된 필드 (structured fields)를 뽑아내라고 요청했을 때의 **모델 (model)**입니다.
이 두 가지 실패는 비슷하게 느껴지지만 실제로는 다릅니다. 하나는 "페이지의 문법 (grammar)이 변했다"는 것이고, 다른 하나는 "문법은 동일하지만 사실 (fact)이 틀렸다"는 것입니다. 스키마 체크 (schema check)는 첫 번째 상황을 위해 만들어졌으며, 두 번째 상황에는 무용지물입니다. 저는 구조적으로는 결함이 없지만 의미론적 (semantically)으로는 쓰레기인 행을 스키마 체크가 통과(green)했다는 사실만 믿고 배포했다가, 아주 골치 아픈 방식으로 이 사실을 배웠습니다.
구조화된 출력 (structured output)이 상황을 개선하는 것이 아니라 악화시키는 이유
여기서 저를 놀라게 한 부분이 있습니다. response_format: json_schema (또는 Bedrock의 도구 결과 스키마, 혹은 당신의 스택에서 부르는 무엇이든)를 활성화하면 환각 (hallucination) 문제를 "해결"할 수 있을 것처럼 느껴집니다. 하지만 값의 정확성 (value correctness) 측면에서는 정반대의 결과를 초래합니다.
Paul SANTUS는 5월 29일 자신의 Dev.to 게시물("LLMs suck at generating large, structured data")에서 이를 깔끔하게 설명했습니다. 구조화된 출력 모드는 "구문 (syntax)에는 도움이 됩니다. 즉, 유효한 JSON을 얻게 될 것입니다. 하지만 의미론적 (semantic)인 문제는 해결하지 못합니다." 그리고 실제 페이지에서 제가 목격하는 것과 정확히 일치하는 핵심적인 지점은 다음과 같습니다: "모델은 한 번에 (one shot) 구조화된 출력을 생성할 때 환각을 일으킬 가능성이 더 높습니다. 구조가 완결성을 요구하기 때문에, 모델은 불확실한 필드를 비워두기보다는 임의로 채워 넣습니다."
이 문장을 다시 읽어보십시오. 이 한 문장이 이 글 전체의 핵심입니다. 스키마는 완전한 행을 요구합니다. 그래서 모델이 평점 (rating)이 무엇인지 확신하지 못할 때, null을 반환하지 않습니다. null은 실패처럼 느껴지기 때문입니다. 대신 모델은 숫자를 반환합니다. 자신감 있고, 형식이 잘 갖춰졌으며, 제 위치에 딱 들어맞는 숫자 말입니다. 때때로 그 숫자는 7일 수도 있습니다.
스키마는 요청한 것을 받았습니다: 완전하고 유효한 객체 (object)를 말이죠. 다만 그 안에 조작된 값이 들어있었을 뿐입니다.
이보다 훨씬 더 고약한 하류(downstream) 버전도 존재합니다. israelhen153이 5월 26일에 작성한 글의 제목은 말 그대로 "Sonnet이 환각(hallucination)을 일으켰습니다. 내 에이전트가 그것을 사실로 저장했습니다."였습니다. 그의 메모리 레이어(memory layer)는 모델의 잘못된 부정(denial)을 받아들여 이를 추출한 뒤, 검증 과정 없이 SQLite 테이블에 [fact] 태그를 붙여 저장했습니다. 그러자 에이전트는 이후 세션에서 그 거짓 주장을 반복했습니다. 이는 제 스크레이퍼 버그와 동일한 형태입니다. 모델이 구조적 확신을 가지고 사실이 아닌 것을 말했고, 멍청한 하류 레이어가 그것을 진실로 기록해 버린 것입니다. 그 값이 영구적인 기록이 되기 전, 경계(boundary)에서 아무도 그 _값(value)_을 검증하지 않았습니다.
내 수치가 어디서 오는지 (그리고 어디서 오지 않는지)
저는 운영 환경(production)에서 스크레이퍼를 실행합니다. 제 Apify 프로필에 있는 Trustpilot 스크레이퍼는 962회의 실행 기록이 있으며, 제 모든 액터(actors)를 통틀면 평생 실행 횟수는 약 2,190회에 달합니다. 이는 실험실이 아닌 실제 활용 트래픽입니다.
여기서 주의를 기울이고 싶습니다. 왜냐하면 이곳이 바로 데이터를 조작하고 싶은 유혹이 드는 지점이기 때문입니다. 이것은 HTTP 스크레이핑이지, 통제된 LLM 벤치마크가 아닙니다. 저는 "412번째 실행에서 모델이 업체 X에 대해 rating=7을 반환했다"라고 명시된 날짜가 찍힌 로그를 가지고 있지 않습니다. 저는 추출 단계에 대해 깨끗한 전/후 실험(before/after experiment)을 도구화(instrument)한 적이 없으므로, 여러분에게 적중률(hit-rate) 수치를 제공할 수 있는 척하지 않겠습니다. 통제된 설정 없이 운영 환경의 스크레이핑 데이터만으로 "LLM이 추출된 필드의 N%를 환각한다"라는 정밀한 수치를 제시하는 사람은 소수점 단위로 추측을 하고 있는 것입니다.
제가 대량의 데이터를 통해 얻은 것은 침묵하는 오염(silent corruption)의 **유형(classes)**에 대해 이야기할 권리입니다. 왜냐하면 저는 이 모든 유형의 뒤처리를 직접 손으로 해왔기 때문입니다. 아래의 예시들은 이러한 실패 유형들을 설명하기 위해 구성된 것이지, 실제 사건을 그대로 옮겨 적은 것이 아닙니다. 각 사례의 형태는 실제와 같으며, 특정 행(row)은 규칙을 명확히 하기 위해 제가 직접 작성한 것입니다.
제가 계속해서 맞닥뜨리는 유형들은 다음과 같습니다:
- 1~5 범위를 벗어난 평점 (rating outside 1–5). 모델이 페이지의 잘못된 부분에서 숫자를 읽었거나, 숫자를 지어냈습니다.
- 미래의 리뷰 날짜 (review date in the future). 자유 형식의 날짜(Free-text dates)는 늪과 같습니다 ("지난 화요일", "vor 3 Tagen", "2 days ago"). 이를 정규화(normalizing)하는 모델은 가끔 아직 오지 않은 날짜를 생성하곤 합니다.
- 근거 없는
verified플래그의 true 설정. 페이지 어딘가에 "verified"라는 단어가 나타났고, 모델은 그것이 리뷰가 인증되었다는 의미라고 판단했습니다. - 스크레이핑된 수치(Scraped count)가 표시된 수치와 크게 불일치함. 페이지에는 리뷰가 40개라고 되어 있는데, 추출된 객체(object)는 500개라고 주장합니다. 둘 중 하나는 허구입니다.
- 국가는 US라고 되어 있는데, 리뷰 텍스트는 명백히 독일어임. 모델이 잘못된 블록에서 로케일(locale) 필드를 복사했습니다.
이 모든 것들은 유효한 JSON입니다. 모든 것이 스키마 체크(schema check)를 통과합니다. 하지만 모든 것이 틀렸습니다.
해결책: 형태(shape)가 아니라 값(value)을 확인하라
저렴하고, 지루하며, 효과적인 방법은 파싱(parsing)이 끝난 _후_에, 그리고 데이터베이스에 쓰기(writing) _전_에 실행되는 두 번째 게이트(gate)를 두는 것입니다. 이 게이트는 "이것이 올바른 키(key)와 타입(type)을 가진 완전한 객체인가?"라고 묻지 않습니다. 그 작업은 이미 스키마 체크가 수행했습니다. 대신 이 게이트는 "이 값이 주장하는 바에 비추어 볼 때 그럴듯한가(plausible)?"라고 묻습니다.
표준 라이브러리(stdlib)만 사용합니다. json, re, datetime. 모델 호출도, 네트워크도, API 키도 필요 없습니다. 오늘 오후에 바로 적용할 수 있는 종류의 작업입니다. 핵심 코드는 다음과 같습니다. 규칙을 먼저 붙여넣은 뒤, 실행 시 나오는 실제 출력값을 보여드리겠습니다.
import re
from datetime import date
...
언어 체크는 가장 조잡한 방식이며, 의도적으로 조잡하게 남겨두었습니다. 실제 언어 탐지기(language detector)가 아니라 아주 작은 단어 빈도 힌트(word-frequency hint)일 뿐입니다. 이는 "국가는 US인데 텍스트는 명백히 독일어인" 경우를 잡아내기 위한 용도이며, 그 이상의 복잡한 기능은 없습니다.
_LANG_HINTS = {
"de": (" der ", " und ", " nicht ", " ich ", " sehr "),
"en": (" the ", " and ", " not ", " very ", " with "),
...
그리고 이 모든 규칙을 실행하는 게이트입니다:
RULES = (_rating_in_range, _date_not_in_future,
_verified_flag_consistent, _counts_agree, _language_matches_country)
...
그게 전부입니다. 빈 리스트는 해당 행이 이 규칙들에 따르면 그럴듯하다는 것을 의미합니다. 이 점을 유념해 두세요. 이 과정은 많은 일을 수행하고 있으며, 이 방식이 얼마나 많은 일을 수행하지 못하는지에 대해서는 나중에 다시 다루겠습니다.
실행하기
저는 하나의 '정상(GOOD)' 행(모든 것이 합리적인 상태)과 하나의 '잘못된(BAD)' 행을 만들었습니다. 잘못된 행은 모든 필드가 올바른 타입(type)을 가지고 유효한 JSON으로 파싱되지만, 모든 값이 모델이 결코 반환해서는 안 되는 값들로 구성되어 있습니다. 그런 다음 파일을 실행했습니다. 출력물에 어떠한 수정도 가하지 않았습니다. 다음은 Python 3.13 환경에서 표준 라이브러리(stdlib)만 사용하여 python3 field_sanity.py를 실행했을 때 출력된 결과입니다:
GOOD: CLEAN []
BAD: [
[
...
정상 행은 깨끗하게(clean) 반환됩니다. 반면, 스키마(schema)를 통과하며 그럴듯해 보이는 다섯 개의 필드를 가진 잘못된 행은 다섯 개의 구체적인 값 위반(value violations) 리스트로 반환됩니다. 각각의 위반 사항은 로그를 남기거나, 경고를 보내거나, 격리하거나, 쓰기를 거부할 수 있는 항목들입니다. 키(key)가 존재하는지 확인하고 타입(type)이 맞는지 확인하는 것만으로는 이 중 그 어느 것도 잡아낼 수 없었을 것입니다.
이 로직은 Paul SANTUS가 경계(boundary)라고 부른 바로 그 지점, 즉 모델이 객체를 생성한 직후이자 데이터베이스에 닿기 전 단계에 연결합니다.
row = json.loads(model_output) # 여기서 스키마/형태(schema/shape) 확인이 이루어짐
problems = sanity_violations(row) # 여기서 값(value) 확인이 이루어짐
if problems:
...
그가 말했듯이, 경계에서의 검증(Validation)은 데이터를 전혀 손실시키지 않습니다. 거부된 행은 허공으로 사라지는 것이 아니라, 위반 리스트가 첨부된 채 격리 테이블(quarantine table)로 이동합니다. 그것이 실제 예외 케이스(edge case)였는지 아니면 환각(hallucination)이었는지는 나중에 결정하면 됩니다. 여러분이 하지 말아야 할 일은 그것이 [fact]가 되도록 방치하는 것입니다.
이것이 잡아내지 못하는 것 (신뢰하기 전에 반드시 읽으십시오)
이제 솔직한 이야기를 해보겠습니다. 과도하게 신뢰하는 문(gate)은 문이 없는 것보다 더 위험하기 때문입니다.
이것은 규칙 위반을 잡아냅니다. 하지만 허용된 범위 내에 존재하는 그럴듯한 거짓말은 잡아내지 못합니다. 만약 실제 평점이 2인데 모델이 4를 반환했다면, 저의 검사 로직은 4 ∈ [1,5]를 확인하고 미소를 지으며 통과시켜 버립니다. 그것이 진정으로 무서운 실패입니다. — 확신에 차서 틀렸으면서도, 범위 안에는 완벽하게 들어와 있는 상태 말입니다. — 그리고 범위 검사(range check)는 이에 대해 무용지물입니다. 이를 해결할 저렴한 표준 라이브러리(stdlib) 트릭은 없습니다. 참조 데이터(reference data)를 사용하거나, 두 번째 모델로 재추출(re-extraction)하거나, 샘플링 및 인간 검토(sampling-and-human-review) 영역으로 넘어가야 하는데, 이 모든 것에는 실제 비용이 발생합니다.
규칙은 수동으로 작성되며 도메인 특화적(domain-specific)입니다. "rating ∈ [1,5]"는 리뷰 스크레이퍼(review-scraper) 규칙입니다. 이는 가격 필드나 재고 수량에는 아무런 의미가 없습니다. 여러분은 도메인별로 자신만의 규칙을 작성하게 될 것이며, 첫 번째 시도에서는 임계값(thresholds) 중 일부를 틀리게 설정할 것입니다. 저의 scraped > shown * 2 + 10은 "터무니없이 불일치함"을 추측한 값입니다. 여러분의 데이터에 맞춰 조정하지 않으면, 정당하게 빠르게 성장하는 페이지에서 오탐(false-positive)이 발생할 것입니다.
언어 검사는 장난감 수준입니다. 언어당 5개의 불용어(stopwords)만 사용합니다. 영어를 기대했는데 독일어가 나온 경우는 잡아내겠지만, 그보다 미묘한 차이는 완전히 놓칠 것입니다. 제가 이를 조잡하게 남겨둔 이유는, 이 기능이 처리해야 할 단 하나의 케이스를 위해 더 무거운 의존성(dependency)을 추가할 가치가 없었기 때문입니다. 하지만 이를 언어 감지(language detection)로 오해하지 마십시오. 실제 감지가 필요하다면 라이브러리를 가져오고 그 무게를 감수하십시오.
이것은 필드별(per-field)로 작동합니다. 이 방식은 _리뷰 전체_가 말이 되는지는 알지 못합니다. 한 행(row)이 모든 규칙을 통과하더라도, 존재하지 않는 제품에 대한 조작된 리뷰일 수 있습니다. 필드별 개연성(plausibility)은 천장이 아니라 바닥(최소한의 기준)입니다.
결론적으로: 이것은 조용한 데이터 오염(silent corruption)의 한 부류를 시끄럽고 기록이 남는 거부(logged rejection)로 바꿔주는, 저렴하면서도 가치 높은 첫 번째 방어선입니다. 이것은 진실을 알려주는 신탁(truth oracle)이 아닙니다. 표준 라이브러리 검증기(stdlib validator)가 LLM 추출을 "안전"하게 만든다고 말하는 사람은, 모델이 rating: 7로 여러분에게 주었던 것과 똑같은 거짓된 확신을 여러분에게 팔고 있는 것입니다.
실제로 중요한 변화
여기서 필요한 사고의 전환은 작지만, 제가 추출(extraction) 결과물을 배포하는 방식을 바꾸어 놓았습니다. HTTP 200은 요청이 성공했음을 의미합니다. 유효한 JSON (Valid JSON)은 응답이 형식에 맞게 구성되었음을 의미합니다. 이 중 어느 것도 데이터가 사실임을 의미하지는 않습니다. 이것들은 세 가지 별개의 주장이며, 저는 예전에 이들을 하나의 '그린 라이트(성공 신호)'로 뭉뚱그려 생각하곤 했습니다.
필수 필드를 채워야 하는 모델은 어떻게든 채워 넣을 것입니다. 안전을 위해 추가한 구조는 모델이 내용을 지어내도록 압박하는 것과 동일한 구조가 됩니다. 따라서 경계 지점에 저렴한 검문소를 하나 더 두어야 합니다. 단순히 형태(shape)만 확인하는 것이 아니라 값(value)을 확인하고, '확신에 차 있지만 틀린' 행들이 영구적인 기록이 되지 않도록 막아야 합니다. 모든 것을 잡아낼 수는 없을 것입니다. 하지만 하류(downstream)의 모든 이들이 그것을 사실로 취급하며 데이터베이스에 저장하기 전에, 당혹스러운 오류나 '불가능한 평점 7점' 같은 오류들을 잡아낼 수 있을 것입니다.
제가 여전히 고민 중인 어려운 질문은 이것입니다: 두 번째 추출 비용을 지불하거나 인간 참여(human in the loop)를 거치지 않고, 어떻게 하면 '범위 내에 있는(in-range)' 거짓말 — 즉, 2가 되어야 할 것이 4로 들어오는 경우 — 을 잡아낼 수 있을까요? 저는 아직 저렴한 해답을 찾지 못했습니다. 만약 여러분이 실제 운영 환경(production)에서 견딜 수 있는 방법을 찾았다면, 저는 진심으로 그것을 읽어보고 싶습니다.
다음 배치 운영 결과의 수치들을 확인하려면 팔로우해 주세요. 그리고 스크레이퍼가 여러분에게 전달했던, 그럴듯하지만 틀린 최악의 필드 사례가 있다면 알려주세요. 모든 댓글을 읽고 있습니다.
AI의 도움을 받아 작성되었습니다. 모든 코드는 Python 3.13(표준 라이브러리만 사용) 환경에서 로컬로 실행되었으며, 위의 출력값은 편집되지 않은 실제 결과입니다. 운영 수치(962 / 2,190회 실행)는 통제된 LLM 벤치마크가 아닌 HTTP 스크레이핑 문맥에서의 결과이며, 실패한 행들은 제가 사후에 정리한 오류 유형들을 구성한 예시일 뿐, 특정 시점의 사고 로그가 아닙니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기