환각을 없애기보다 검증 경로를 설계하기

환각을 없애기보다 검증 경로를 설계하기

한눈에 보기

LLM이 사실과 다른 문장을 만들 가능성을 완전히 없애기는 어렵다. 운영 시스템의 목표는 모델이 절대 틀리지 않게 만드는 것보다 틀릴 수 있는 주장을 식별하고, 알맞은 원천으로 검증하며, 검증할 수 없을 때 답변을 제한하는 것이어야 한다. 이 글에서는 주장 분해, 근거 연결, 최신성 검사, 모순 판정, 답변 보류를 하나의 검증 경로로 설계한다.

목차

문장이 자연스럽다는 것은 검증됐다는 뜻이 아니다

LLM의 잘못된 답변은 문법이 정확하고 흐름도 자연스러우며 확신에 찬 표현을 사용하기 때문에 위험하다. 모델이 “확실합니다”라고 말하는 것과 신뢰할 수 있는 데이터로 확인했다는 것은 전혀 다른 사건이다.

배송 상담 에이전트가 이렇게 답했다고 해 보자.

주문은 이미 출고되었고 오늘 오후 6시 이전에 도착할 예정입니다.

짧은 문장이지만 네 가지 주장이 섞여 있다.

  1. 입력된 주문이 실제 고객의 주문과 연결되어 있다.
  2. 주문 상태가 SHIPPED이다.
  3. 배송 예정일이 오늘이다.
  4. 도착 예정 시각이 오후 6시 이전이다.

주문 API가 알려 준 값은 2번뿐인데 모델이 자연스러운 답변을 만들면서 3번과 4번까지 보충했을 수 있다. 응답 전체를 한 덩어리로 보고 “근거 있음”으로 표시하면 이런 부분 오류를 잡기 어렵다.

검증의 단위

답변 전체가 아니라 사용자의 판단이나 후속 행동에 영향을 주는 개별 주장을 검증해야 한다. 출처 하나가 연결되어 있다는 이유만으로 모든 문장이 검증된 것은 아니다.

프롬프트에 “모르면 모른다고 답하라”를 넣는 것은 도움이 되지만 충분하지 않다. 모델은 자신이 무엇을 모르는지 잘못 판단할 수 있고, 검색 결과가 질문을 실제로 뒷받침하는지도 오판할 수 있다. 검증을 모델의 태도에만 맡기지 않고 시스템의 명시적인 단계로 만들어야 한다.

환각을 하나의 오류로 묶으면 놓치는 것들

사실성 문제를 모두 hallucination으로 기록하면 개선 방향을 찾기 어렵다.

오류 유형 예시 필요한 대응
사실 오류 존재하지 않는 API 옵션을 있다고 설명 공식 문서와 대조
근거 없는 보충 배송 상태만 보고 도착 시각까지 추정 지원되지 않은 주장 제거
오래된 정보 이전 가격을 현재 정책처럼 설명 기준 시각과 유효 기간 확인
계산 오류 할인율이나 합계를 잘못 계산 계산 도구로 재계산
인용 조작 존재하지 않는 문서나 URL 생성 실제 문서 식별자 확인
엔티티 혼동 같은 이름의 다른 제품 정보를 사용 ID와 테넌트 범위 확인
도구 상태 불일치 실행하지 않은 환불을 완료됐다고 표현 실행 결과와 답변 연결
과도한 일반화 한 사례를 전체 정책으로 확대 적용 범위와 예외 확인

가격이나 주문 상태는 API의 구조화된 값으로 확인하는 편이 낫다. 기술 개념은 공식 문서나 원 논문의 구절이 필요하다. 산술은 다른 LLM보다 계산기가 안정적이다. 생성 코드는 검색만으로 검증되지 않으며 타입 검사와 테스트가 필요하다.

따라서 “검색을 한 번 더 한다”는 하나의 해법으로 모든 오류를 해결할 수 없다. 주장의 종류를 분류하고 그 종류에 맞는 검증 수단으로 라우팅하는 구조가 필요하다.

모든 문장을 같은 비용으로 검증할 필요는 없다

모든 문장을 같은 강도로 검증하면 지연과 비용이 커진다. 현실적인 시스템은 주장별 위험도에 따라 검증 예산을 다르게 배분한다.

등급 대표 주장 정책
Low 문체 제안, 아이디어 별도 검증 없이 생성 가능
Medium 기술 개념, 제품 사용법 신뢰 가능한 문서 근거 요구
High 가격, 일정, 계정 상태 권위 있는 최신 원천으로 필수 검증
Critical 결제·삭제·권한 변경 실행 전 재검증, 필요 시 사람 승인

“Redis는 인메모리 데이터 저장소다”와 “회원님의 결제는 취소되었습니다”에는 같은 정책을 적용하면 안 된다. 후자는 특정 거래를 가리키며 잘못 안내하면 금전적 피해가 생긴다.

위험 등급은 모델 분류에 규칙을 더하는 편이 좋다. refund, delete, 금액, 날짜처럼 명확한 조건은 코드에서 상향한다. 모델이 Low라고 판단해도 외부 변경 도구의 인자가 포함되면 Critical로 올리는 식이다.

답변보다 주장과 근거를 중심에 놓기

단순한 RAG는 다음처럼 흐른다.

question -> retrieve documents -> generate answer

검색 문서가 컨텍스트에 들어갔다는 사실만으로는 어느 문장이 어느 구절을 사용했는지, 모델이 문서 밖의 내용을 보충했는지 알기 어렵다.

flowchart LR
    Q[사용자 질문] --> P[답변 계획]
    P --> D[초안 생성]
    D --> C[원자 주장 추출]
    C --> R[주장별 검증기 선택]
    R --> E[근거 수집]
    E --> J{지원 여부 판정}
    J -->|supported| A[근거가 연결된 답변]
    J -->|contradicted| X[수정 또는 거절]
    J -->|insufficient| H[추가 질문 또는 사람 검토]

초안은 사용자에게 곧바로 보여 줄 답변이 아니라 검사 대상이다.

question
  -> draft
    -> claim-001
       -> evidence-order-api:field=status
       -> verdict: supported
    -> claim-002
       -> evidence-order-api:field=deliveryEstimate
       -> verdict: insufficient

근거를 먼저 모은 뒤 근거에서만 답변을 구성할 수도 있다. 범위가 제한된 고객 지원에는 이 방식이 단순하다. 복합 질문에서는 필요한 근거를 찾기 위해 답변 계획이나 가설적 주장을 먼저 만들 수 있다. 어느 쪽이든 초안을 곧 사실로 취급하지 않는다는 원칙은 같다.

주장을 검증 가능한 원자 단위로 나누기

FActScore는 긴 생성문을 원자적 사실로 나눈 뒤 신뢰할 수 있는 원천이 각 사실을 지원하는 비율을 평가한다. 운영 시스템에 그대로 적용하지 않더라도 “문단이 아니라 최소 사실 단위로 확인한다”는 아이디어는 유용하다.

AuroraDB 4.2는 2026년 3월에 출시되었고, 읽기 지연 시간을 30% 줄였으며 기존 4.x 클라이언트와 완전히 호환된다.

- id: claim-01
  text: AuroraDB 4.2는 2026년 3월에 출시되었다.
  type: release_date
- id: claim-02
  text: AuroraDB 4.2는 읽기 지연 시간을 30% 줄였다.
  type: benchmark
- id: claim-03
  text: AuroraDB 4.2는 기존 4.x 클라이언트와 완전히 호환된다.
  type: compatibility

출시일은 릴리스 노트로 확인한다. 성능 수치는 벤치마크 조건과 비교 대상이 있어야 한다. 호환성은 마이그레이션 문서의 예외와 지원 범위를 확인해야 한다.

type Risk = "low" | "medium" | "high" | "critical";
type ClaimKind =
  | "definition"
  | "numeric"
  | "temporal"
  | "account_state"
  | "compatibility"
  | "causal"
  | "action_result";

interface AtomicClaim {
  id: string;
  text: string;
  kind: ClaimKind;
  risk: Risk;
  subjectIds: string[];
  validAt?: string;
  requiredAuthority: "any" | "official" | "system-of-record";
  sourceHint?: string;
}

subjectIds는 같은 이름의 엔티티를 혼동하지 않게 한다. validAt은 “현재 가격”처럼 변하는 주장을 다룬다. requiredAuthority는 반드시 주문 시스템에서 읽어야 하는 주장을 구분한다.

원자화가 지나치면 비용이 커진다. 주관적 평가까지 쪼갤 필요는 없다. 외부 정보로 참과 거짓을 판정할 수 있는지, 숫자·날짜·고유명사·상태를 포함하는지, 후속 실행을 바꾸는지를 기준으로 삼는다.

주장 추출기도 틀릴 수 있다

중요한 문장을 누락하면 뒤 단계는 검증하지 않는다. 고위험 키워드 규칙, 숫자·날짜 탐지, 도구 인자 검사 같은 결정론적 검사를 함께 둬야 한다.

주장 유형에 맞는 검증기를 선택하기

주장 유형 우선 검증 원천 보조 수단
계정·주문·재고 system of record API 이벤트 로그, 읽기 전용 DB
제품 기능·옵션 버전이 명시된 공식 문서 릴리스 노트
현재 가격·정책 공식 가격/정책 데이터 변경 이력
산술·통계 계산기, 쿼리 독립 재계산
생성 코드 컴파일러, 타입 검사, 테스트 정적 분석
일반 지식 권위 있는 문서·논문 복수 출처 비교
외부 실행 결과 거래 ID와 최종 상태 후속 조회 API

합계를 확인하는 데 검색 모델을 사용할 이유는 없다.

interface LineItem {
  unitPrice: number;
  quantity: number;
}

function calculateSubtotal(items: LineItem[]): number {
  return items.reduce(
    (total, item) => total + item.unitPrice * item.quantity,
    0,
  );
}

const subtotal = calculateSubtotal([
  { unitPrice: 12_000, quantity: 2 },
  { unitPrice: 3_500, quantity: 1 },
]);
console.assert(subtotal === 27_500);

계정 상태는 벡터 검색으로 찾은 매뉴얼이 아니라 계정 API에서 읽는다.

async function verifyAccountStatus(accountId: string) {
  const snapshot = await accountApi.getSnapshot({ accountId });

  if (snapshot.accountId !== accountId) {
    throw new Error("ACCOUNT_SCOPE_MISMATCH");
  }

  return {
    status: snapshot.status,
    observedAt: snapshot.observedAt,
    version: snapshot.version,
  };
}

문서 검색에서도 공식 문서, 표준, 원 논문을 먼저 쓴다. 같은 잘못을 복제한 페이지 열 개보다 원문 하나가 낫다.

근거가 있다는 것과 주장을 뒷받침한다는 것은 다르다

검색 결과를 인용했다고 사실성이 확보되지는 않는다.

  1. 관련성: 구절이 질문 주제와 관련 있는가
  2. 함의: 구절이 실제 주장을 뒷받침하는가
  3. 충분성: 조건과 예외까지 판단하기에 충분한가

문서에 “일부 Team 고객은 감사 로그를 사용할 수 있다”고 적혀 있는데 “Team 요금제는 감사 로그를 제공한다”고 단정하면 관련성은 높지만 완전히 지원되지는 않는다. 일부, 지역 제한 같은 조건이 사라졌기 때문이다.

type Verdict =
  | { status: "supported"; confidence: number; evidenceIds: string[] }
  | { status: "contradicted"; confidence: number; evidenceIds: string[] }
  | { status: "insufficient"; reason: string; evidenceIds: string[] };

interface Evidence {
  id: string;
  sourceId: string;
  sourceVersion: string;
  locator: { section?: string; startOffset?: number; endOffset?: number };
  excerpt: string;
  retrievedAt: string;
  authority: "official" | "internal" | "primary" | "secondary";
}

insufficient는 별도 상태여야 한다. 증거가 없다고 거짓은 아니며, 반박을 찾지 못했다고 참인 것도 아니다. 판정에 사용한 구간과 버전을 저장하면 “왜 통과했는가”를 재현할 수 있다.

최신성과 출처 계보를 함께 기록하기

가격, 재고, 일정, 계정 상태, 지원 버전은 시간이 지나면 바뀐다.

interface TemporalEvidence extends Evidence {
  effectiveAt?: string;
  validUntil?: string;
}

function isFresh(evidence: TemporalEvidence, now: Date): boolean {
  if (!evidence.validUntil) return false;
  return new Date(evidence.validUntil).getTime() >= now.getTime();
}

TTL은 데이터마다 달라야 한다. 일반 개념 문서는 며칠 캐시해도 되지만 결제 상태는 응답 직전에 다시 읽어야 한다.

official-policy-v7.pdf
  -> parser 2.3
  -> section: cancellation
  -> chunk: policy-v7-cancel-04
  -> evidence: ev-91
  -> claim: claim-12
  -> response: response-502

이 계보가 있으면 문서가 v8로 바뀔 때 영향을 받는 청크와 답변을 찾아 재색인하고 회귀 평가할 수 있다.

검증 결과를 답변 정책으로 연결하기

상태 낮은 위험 높은 위험
supported 답변 포함 근거와 기준 시각 포함
contradicted 문장 수정 중단 후 권위 원천으로 재작성
insufficient 불확실성 표시 보류, 추가 질문, 사람 검토
verifier error 제한적 답변 안전 실패

답변 보류는 실패가 아니라 제품 동작이다. 무엇을 확인하지 못했고 어떤 입력이 필요한지 알려 준다.

주문이 출고된 사실은 확인했습니다. 다만 배송사 응답에 도착 예정 시각이 없어 오늘 오후 6시 이전 도착 여부는 확인할 수 없습니다.

stateDiagram-v2
    [*] --> Drafted
    Drafted --> Verifying
    Verifying --> Ready: 필수 주장 supported
    Verifying --> Revising: contradicted
    Verifying --> NeedInput: 정보 부족
    Verifying --> HumanReview: 고위험 판정 불가
    Revising --> Verifying
    NeedInput --> Verifying: 추가 입력
    HumanReview --> Ready: 승인 또는 수정
    Ready --> Delivered
    Delivered --> [*]

예시 코드로 검증 파이프라인 구성하기

다음은 구조 설명을 위한 재구성 예시이며 실제 프로젝트 코드는 아니다.

interface VerificationContext {
  tenantId: string;
  now: Date;
  question: string;
}

interface ClaimVerifier {
  supports(kind: ClaimKind): boolean;
  verify(claim: AtomicClaim, context: VerificationContext): Promise<Verdict>;
}

class VerifierRegistry {
  constructor(private readonly verifiers: ClaimVerifier[]) {}

  find(kind: ClaimKind): ClaimVerifier {
    const verifier = this.verifiers.find((item) => item.supports(kind));
    if (!verifier) throw new Error("NO_VERIFIER:" + kind);
    return verifier;
  }
}

검색 점수와 지원 판정은 분리한다.

class DocumentClaimVerifier implements ClaimVerifier {
  supports(kind: ClaimKind): boolean {
    return ["definition", "compatibility", "causal"].includes(kind);
  }

  async verify(claim: AtomicClaim, context: VerificationContext) {
    const candidates = await documentIndex.search({
      query: claim.text,
      authority: claim.requiredAuthority,
      validAt: claim.validAt ?? context.now.toISOString(),
      limit: 8,
    });

    const fresh = candidates.filter((item) =>
      item.validUntil
        ? new Date(item.validUntil) >= context.now
        : item.isVersioned,
    );

    if (fresh.length === 0) {
      return {
        status: "insufficient" as const,
        reason: "no fresh authoritative evidence",
        evidenceIds: [],
      };
    }

    const judgment = await entailmentModel.judge({
      claim: claim.text,
      passages: fresh.map((item) => item.excerpt),
      allowedLabels: ["supported", "contradicted", "insufficient"],
    });

    const evidenceIds = await evidenceStore.saveMany(judgment.usedPassages);
    return { ...judgment.verdict, evidenceIds };
  }
}

정책 엔진은 검증 결과를 실제 동작으로 바꾼다.

interface CheckedClaim {
  claim: AtomicClaim;
  verdict: Verdict;
}

type ResponseDecision =
  | { action: "answer"; includedClaimIds: string[] }
  | { action: "revise"; rejectedClaimIds: string[] }
  | { action: "ask"; question: string }
  | { action: "human_review"; reason: string };

function decideResponse(claims: CheckedClaim[]): ResponseDecision {
  const criticalUnknown = claims.find(
    ({ claim, verdict }) =>
      claim.risk === "critical" && verdict.status !== "supported",
  );
  if (criticalUnknown) {
    return {
      action: "human_review",
      reason: "critical claim is not verified: " + criticalUnknown.claim.id,
    };
  }

  const contradicted = claims.filter(
    ({ verdict }) => verdict.status === "contradicted",
  );
  if (contradicted.length > 0) {
    return {
      action: "revise",
      rejectedClaimIds: contradicted.map(({ claim }) => claim.id),
    };
  }

  return {
    action: "answer",
    includedClaimIds: claims
      .filter(({ verdict }) => verdict.status === "supported")
      .map(({ claim }) => claim.id),
  };
}

전체 흐름에서는 검증기 장애도 insufficient로 취급한다.

async function buildVerifiedAnswer(input: UserQuestion) {
  const context = await createVerificationContext(input);
  const draft = await answerModel.createDraft(input);
  const extracted = await claimExtractor.extract(draft);
  const claims = enforceRiskRules(extracted, draft);

  const checked = await Promise.all(
    claims.map(async (claim): Promise<CheckedClaim> => {
      try {
        const verifier = verifierRegistry.find(claim.kind);
        return { claim, verdict: await verifier.verify(claim, context) };
      } catch (error) {
        await auditLog.recordVerifierFailure(claim, error);
        return {
          claim,
          verdict: {
            status: "insufficient",
            reason: "verifier unavailable",
            evidenceIds: [],
          },
        };
      }
    }),
  );

  const decision = decideResponse(checked);
  return responseComposer.compose({ draft, checked, decision });
}

검증 뒤 답변을 자유롭게 다시 쓰게 하면 새 주장이 생길 수 있다. 검증된 주장만 조합하게 하거나 재작성 결과를 다시 검사해야 한다. 검증 이후의 생성도 비결정적 경계다.

원형 검증

답변을 만든 모델과 같은 컨텍스트의 모델에게 “맞습니까?”라고 묻는 것만으로 독립 검증이 되지는 않는다. 구조화된 데이터, 실행 결과, 원문 구절 같은 외부 신호가 필요하다.

자기 일관성 검사의 역할과 한계

SelfCheckGPT는 같은 질문에 생성된 여러 응답이 서로 모순되는지를 사실성 문제의 신호로 사용한다.

async function consistencySignal(prompt: string, samples = 5) {
  const answers = await Promise.all(
    Array.from({ length: samples }, () =>
      model.generate({ prompt, temperature: 0.9 }),
    ),
  );
  return contradictionDetector.score(answers);
}

하지만 일관성은 진실의 증명이 아니다. 모델이 같은 잘못을 강하게 배웠다면 매번 같은 오답을 낼 수 있다. 외부 근거가 없는 주장의 불확실성 신호, 검증 우선순위, 사람 검토 라우팅의 보조 특징으로 제한해야 한다.

도구 실행 전에는 더 엄격하게 검증한다

외부 상태를 바꾸는 에이전트는 실행 전 검증이 필요하다.

sequenceDiagram
    participant U as User
    participant A as Agent
    participant V as Verifier
    participant P as Policy
    participant T as Payment Tool
    U->>A: 주문 환불 요청
    A->>V: 주문, 소유자, 금액, 상태 확인
    V-->>A: snapshot + version
    A->>P: 의도와 검증 결과
    P-->>A: 허용 또는 승인 요청
    A->>T: idempotency key로 환불
    T-->>A: transaction ID
    A->>V: 결과 재조회
    V-->>A: refunded 확인
    A-->>U: 확인된 결과 안내
async function executeVerifiedRefund(command: RefundCommand) {
  const order = await orderApi.get(command.orderId);

  assertSameTenant(order.tenantId, command.tenantId);
  assertRefundPermission(command.actorId, order);
  assertVersion(order.version, command.expectedVersion);
  assertRefundableAmount(order, command.amount);

  const receipt = await paymentApi.refund(command);
  const latest = await orderApi.get(command.orderId);

  if (latest.paymentStatus !== "refunded") {
    throw new Error("REFUND_NOT_CONFIRMED:" + receipt.transactionId);
  }
  return receipt;
}

요청 대상과 도구 인자의 ID, 권한, 읽은 상태의 버전, 금액 한도, 멱등성 키를 검사한다. 모델이 만든 성공 문구가 아니라 거래 ID와 후속 조회가 최종 답변의 근거가 되어야 한다. AI 에이전트 권한을 최소화하는 방법에이전트 재시도와 멱등성에도 이어지는 부분이다.

운영에서 관측할 지표와 평가 방법

지표 의미 주의점
claim support rate 근거가 확인된 주장 비율 위험도가 다른 주장을 단순 평균하지 않기
unsupported delivery rate 미검증 주장이 전달된 비율 위험 등급별 분리
false support rate 틀린 주장을 통과시킨 비율 사람이 라벨링한 표본 필요
abstention rate 보류·추가 질문 비율 높다고 무조건 좋은 것은 아님
useful answer rate 행동 가능한 답을 받은 비율 안전성과 함께 관측
evidence freshness 유효 기간 내 근거 비율 데이터별 SLA 적용
verification latency 검증이 추가한 지연 p95·p99도 확인

정확도만 최적화하면 모든 답변을 거절하는 시스템도 높은 점수를 받을 수 있다.

utility = verified_helpful_answers
          - 5 * high_risk_false_supports
          - 1 * unnecessary_abstentions

평가셋에는 근거가 없는 질문, 구문서와 최신 문서가 충돌하는 질문, 같은 이름의 다른 엔티티, 숫자 하나만 틀린 초안, 관련 있지만 직접 지원하지 않는 출처, API 타임아웃, 실행 직전 상태 변경을 포함한다.

id: verification-042
question: Team 요금제의 감사 로그 보존 기간은 1년인가요?
expected:
  required_claims:
    - kind: temporal
      risk: high
  acceptable_decisions:
    - answer_with_current_official_evidence
    - abstain_if_plan_region_is_unknown
  forbidden:
    - cite_deprecated_policy
    - infer_enterprise_policy_for_team

LLM 평가셋을 실제 실패 사례로 만드는 방법처럼 실제 실패를 평가 케이스로 남긴다. 전체 정답률뿐 아니라 주장 추출 재현율, 검색 재현율, 함의 판정 정확도, 정책 결정 정확도를 따로 봐야 병목을 찾을 수 있다.

도입 순서와 점검표

  1. 숫자·날짜·상태·실행 결과를 우선 식별한다.
  2. 각 주장에 대한 system of record와 공식 원천을 정한다.
  3. 주장 ID, 근거 ID, 기준 시각을 로그에 남긴다.
  4. 근거가 없으면 추정하지 않는 정책을 고위험 경로에 적용한다.
  5. 실제 실패로 거짓 통과와 과도한 보류를 측정한다.
  6. 필요할 때 LLM 기반 주장 추출과 함의 판정을 넓힌다.

결론

환각을 0으로 만들겠다는 목표는 운영 기준으로 모호하다. 어떤 문장이 중요한지, 무엇으로 확인할지, 자료가 오래되거나 모순될 때 어떻게 할지, 확인하지 못했을 때 어떤 동작을 할지가 빠져 있기 때문이다.

더 실용적인 목표는 중요한 주장마다 답변에서 신뢰 가능한 원천까지 이어지는 검증 경로를 만들고, 그 경로가 끊기면 추측 대신 제한된 동작을 선택하게 하는 것이다.

긴 답변을 원자 주장으로 나누고, 유형과 위험도에 맞는 검증기를 선택하며, 관련성뿐 아니라 실제 지원 여부와 최신성을 판정해야 한다. 결과는 로그가 아니라 답변 수정, 부분 답변, 추가 질문, 사람 검토, 실행 차단으로 이어져야 한다.

좋은 AI 시스템은 틀릴 가능성이 없다고 주장하는 시스템이 아니다. 어디까지 확인했고 무엇은 확인하지 못했는지를 구조적으로 설명할 수 있는 시스템에 가깝다.

참고 자료

관련 노트