- Published on
RAG 환각을 줄이는 JSON Schema 강제 출력법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론에서 결론부터 말하면, RAG(Retrieval-Augmented Generation)의 환각(hallucination)은 검색(retrieval) 문제만이 아니라 생성(generation) 단계의 출력 제약 부재에서 크게 증폭됩니다. 검색 결과가 충분히 좋아도 모델이 “그럴듯한 문장”을 만들어내는 순간, 사실 검증이 어려운 자연어 응답은 곧바로 운영 리스크가 됩니다. 특히 고객지원/정책/보안/정산처럼 “정확한 필드”와 “근거 링크/문서 위치”가 필요한 도메인에서는, 자연어 위주의 응답이 오류를 숨긴 채 배포되는 일이 잦습니다.
이 글은 RAG 파이프라인에서 환각을 줄이기 위한 실전 패턴인 JSON Schema 강제 출력(Structured Output) 을 다룹니다. 핵심은 다음 3가지입니다.
- 모델이 반드시 특정 JSON 구조만 생성하도록 강제한다.
- 스키마 검증 실패 시 자동 재시도/수정 루프를 둔다.
- 근거(citation) 필드를 스키마에 포함해 “근거 없는 주장”을 구조적으로 차단한다.
추가로 운영 환경에서 자주 부딪히는 오류(예: 모델/엔드포인트 설정)도 함께 언급합니다. 모델 호출 단계에서 403이 나면 구조화 이전에 파이프라인이 무너질 수 있으니, 필요하면 OpenAI Responses API 403 model_not_found 해결 가이드도 같이 확인하세요.
RAG에서 환각이 발생하는 지점
RAG는 보통 아래 흐름으로 구성됩니다.
- 사용자 질문 수신
- 쿼리 재작성(optional)
- 벡터 검색/키워드 검색
- 컨텍스트(문서 조각) 구성
- LLM 생성
환각은 (4)와 (5)에서 자주 커집니다.
- 컨텍스트가 길어지면 모델은 중요 근거를 놓치고 빈칸을 상상으로 채웁니다.
- 컨텍스트가 부족하면 모델은 일반 상식/패턴으로 답을 만들어냅니다.
- 컨텍스트가 있어도, 자연어 응답은 검증하기 어렵고, 잘못된 문장을 “그럴듯하게” 섞기 쉽습니다.
따라서 “정답을 더 잘 찾자”만으로는 부족하고, “답을 검증 가능한 형태로만 내게 만들자”가 필요합니다.
JSON Schema 강제 출력이 환각을 줄이는 이유
JSON Schema 강제 출력은 단순히 파싱 편의를 위한 기능이 아닙니다. 환각 억제 관점에서 특히 유효한 이유는 다음과 같습니다.
1) ‘말’이 아니라 ‘필드’로 답하게 만든다
자연어는 모델이 수사를 섞어 오류를 숨기기 쉽습니다. 반면 스키마는 “답을 어디에 넣어야 하는지”를 강제합니다.
answer: 최종 답변(짧고 명확)citations: 근거(문서 id, 위치, 스니펫)confidence: 확신도(정책적으로 낮으면 보수적으로 응답)missing_info: 부족한 정보(추가 질문 유도)
이렇게 필드를 나누면, 모델이 근거 없이 단정하는 문장을 길게 늘어놓기 어렵습니다.
2) 검증/재시도가 쉬워진다
스키마 검증이 실패하면 “틀린 출력”을 자동으로 감지할 수 있습니다.
- JSON 파싱 실패
- 필수 필드 누락
- 타입 불일치
- enum 위반
- citations 배열 비어있음(정책적으로 금지)
검증 실패를 재시도 트리거로 쓰면 운영 안정성이 크게 올라갑니다.
3) 근거 인용을 스키마로 강제하면 ‘근거 없는 주장’을 구조적으로 차단한다
RAG의 목적은 “검색된 근거를 바탕으로 답한다”입니다. 그런데 자연어 응답은 근거가 없어도 그럴듯하게 보입니다.
스키마에 citations를 필수로 두고, 각 citation에 doc_id, chunk_id, quote, score 등을 요구하면 모델은 근거 필드가 비어있지 않도록 컨텍스트를 참조하려는 압력을 받습니다.
설계 원칙: 스키마는 ‘운영 정책’을 담아야 한다
스키마는 단순 데이터 구조가 아니라, “이 시스템이 허용하는 답변 방식”을 표현해야 합니다.
권장 필드 구성
final_answer: 사용자에게 보여줄 답citations: 근거 목록(최소 1개 이상)assumptions: 모델이 가정한 전제(가정을 드러내면 환각이 줄어듦)missing_info: 답변에 필요한데 컨텍스트에 없는 정보safety: 민감정보/정책 위반 여부
환각 억제를 위한 제약 예시
citations.minItems = 1final_answer.maxLength제한(장황한 환각 억제)confidence는low|medium|highenum으로 제한missing_info가 비어있지 않으면final_answer는 “추가 질문” 형태로 유도
예시 JSON Schema: “근거 필수 + 불확실성 표기”
아래는 RAG 응답을 강제하기 위한 스키마 예시입니다.
{
"name": "rag_answer",
"schema": {
"type": "object",
"additionalProperties": false,
"required": ["final_answer", "citations", "confidence"],
"properties": {
"final_answer": {
"type": "string",
"minLength": 1,
"maxLength": 800
},
"confidence": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"citations": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["doc_id", "chunk_id", "quote"],
"properties": {
"doc_id": { "type": "string" },
"chunk_id": { "type": "string" },
"quote": { "type": "string", "minLength": 1 },
"url": { "type": "string" },
"score": { "type": "number" }
}
}
},
"missing_info": {
"type": "array",
"items": { "type": "string" }
},
"assumptions": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
포인트는 additionalProperties: false와 citations.minItems: 1입니다.
additionalProperties: false는 모델이 임의 필드를 만들어 “설명”을 덧붙이는 것을 줄입니다.citations를 필수 + 최소 1개로 강제하면, 컨텍스트 기반 답변을 유도합니다.
OpenAI Responses API로 JSON Schema 강제 출력하기
아래는 Responses API에서 구조화 출력(스키마)을 사용하는 형태의 예시 코드입니다. (SDK 버전에 따라 파라미터 명이 달라질 수 있으니, 사용 중인 SDK 문서를 확인하세요.)
from openai import OpenAI
client = OpenAI()
schema = {
"name": "rag_answer",
"schema": {
"type": "object",
"additionalProperties": False,
"required": ["final_answer", "citations", "confidence"],
"properties": {
"final_answer": {"type": "string", "minLength": 1, "maxLength": 800},
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
"citations": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": False,
"required": ["doc_id", "chunk_id", "quote"],
"properties": {
"doc_id": {"type": "string"},
"chunk_id": {"type": "string"},
"quote": {"type": "string", "minLength": 1},
"url": {"type": "string"},
"score": {"type": "number"}
},
},
},
"missing_info": {"type": "array", "items": {"type": "string"}},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
},
}
# retrieval로 뽑은 컨텍스트(예: 상위 5개 chunk)를 문자열로 합쳐 전달
context = """
[doc_id=A, chunk_id=3] ...
[doc_id=B, chunk_id=1] ...
"""
question = "환불 정책에서 무료 체험 사용자는 결제 취소가 가능한가?"
resp = client.responses.create(
model="gpt-4.1-mini",
input=[
{
"role": "system",
"content": (
"You are a RAG assistant. Use ONLY the provided context. "
"If the context is insufficient, set confidence=low and fill missing_info. "
"Citations must quote exact text from context."
),
},
{
"role": "user",
"content": f"Question: {question}\n\nContext:\n{context}",
},
],
text={
"format": {
"type": "json_schema",
"json_schema": schema
}
},
)
# SDK에 따라 resp.output_text 또는 resp.output[0].content 등 접근 방식이 다릅니다.
print(resp.output_text)
프롬프트 팁: 스키마만으로 부족한 부분을 문장으로 보강
스키마는 구조를 강제하지만, “어떤 문장을 넣어야 하는지”까지 완벽히 통제하진 못합니다. 아래 지침을 시스템 프롬프트에 추가하면 환각이 더 줄어듭니다.
- “컨텍스트에 없는 내용은 답하지 말 것”
- “인용(quote)은 컨텍스트에서 그대로 복사할 것”
- “확신 낮으면 missing_info에 질문을 적고 final_answer는 단정하지 말 것”
검증-재시도 루프: 스키마는 ‘게이트’가 되어야 한다
현업에서는 “대부분 맞는” 출력보다 “항상 검증 가능한” 출력이 중요합니다. 따라서 아래처럼 스키마 검증 → 실패 시 재시도를 구성합니다.
import json
from jsonschema import validate, ValidationError
RAG_SCHEMA = schema["schema"]
def call_llm():
# 위 Responses API 호출 함수라고 가정
...
def get_structured_answer(max_retries=2):
last_err = None
for _ in range(max_retries + 1):
raw = call_llm()
try:
data = json.loads(raw)
validate(instance=data, schema=RAG_SCHEMA)
# 운영 정책 추가 검증: citations quote가 실제 context에 존재하는지 등
# (여기서 추가로 fail 시 재시도)
return data
except (json.JSONDecodeError, ValidationError) as e:
last_err = e
# 재시도 시에는 “이전 출력이 스키마를 위반했다”는 피드백을 함께 제공하면 개선됨
continue
raise RuntimeError(f"Structured output failed: {last_err}")
여기서 한 단계 더 나아가면, citation quote가 실제 컨텍스트에 포함되는지를 문자열 매칭으로 검증할 수 있습니다. 이 검증이 매우 강력한 환각 억제 장치가 됩니다.
RAG 전용 스키마 패턴 3가지
1) 답변 타입을 enum으로 제한하기
질문 유형이 정해져 있다면 answer_type을 두고 enum으로 제한하세요.
"answer_type": "policy" | "pricing" | "troubleshooting" | "unknown"
모델이 “문학적 답변”을 할 공간이 줄고, 후처리 라우팅도 쉬워집니다.
2) “모르면 모른다”를 필드로 강제하기
자연어에서 “모르겠다”는 말은 잘 안 나옵니다. 대신 필드로 강제합니다.
confidence=low이면missing_info.minItems=1을 요구final_answer는 “추가 확인이 필요합니다” 템플릿으로 제한(정책적으로)
3) 인용을 문서 좌표 기반으로 강제하기
가능하면 citations에 doc_id/chunk_id뿐 아니라 start_offset/end_offset 같은 좌표를 넣으세요. 추후 UI에서 하이라이팅하거나 감사 로그로 남기기 좋습니다.
운영에서 자주 겪는 실패 모드와 대응
모델 호출 자체가 불안정할 때
구조화 출력 이전에 API 호출이 실패하면 전체 파이프라인이 흔들립니다. 특히 배포 환경에서 모델 이름/권한/리전 설정 문제로 403이 날 수 있습니다. 이 경우는 애플리케이션 로직의 문제가 아니라 설정 문제일 가능성이 높으니, 앞서 언급한 OpenAI Responses API 403 model_not_found 해결 가이드를 참고해 빠르게 원인을 좁히는 게 좋습니다.
재시도 폭주와 비용 증가
스키마 강제 + 검증은 재시도를 유발할 수 있습니다. 해결책은 다음입니다.
- 스키마를 과도하게 복잡하게 만들지 않기(필드 최소화)
max_retries를 1~2로 제한- 실패 시 fallback: “추가 정보 요청” 템플릿 반환
컨텍스트 자체가 빈약한데 citations를 강제하면?
이 경우 모델이 억지 인용을 만들 위험이 있습니다. 그래서 retrieval 단계 품질 보장이 전제입니다.
- top-k를 늘리되, chunk 중복 제거
- 문서 최신성/권한 필터링
- query rewrite로 검색 recall 개선
다만 이 글의 초점은 retrieval이 아니라 generation 억제이므로, 여기서는 “컨텍스트가 부족하면 low confidence + missing_info”로 안전하게 빠지는 설계를 권장합니다.
결론: JSON Schema는 RAG의 ‘환각 방지 안전벨트’다
RAG에서 환각을 줄이는 가장 실용적인 방법 중 하나는 JSON Schema 강제 출력 + 검증 게이트 + 근거 인용 필수화입니다. 검색 품질을 올리는 노력은 계속 필요하지만, 운영 환경에서 문제를 크게 줄이는 지점은 결국 “모델이 어떤 형태로 답할 수 있는가”를 통제하는 데 있습니다.
정리하면 다음 체크리스트로 시작하면 됩니다.
-
additionalProperties: false로 임의 필드 차단 - citations를 필수 + 최소 1개로 강제
- confidence/answer_type을 enum으로 제한
- 스키마 검증 실패 시 1~2회 재시도
- citation quote가 실제 컨텍스트에 존재하는지 추가 검증
- 컨텍스트 부족 시 low confidence + missing_info로 안전 종료
이 패턴을 적용하면, “그럴듯한 답변”이 아니라 “검증 가능한 답변”을 기본값으로 만들 수 있고, 그 자체가 RAG 환각을 체계적으로 줄이는 가장 큰 레버가 됩니다.