청구 관련 Corrective RAG: 버그는 검색(Retrieval)이 아니라 모델이 올바른 숫자를 잘못 설명하는 데 있습니다
요약
RAG 시스템에서 발생하는 오류가 검색(Retrieval)이 아닌 모델의 잘못된 수치 해석에 있음을 지적합니다. RRF를 활용한 랭킹 융합과 FTS를 통한 숫자 검색 최적화, 그리고 비용 효율적인 CRAG 구현 방식을 제안합니다.
핵심 포인트
- RAG 실패의 핵심은 잘못된 검색보다 검색된 올바른 숫자를 모델이 잘못 설명하는 것임
- 벡터 유사도와 FTS의 스케일 차이를 극복하기 위해 RRF(Reciprocal Rank Fusion) 사용 권장
- 계좌 번호 등 의미론적 가치가 없는 숫자는 임베딩 대신 전문 검색(FTS)으로 처리
- 비용 절감을 위해 별도의 평가 모델 없이 코사인 유사도를 활용한 CRAG 정제 단계 구현
대부분의 RAG 데모는 정답을 확인할 수 없는 청중들에 의해 평가됩니다. 문서 봇에게 무언가를 물어보고 유창한 문단을 답변으로 받으면, 그 자리에 있는 누구도 세 번째 문장이 지어낸 것인지 알 수 없습니다. 그렇게 데모는 성공적으로 끝납니다.
우리는 요금제 및 청구 질문에 답변하는 ISP(인터넷 서비스 제공업체)를 위한 WhatsApp 어시스턴트를 구축했습니다. 이 포스트는 결과적으로 중요했던 두 가지 요소에 관한 것이며, 그 중 어느 것도 "우리는 CRAG를 사용했다"는 내용은 아닙니다.
- 문서 코퍼스(Document corpus)와 청구 API(Billing API)는 모두 "검색 (Retrieval)"이라고 불리지만, 완전히 다른 방식으로 실패합니다.
- 실제로 치명적인 실패는 잘못된 검색이 아닙니다. 모델이 올바른 숫자를 가져와서 잘못 설명하는 것입니다.
코드는 실제 시스템에서 가져왔습니다. 주석은 스페인어에서 번역되었으며, 그 외에는 수정되지 않았습니다.
파이프라인 (The pipeline)
inbound message (수신 메시지)
↓
state gate (상태 게이트) ──────────────► human agent active? (상담원 활성화 상태?) bot writes nothing (봇은 아무것도 쓰지 않음)
...
오른쪽을 가리키는 모든 화살표는 거부 경로(Refusal path)입니다. 그것이 설계 의도입니다.
1. 비교 불가능한 두 가지 랭킹의 융합 (Fusing two rankings that aren't comparable)
벡터 유사도(Vector similarity)와 Postgres의 ts_rank는 서로 다른 스케일(Scale)을 가집니다. 이 둘을 단순히 더하는 것은 의미가 없습니다. Reciprocal Rank Fusion (RRF)은 오직 _순위(Position)_만을 고려하므로, 점수가 비교 가능하다는 가정을 하지 않고도 두 랭킹을 융합할 수 있습니다.
DEFAULT_RRF_K = 60
"""Smoothing constant (평활화 상수). 단일 랭킹이 융합을 지배하지 못하도록 상위 순위의 가중치를 감쇄시킵니다. 60은 원본 논문에 나온 값입니다."""
...
동점자는 ID를 기준으로 결정론적(Deterministically)으로 처리됩니다. 동일한 실행 사이에서 결과 순서가 바뀌면 테스트가 불안정해지고 디버깅이 불가능해집니다.
임베딩(Embeddings)이 있는데 왜 굳이 전문 검색(Full-text search, FTS)을 사용할까요? 그 이유는 계좌 번호, 세금 식별 번호(Tax IDs), 국가 식별 번호(National ID numbers)는 의미론적 의미(Semantic meaning)를 갖지 않기 때문입니다. 임베딩은 숫자를 점수화할 수 없습니다. FTS는 이러한 정보들을 검색할 수 있는 유일한 경로입니다.
2. 두 번째 모델 없는 CRAG의 정제 단계 (CRAG's refinement step, without the second model)
CRAG (Yan et al., 2024)는 생성(Generating)을 하기 전에 검색된 내용의 관련성을 평가하고 노이즈를 제거합니다. 해당 논문은 별도의 경량 평가 모델(Lightweight evaluator model)을 사용합니다.
우리는 평가기(evaluator)를 건너뛰었습니다. 검색기(retriever)는 이미 코사인 유사도(cosine similarity)를 계산하여 전체 텍스트가 일치하는지 알고 있습니다. 매 턴마다 청크(chunk)별로 모델 호출을 실행하는 것은 CRAG에서 비용이 많이 드는 부분이며, 이미 점수를 가지고 있는 상황에서는 가장 유용성이 낮은 부분입니다.
이 방식이 해결하는 문제는 구체적입니다: top_k는 6개의 청크를 요청하며, RRF는 여섯 번째 청크가 타당하든 아니든 항상 6개를 반환합니다. 퓨전(Fusion)은 순서를 정할 뿐, 필터링을 하지 않습니다. 그 채워넣기용 데이터(filler)는 전체 크기 그대로 프롬프트에 들어가 매 턴 토큰을 소모하고, 유효한 증거를 희석시킵니다.
DEFAULT_MARGIN = 0.12
"""최적의 청크보다 얼마나 더 낮은 점수까지 허용할 것인가.
...
여기서 편향(bias)에 주목하십시오. 이는 아래에 나올 검증기(verifier)와는 정반대이기 때문입니다: 의심스러울 때는 유지하십시오. 과도한 가지치기(over-pruning)는 질문에 답이 될 수 있는 단 한 줄을 삭제하여, 봇이 해결할 수 있었던 케이스를 에스컬레이션(escalation)하게 만듭니다. 이는 몇 개의 토큰을 낭비하는 것보다 더 나쁜 상황입니다.
3. 도구 결과(Tool results)는 일급 증거(first-class evidence)입니다
진정한 균형은 문서 자체에 있는 것이 아닙니다. 그것은 청구(billing) API에서 오며, 이는 인용 가능한 증거로 간주되어야 합니다. 그렇지 않으면 4단계의 검증기가 _정답_을 거부할 것이기 때문입니다:
@dataclass(frozen=True, slots=True)
class ToolResult:
capability: str
...
마지막 필드는 가져올 만한 가치가 있는 차별점입니다. "API가 다운되었습니다"와 "이 고객은 존재하지 않습니다"는 둘 다 데이터를 생성하지 않지만, 서로 다른 이벤트입니다. 하나는 장애(outage)이고, 다른 하나는 답변입니다.
except CrmBusinessError as exc:
# API는 작동하며 "존재하지 않음"이라고 응답했습니다: 데이터 누락이며, 강제 에스컬레이션 대상이 아닙니다.
return ToolResult(capability=capability.name, ok=False,
...
API가 실제로 다운되었을 때 재시도하는 것은 상황을 악화시킵니다. 모든 인바운드 메시지가 15초의 타임아웃 × 3번의 시도를 소모하게 만들고, 워커 큐(worker queue)는 정체되며, API는 복구를 시도하는 바로 그 순간에 폭주(stampede)를 겪게 됩니다. 따라서 서킷 브레이커(circuit breaker)가 존재하며, 봇에게 "API 다운"은 단순히 _빠른 에스컬레이션(escalate fast)_으로 번역됩니다:
def record_failure(self) -> None:
"""실패를 기록합니다; HALF_OPEN 상태에서는 즉시 다시 엽니다.
...
실제로 잠들지 않고도 재개방(reopening)을 검증할 수 있도록 시계(clock)를 주입합니다. sleep(60)을 수행하는 테스트는 삭제되거나 느린 테스트로 표시되며, 이로 인해 시스템의 가장 민감한 부분이 테스트 커버리지(coverage)를 잃게 됩니다.
4. 실제로 중요한 부분
위의 모든 내용은 검색 위생(retrieval hygiene)에 관한 것입니다. 제가 다른 사람의 청구 어시스턴트(billing assistant)를 리뷰한다면 가장 강력하게 밀어붙일 부분은 바로 이것입니다. 왜냐하면 이 부분은 모든 모델 업그레이드 상황에서도 살아남기 때문입니다.
프롬프트(prompt)는 모델에게 지어내지 말라고 요청합니다. 이 코드는 그것을 검증합니다:
class GroundingVerifier:
def verify(
self, *, answer: str, used_chunk_ids: list[str],
...
두 가지 확인 사항:
- 모든 인용(citation)이 실제 근거를 가리켜야 합니다. 검색되지 않은 청크 ID(chunk id)를 인용하는 모델은 스스로 근거를 지어내고 있는 것입니다.
- 근거 없이 숫자가 나타나서는 안 됩니다. 답변에 포함된 모든 숫자 토큰(numeric token) — 금액, 계좌 번호, 전화번호, 날짜 등 — 은 반드시 검색된 근거(retrieved evidence)나 도구 결과(tool result)에 나타나야 합니다.
이 편향(bias)은 의도적인 것이며 가지치기(pruner)의 역과 같습니다: 의심스러울 때는 거부하십시오. 거부된 답변은 사람에게 에스컬레이션(escalate)되어 번거로울 수는 있지만 안전합니다. 배포된 환각(hallucination)은 비용이 많이 들고 신뢰를 잃게 만듭니다. 해당 모듈은 100% 테스트 커버리지(test coverage)를 유지해야 합니다.
이를 까다롭게 만드는 버그
단순한 버전: 숫자에서 숫자가 아닌 것을 제거한 뒤, 해당 숫자(digits)가 근거에 나타나는지 확인합니다.
이 방식은 틀렸으며, 이를 무너뜨린 반례는 다음과 같습니다:
근거 (청구 API로부터): 45.00
모델의 답변: 4.500
두 값의 숫자(digits): "4500" ✓ 통과
두 금액은 서로 다릅니다. 하지만 숫자는 같습니다. 베네수엘라 표기법에서 4.500은 4,500으로 읽히며, 45.00은 45입니다. 숫자 일치 여부만 확인하는 방식은 누군가의 청구 금액 규모를 두 자릿수나 바꿔버리는 환각(hallucination)에 통과 신호를 보내게 됩니다.
따라서 금액은 숫자가 아닌 정규 값(canonical value)에 의해 비교됩니다:
_AMOUNT = re.compile(r"^\d{1,3}(?:[.,\s]\d{3})*[.,]\d{1,2}$|^\d+[.,]\d{1,2}$")
def _amount_value(token: str) -> str | None:
...
세 가지 규칙, 엄격한 순서대로:
def _number_supported(token, evidence, evidence_digits, evidence_amounts) -> bool:
# 1. 숫자 경계가 있는 리터럴(Literal). `12345678`이 더 긴 `123456789` 안에
# 포함되어 있다는 이유만으로 수용되지 않음.
...
규칙 3은 의도적으로 좁게 설정되었습니다. 리터럴(Literal)로도 일치하지 않고 금액(Amount)으로도 일치하지 않는 짧은 숫자는 거부됩니다. 즉, 4.500이 45.00과 숫자를 공유한다는 이유만으로 통과되어서는 안 됩니다.
거부 경로(Refusal path)가 곧 제품입니다
본능적으로는 에스컬레이션(Escalation, 상급자/전문가 이관)을 실패율로 간주하고 이를 0으로 만드는 데 집중하게 됩니다. 하지만 이는 잘못된 것을 최적화하는 것입니다. 검증기(Verifier)를 완화하여 제거한 모든 에스컬레이션은 청구서에 대한 '확신에 찬 답변'이 되며, 그중 일부는 고객이 알아챌 수 있는 방식으로 틀리게 됩니다.
각 사유에는 상담원이 전체 스레드를 다시 읽지 않도록 요약 정보가 포함됩니다:
_AGENT_SUMMARY: dict[EscalationReason, str] = {
EscalationReason.NO_CONTEXT:
"고객이 지식 베이스(Knowledge base)에 없는 내용을 질문했습니다.",
...
시스템을 구축하기 전에 드리고 싶은 말씀
- 기록 시스템(System of record)의 숫자는 사용자에게 그 숫자 그대로 전달되어야 합니다. 모델의 역할은 수치 자체가 아니라 수치를 둘러싼 문장을 만드는 것입니다. 합계나 날짜 비교와 같은 모든 차이(Delta) 계산은 연산(Computation)이며, 연산은 테스트 가능한 코드의 영역에 있어야 합니다.
- 도구 거부율(Instrument refusal rate)을 결함이 아닌 조절 가능한 다이얼(Dial)로 취급하세요. 불만 사항에 맞춰 이를 조정하십시오.
- 중요한 지표는 검증된 오답(Verified-wrong answers)이며, 이는 여러분의 평가 하네스(Eval harness)가 아니라 고객 지원(Support) 부서에서 나옵니다.
범위에 대한 솔직한 언급을 하자면: 이 내용은 벤치마크라기보다는 아키텍처(Architecture)로서 설명되는 한 운영사를 위한 하나의 시스템 사례입니다. 단 하나의 샘플을 바탕으로 한 고객 응대 전환율(Deflection-rate) 차트를 보여드리지는 않겠습니다.
또한 명확히 말씀드릴 점은, 청구 API 통합은 계약 우선 (contract-first) 방식이라는 것입니다. 문서화된 HTTP 계약(contract)이 존재하며 이를 구현한 시뮬레이터가 있습니다. 반대편의 운영 시스템(production system)은 아직 구축 중입니다. 위에서 언급한 모든 것들 — 능력 해결 (capability resolution), 서킷 브레이커 (circuit breaker), 증거로서의 도구 결과 (tool-results-as-evidence) — 은 해당 계약을 바탕으로 실행됩니다. 저희는 이것이 올바른 순서(계약은 양측이 합의하는 대상임)라고 생각하지만, 코드를 읽을 때 이 점을 알고 있어야 합니다.
WhatsApp이 왜 불확실성을 나타내기 위해 통상적으로 사용하는 모든 UI 어포던스 (UI affordance)를 제거하는지에 대한 이유를 포함한 더 긴 버전은 다음과 같습니다: WhatsApp의 청구 관련 질문을 위한 Corrective RAG (Corrective RAG for Billing Questions on WhatsApp)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기