RAG 답변의 환각 줄이기: 문장별 출처 표시와 자동 검증 스크립트, 그리고 그 한계
출처 없는 문장, 없는 출처 번호, 출처에 없는 숫자를 코드로 걸러내고 남는 문제는 무엇인지

핵심 요약
- RAG의 환각은 검색 실패, 조각 간 충돌, 생성 단계의 빈틈 채우기에서 나온다.
- 답변을 문장 단위로 받고 문장마다 출처 조각 번호를 붙이게 하면 자동 검증이 가능해진다.
- 출처 없는 문장, 없는 출처 번호, 출처에 없는 숫자는 코드로 확실하게 걸러낼 수 있다.
- 같은 숫자를 다른 의미로 쓴 문장은 규칙 검증을 통과하므로, 숫자·비교 문장은 별도 모델로 한 번 더 판정한다.
RAG는 검색한 문서를 근거로 답하게 해 환각을 줄이지만, 없애지는 못한다. 검색이 엉뚱한 조각을 가져오거나, 조각끼리 내용이 다르거나, 모델이 조각 사이의 빈틈을 스스로 채우면 사실이 아닌 문장이 나온다. 이 글은 답변을 문장 단위로 받고 문장마다 출처를 붙이게 한 뒤, 코드로 확인할 수 있는 것부터 자동 검증하는 방법을 정리한다.
환각이 남는 세 가지 경로
| 경로 | 예 | 대응 |
|---|---|---|
| 검색 실패 | 질문과 관계없는 조각이 상위에 온다 | 하이브리드 검색·리랭킹, 검색 결과가 약하면 "모름"으로 답하게 함 |
| 조각 간 충돌 | 버전이 다른 두 문서가 다른 값을 말한다 | 조각에 문서 날짜·버전 메타데이터를 넣고, 충돌 시 둘 다 표시 |
| 생성 단계의 빈틈 채우기 | 조각에 없는 숫자나 원인을 덧붙인다 | 문장별 출처 표시와 검증 |
Anthropic의 환각 줄이기 가이드는 기본 전략으로 "모르면 모른다고 말하게 허용하기", 긴 문서에서는 "직접 인용을 먼저 뽑게 하기", "주장마다 인용과 출처를 달게 하고, 뒷받침하는 인용을 찾지 못한 주장은 철회하게 하기"를 제시한다. 이 글의 검증 스크립트는 마지막 전략을 코드로 확인하는 부분이다.
1단계: 답변을 문장과 출처 번호로 받기
모델에 검색 조각을 c1, c2처럼 번호를 붙여 넘기고, 답변을 아래 형식의 JSON으로 받는다. 형식을 강제하는 방법은 LLM 구조화 출력과 Zod 검증에서 다뤘다.
검색 조각만 근거로 답하라. 조각에 없으면 "제공된 자료에서 찾지 못했다"고 답하라.
답변은 문장 단위로 나누고, 문장마다 근거가 된 조각 번호를 cites에 넣어라.
{"sentences": [{"text": "...", "cites": ["c1"]}]}
2단계: 코드로 확인할 수 있는 것부터 검증
아래 스크립트는 문장마다 세 가지를 확인한다. 출처 번호가 있는지, 그 번호가 실제 조각인지, 문장에 나온 숫자가 인용한 조각에 그대로 있는지. 여기에 문장과 조각의 표현 겹침 비율이 너무 낮은 경우도 표시한다.
// 사용법: node verify-citations.mjs answer.json chunks.json
// 답변 문장마다 붙은 출처 번호가 실제 검색 조각을 가리키는지, 문장의 숫자가 그 조각에 있는지,
// 핵심 표현이 조각과 충분히 겹치는지를 확인한다. 통과하지 못한 문장은 재검색·재생성 대상으로 표시한다.
import { readFileSync } from 'node:fs';
const answer = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const chunks = new Map(JSON.parse(readFileSync(process.argv[3], 'utf8')).map((c) => [c.id, c]));
const numbers = (s) => (s.match(/\d+(?:[.,]\d+)?%?/g) || []).map((n) => n.replace(',', ''));
const grams = (s) => {
const t = s.toLowerCase().replace(/[^0-9a-z가-힣]+/g, '');
return new Set(Array.from({ length: Math.max(0, t.length - 1) }, (_, i) => t.slice(i, i + 2)));
};
const overlap = (a, b) => { const A = grams(a); const B = grams(b); let n = 0; for (const g of A) if (B.has(g)) n++; return A.size ? n / A.size : 0; };
let flagged = 0;
for (const [i, s] of answer.sentences.entries()) {
const issues = [];
if (!s.cites?.length) issues.push('출처 없음');
const cited = (s.cites || []).map((id) => chunks.get(id));
if (cited.some((c) => !c)) issues.push('존재하지 않는 출처 번호');
const evidence = cited.filter(Boolean).map((c) => c.text).join(' ');
if (evidence) {
const missingNums = numbers(s.text).filter((n) => !evidence.replace(/,/g, '').includes(n));
if (missingNums.length) issues.push(`출처에 없는 숫자: ${missingNums.join(', ')}`);
const ov = overlap(s.text, evidence);
if (ov < 0.35) issues.push(`표현 겹침 ${(ov * 100).toFixed(0)}% (기준 35%)`);
}
if (issues.length) flagged++;
console.log(`${issues.length ? 'FLAG' : 'OK '} [${i + 1}] ${s.text}`);
issues.forEach((x) => console.log(` - ${x}`));
}
console.log(`\n문장 ${answer.sentences.length}개 중 확인 필요 ${flagged}개`);
process.exitCode = flagged ? 1 : 0;
검색 조각은 이 사이트의 RAG 설명 글 본문 두 단락을 썼고, 답변은 검증 결과를 보여 주기 위해 맞는 문장과 틀린 문장을 섞어 만든 예시다.
[
{ "id": "c1", "source": "what-is-rag-and-why-it-is-still-needed › 문서를 전부 넣어도 되는 경우",
"text": "Anthropic은 지식 문서가 20만 토큰(약 500쪽)보다 작다면 RAG 없이 전체를 프롬프트에 넣으면 된다고 설명하고, 프롬프트 캐싱을 쓰면 지연 시간이 2배 이상 줄고 비용이 최대 90% 줄어든다고 밝혔다." },
{ "id": "c2", "source": "what-is-rag-and-why-it-is-still-needed › 결과에서 읽을 수 있는 것",
"text": "Contextual Embeddings와 Contextual BM25를 함께 쓰면 검색 실패율이 49%(5.7%→2.9%), 리랭킹까지 더하면 67%(5.7%→1.9%) 줄었다고 보고했다." }
]
$ node verify-citations.mjs answer.json chunks.json
OK [1] 지식 문서가 20만 토큰보다 작다면 RAG 없이 전체를 프롬프트에 넣어도 된다.
OK [2] 프롬프트 캐싱을 쓰면 비용이 최대 90% 줄어든다.
FLAG [3] 리랭킹까지 더하면 검색 실패율이 80% 줄었다.
- 출처에 없는 숫자: 80%
FLAG [4] 임베딩 모델은 매달 다시 학습해야 정확도가 유지된다.
- 출처 없음
FLAG [5] BM25만 써도 검색 실패율이 절반으로 준다.
- 존재하지 않는 출처 번호
OK [6] Contextual BM25만 써도 검색 실패율이 49% 줄었다.
문장 6개 중 확인 필요 3개
규칙 기반 검증이 놓친 문장
3·4·5번 문장은 걸러졌다. 문제는 6번이다. 조각에는 "Contextual Embeddings와 Contextual BM25를 함께 쓰면 49%"라고 되어 있는데, 6번 문장은 같은 숫자를 BM25 하나의 효과처럼 썼다. 숫자도 조각에 있고 표현도 많이 겹치니 스크립트는 통과시켰다. 숫자·출처 번호 확인은 빠르고 확실하지만, 의미가 바뀐 인용은 잡지 못한다.
그래서 검증을 두 층으로 나눈다. 1층은 이 글의 스크립트처럼 비용 없이 확실한 규칙을 확인하고, 통과한 문장 중 숫자나 비교 표현이 있는 문장만 2층에서 별도 모델에 "이 문장이 인용한 조각으로 뒷받침되는가"를 판정하게 한다. 1층에서 걸린 문장은 해당 질문으로 다시 검색하거나, 문장을 지우고 "자료에서 확인하지 못했다"로 바꾼다.
운영 순서
- 검색 조각을 저장할 때 문서 이름, 섹션, 날짜를 메타데이터로 함께 남긴다. 출처 표시는 이 메타데이터로 만든다.
- 답변을 문장·출처 번호 형식으로 받는다.
- 규칙 검증(출처 없음, 없는 번호, 출처에 없는 숫자)을 통과하지 못한 문장은 재검색하거나 지운다.
- 숫자·비교·인과 표현이 있는 문장은 별도 모델로 한 번 더 판정한다.
- 사용자 화면에는 문장 끝에 출처 링크를 보여 주고, 검증에서 지운 문장 수를 로그로 남겨 추이를 본다.
검색 품질 자체를 높이는 방법은 하이브리드 검색과 리랭킹, 사용자마다 볼 수 있는 문서가 다를 때는 사내 RAG 접근 제어를 참고한다.
초보자가 자주 실수하는 포인트
- 답변 끝에 출처 목록만 붙여 어떤 문장이 어떤 자료에 근거했는지 알 수 없다.
- "모르면 모른다고 답하라"는 지시를 넣지 않아 근거가 없어도 답을 만들게 둔다.
- 숫자가 출처에 있으면 문장 전체가 맞다고 판단한다.
- 검색 조각에 문서 날짜·버전을 남기지 않아 충돌을 구분하지 못한다.
체크리스트
- 답변이 문장과 출처 번호 형식으로 나온다.
- 출처 없음·없는 번호·출처에 없는 숫자를 자동으로 검사한다.
- 숫자·비교 표현 문장은 2차 판정을 거친다.
- 검증에서 걸린 문장은 재검색하거나 지운다.
- 검색 조각에 문서 이름·섹션·날짜 메타데이터가 있다.
자주 묻는 질문
모든 문장을 LLM 판정에 넘기면 더 정확하지 않나요?
정확도는 높아질 수 있지만 문장 수만큼 호출 비용과 지연이 늘어납니다. 코드로 확실히 걸러지는 것은 먼저 거르고, 숫자·비교처럼 틀리면 영향이 큰 문장만 넘기면 비용을 줄일 수 있습니다.
표현 겹침 기준 35%는 어떻게 정했나요?
이 예제의 문장과 조각에서 맞는 문장이 통과하고 무관한 문장이 걸리도록 정한 값입니다. 서비스의 문장 길이와 도메인 용어에 따라 달라지므로, 평가셋으로 기준을 다시 정해야 합니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.