당신의 RAG 코파일럿은 숫자를 셀 수 없습니다 — 시도하게 두지 마세요
요약
RAG 시스템에서 LLM이 직접 숫자를 집계하게 할 때 발생하는 오류와 그 원인을 분석합니다. 검색 단계의 필터링과 제한된 컨텍스트로 인해 발생하는 수치 오류를 방지하기 위한 설계 원칙을 제시합니다.
핵심 포인트
- LLM은 산술 연산을 직접 수행하지 말고 시스템의 기록을 활용해야 함
- RAG의 검색/재순위화 과정이 전체 데이터 집계를 왜곡할 수 있음
- 모델은 계산 도구가 아닌, 권위 있는 데이터를 문장으로 표현하는 역할에 집중해야 함
- 에이전트 프롬프트에 도구가 제공하는 총계 값을 사용하도록 명시적 지침 필요
당신의 RAG 코파일럿은 숫자를 셀 수 없습니다 — 시도하게 두지 마세요
한 사용자가 우리의 문서 검색 코파일럿(document-search copilot)에게 매우 합리적인 질문을 던졌습니다. 특정 인물이 작성한 문서가 몇 개인지를 물었죠. 코파일럿은 숫자로 대답했습니다. 아주 자신 있게 말이죠. 근사한 문장까지 곁들여서 말입니다.
데이터베이스가 계산한 실제 정답은 84였습니다. 하지만 코파일럿은 다른 숫자를 말했습니다. 왜냐하면 집계(aggregation) 질문을 던졌을 때 모든 RAG 시스템이 하는 행동을 그대로 수행했기 때문입니다. 바로 자신이 볼 수 있는 것들만 센 것입니다.
저는 다음과 같은 주장을 옹호하고 싶습니다. 왜냐하면 우리 중 많은 이들이 현재 이 버그를 그대로 배포하고 있다고 생각하기 때문입니다:
💡 LLM은 절대로 집계(aggregate)를 계산해서는 안 됩니다. 개수(counts), 합계(sums), "가장 최근(most recent)" 모두 마찬가지입니다. 질문이 코퍼스(corpus)에 대한 산술 연산이라면, 정답은 기록 시스템(system of record)에서 나와야 합니다. 모델의 유일한 역할은 그것을 문장으로 표현하는 것뿐입니다.
🔢 모델의 계산이 시작 전부터 실패할 수밖에 없었던 이유
이것은 LangGraph와 Bedrock을 기반으로 구축된 품질 관리 제품을 위한 멀티 에이전트(multi-agent) 문서 검색 코파일럿입니다. 메타데이터 질문(예: "X가 작성한 문서")이 들어오면, 검색 에이전트(search agent)가 문서 서비스에 일치하는 행(rows)을 요청하고, 이를 재순위화(rerank) 및 권한 검사(permission-gates)하며, 동일한 문서의 여러 버전을 하나의 카드로 병합하고, 최대 30개의 결과만을 노출합니다.
이 단계들은 모두 검색(search) 관점에서는 올바른 단계입니다. 하지만 그 모든 단계가 _개수(count)_를 파괴합니다:
| 단계 | 수행하는 작업 | 여기서 "몇 개인가"의 의미 |
|---|---|---|
| 문서 서비스 (SQL) | 데이터베이스에 필터 실행 | 실제 총합: 84 |
| ... |
모델은 그 깔때기(funnel)의 맨 아래에 위치합니다. 모델이 "수를 셀" 때, 모델은 테이블의 마지막 행만을 셉니다. 사용자는 첫 번째 행부터의 전체를 물었지만 말이죠. 그 사이의 모든 과정은 데이터베이스 질문을 컨텍스트 윈도우(context-window) 질문으로 조용히 바꿔버린 검색 위생(retrieval hygiene) 절차였습니다.
그리고 이 부분이 시스템 충돌(crash)보다 상황을 더 악화시키는 지점입니다. 바로 답변이 그럴듯해 보인다는 것입니다. 30개로 제한되고 중복 제거된 숫자는 실제 숫자처럼 보입니다. 사람들은 스택 트레이스(stack trace)를 들여다볼 때처럼
그다음 결과 노드(outcome node) — 검색 상태(search state)를 사용자에게 보여지는 상태 표시줄(status line)과 에이전트가 추론하는 메시지 모두로 변환하는 노드 — 는 두 곳 모두에 권위 있는(authoritative) 숫자를 사용합니다:
total_matching = state.get("total_matching_records") or 0
headline_total = total_matching if total_matching > total else total
...
그리고 에이전트의 프롬프트(prompt)는 명시적인 지침을 통해 이 루프를 완성합니다:
"몇 개"라는 질문에 대해서는, 도구가 보고하는 권위 있는 총계("총 N개의 문서가 일치합니다")로 답변하세요. 표시된 카드(card)의 개수(제한되어 있음)를 절대 사용하지 마세요.
이러한 이중 안전장치(belt-and-braces) 구조에 주목하십시오. 이는 매우 중요하기 때문입니다. 숫자는 에이전트의 컨텍스트 (agent's context) (모델이 이를 바탕으로 답변을 구성할 수 있도록)에 들어가며, 동시에 사용자에게 보이는 헤드라인 (user-facing headline) ("일치하는 84개 문서 중 5개 표시 중")에도 들어갑니다. 모델이 주의 사항을 무시하고 숫자를 임의로 작성하는 턴(turn)이라 할지라도, UI는 그 바로 옆에 실제 총계를 보여주고 있습니다. 프롬프트가 요청하고, 헤드라인은 그럴 필요가 없는 것입니다.
세 가지 작은 테스트가 이 동작을 확인해 줍니다: 일치하는 항목이 카드보다 많을 때는 개수 메모가 나타나고, 모든 항목이 표시되면 사라지며, 내용 중심의 시맨틱 검색 (semantic search) 시에는 절대 나타나지 않습니다. 시맨틱 검색은 명확한 "일치" 집합이 없으므로, 정확한 총계가 있는 것처럼 가장하는 것은 새로운 모습으로 거짓말을 하는 것과 같기 때문입니다.
📅 형제 버그: 무엇의 "최근 10일"인가?
동일한 릴리스(release)에서 발생한, 다른 형태를 띤 동일한 근본 원인입니다. 사용자들이 "최근 10일 이내에 생성된 문서"를 요청했을 때, 코파일럿(copilot)은 오늘이 며칠인지 모르는 상태에서 이를 날짜 필터로 변환해야 했습니다. 모델은 오늘 날짜를 알지 못합니다. 모델은 학습 데이터(training data)에서 날짜를 기꺼이 추측할 것이며, 이는 컨텍스트 윈도우 (context window)의 숫자를 세는 것만큼이나 틀릴 가능성이 높습니다.
해결책은 모든 모델 호출 (every model call) 시 시스템 메시지 (system message)에 현재 날짜를 주입하는 아주 작은 LangGraph 미들웨어 (middleware)입니다:
def current_date_context(now: datetime | None = None) -> str:
day = (now or datetime.now(UTC)).date()
return (
...
한 가지 눈에 띄지 않는 세부 사항은, 이 정보가 호출될 때마다 주입되며 영구적으로 저장되는 대화 기록(persisted conversation)에는 추가되지 않는다는 점입니다. 스레드 기록에 "오늘은 2026-07-30입니다"라고 작성하면, 다음 주에 재개된 대화는 모델이 완전히 신뢰하게 될 오래된 날짜를 그대로 유지하게 됩니다.
두 가지 해결책 모두 동일한 원리를 따릅니다: 모델은 당신이 직접 전달하지 않은 것에 대해서는 아무것도 알지 못하며, 그럼에도 불구하고 답변을 하려 할 것입니다. 개수, 날짜, 합계 — 언어에 관한 것이 아니라 당신의 시스템에 관한 사실인 모든 것은 환각 (hallucinate)을 일으키지 않는 무언가에 의해 계산되어 컨텍스트 (context)로 전달되어야 합니다.
🤔 스스로 반론을 제기해 본다면
출시된 결과물의 솔직한 한계점은 다음과 같습니다:
- 모델이 해당 숫자를 사용하도록 강제하는 장치가 없습니다. 프롬프트 (prompt)가 지시하고, 도구 메시지 (tool message)가 제공하며, 헤드라인이 뒷받침하지만, 모델의 산문 (prose)에 포함된 개수가 권위 있는 숫자와 일치하는지 확인하는 출력 검증 (output check)은 없습니다. 이는 제가 원하지만 아직 갖추지 못한 평가 (eval) 항목입니다.
- 콘텐츠 전용 검색 (Content-only searches)은 여전히 실제 개수를 알 수 없습니다. 청크 (chunks)에 대한 의미론적 순위 지정 (semantic ranking)은 진정한 의미의 정확한 일치 총합을 갖지 않으므로, 해당 턴은 노출된 개수에 의존하게 됩니다. 논쟁의 여지는 있겠지만, 가장 정직한 방법은 숫자를 아예 제공하지 않는 것입니다. 저는 거기까지는 가지 않았습니다.
- 이것은 오직 개수(counts)만을 다룹니다. 합계, 평균, "어느 저자가 가장 많은가" — 이 중 어떤 것이든 서비스 측의 실제 집계 엔드포인트 (aggregation endpoints)가 필요할 것입니다. 패턴은 확장 가능하지만, 구현은 아직 그렇지 못합니다.
그리고 제가 계속 고민하고 있는 설계 선택 사항은 다음과 같습니다: 저는 모델에게 숫자를 도구 결과 내의 산문 (prose) 형태로 인라인 (inline) 제공했습니다. 에이전트 중심의 순수주의적 (agentic-purist) 대안은 모델이 집계 질문을 감지했을 때 호출하는 전용 count_documents 도구를 사용하는 것입니다. 이는 더 깔끔한 분리, 한 번의 추가적인 왕복 (round trip), 그리고 모델이 도구를 호출하지 않을 수 있다는 새로운 실패 모드를 의미합니다. 저는 인라인 방식을 선택했는데, 검색 과정에서 이미 개수 계산 비용이 지불되었기 때문입니다. 도구 호출을 추가하는 것은 이념을 위해 지연 시간 (latency)을 감수하는 것처럼 느껴졌습니다.
그렇다면 질문은 이것입니다. 그 경계선을 어디에 그어야 할까요? 모델에게 숫자를 직접 건네줄 것인가, 아니면 숫자를 가져올 도구 (tool)를 건네줄 것인가? 만약 당신의 코파일럿 (copilot)이 오늘
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기