운영 환경에서 LLM이 법률 URL을 환각(Hallucination)하는 현상을 겪었습니다 — 저희가 시도한 방법들
요약
법률 RAG 애플리케이션 운영 중 LLM이 존재하지 않는 법률 URL을 생성하거나 기존 URL을 미세하게 변형하는 환각 현상을 분석했습니다. 프롬프트 엔지니어링과 컨텍스트 축소 등의 시도가 한계가 있었음을 밝히며 문제의 근본 원인을 탐구합니다.
핵심 포인트
- LLM이 존재하지 않는 조항을 지어내는 패턴 발견
- 컨텍스트 내 실제 URL을 미세하게 변형하는 패턴 확인
- 퓨샷 프롬프팅만으로는 URL 변형 문제를 해결하기 어려움
- 컨텍스트 문서 수를 줄이는 것이 오류율 감소에 일부 효과적임
운영 환경의 버그 (The Production Bug)
저희는 법률 문서를 기반으로 한 RAG (Retrieval-Augmented Generation) 애플리케이션을 구축하고 있었습니다.
사용자가 법률 질문을 하면, 시스템이 관련 법 조항과 판결문을 검색하고, LLM (Large Language Model)이 인용구를 포함하여 답변을 합성합니다.
인용구(Citations)가 핵심입니다. 작동하는 소스 링크가 없는 법률 답변은 쓸모가 없습니다. 쓸모없는 정도를 넘어, 신뢰할 수 있어 보이기 때문에 더 위험합니다.
그러던 중 사용자들이 깨진 링크(Broken links)를 보고하기 시작했습니다.
문제를 파헤쳐 보니 두 가지 뚜렷한 실패 패턴을 발견했습니다:
패턴 1 — 지어낸 URL (Invented URLs). LLM이 단순히 존재하지 않는 조항을 인용하는 경우입니다. 잘못된 문서가 아니라, 실제 문서 내의 잘못된 조항을 가리키는 것입니다. 예를 들어, #paragraf-67까지만 있는 법률에서 #paragraf-99를 참조하는 식입니다. 확신에 차 있고, 그럴듯하지만, 틀렸습니다.
패턴 2 — 절반만 깨진 URL (Half-broken URLs). 소스 URL은 실제 존재하며 컨텍스트(Context)에 포함되어 있었습니다. LLM은 이를 읽었습니다. 하지만 LLM이 반환한 값은 미묘하게 변형되어 있었습니다:
# LLM에게 제공한 것:
4552013#paragraf-31.odsek-2.pismeno-a
...
모든 변형은 그럴듯했습니다. 하지만 그 중 어떤 것도 존재하지 않았습니다. 그리고 링크는 UI에서 정상적으로 표시되었습니다. 클릭해 보기 전까지는 링크가 죽어 있다는 사실을 알 수 없었습니다.
저희가 처음 시도한 것 (What We Tried First)
프롬프트 엔지니어링 (Prompt engineering). 저희는 퓨샷 프롬프팅 (Few-shot prompting)을 시도했습니다. 즉, 모델에게 올바른 인용의 형태와 잘못된 인용의 형태가 무엇인지 명시적인 예시를 제공하는 것입니다. '이것이 허용되는 형식이고, 이것은 피해야 할 것이다'라고 알려준 것입니다. 모델은 예시를 완벽하게 이해했습니다. 테스트 중에는 예시를 정확하게 다시 출력하기도 했습니다. 하지만 실제 법률 URL이 포함된 실제 응답에서는 여전히 파편(Fragments)들을 변형시켰습니다. 예시들은 경계 사례(Edges)에는 도움이 되었지만 핵심 문제는 남아 있었습니다. 모델이 당신의 예시를 무시하는 것이 아니라, 모델 입장에서는 둘 다 똑같이 유효해 보이기 때문에 실제 URL과 변형된 버전 사이의 차이를 진정으로 구분하지 못하는 것이었습니다.
컨텍스트 축소 (Reducing context). 프롬프트당 주입하는 문서의 수를 줄였습니다. 주의 (Attention)를 끌기 위해 경쟁하는 URL이 적어지면 변형 (Mutation)도 줄어듭니다. 이는 실제로 눈에 띄는 효과가 있었습니다. 문서 수를 15개에서 8개로 줄였더니 깨진 링크 비율이 대략 절반으로 감소했습니다. 하지만 근본적인 문제는 여전히 남아 있었고, 8개의 문서가 복잡한 법률 질의에 항상 충분한 것도 아니었습니다.
배치 분할 (More batches). 모든 정보를 담아 한 번에 큰 LLM 호출을 하는 대신, 더 작고 집중된 호출들로 나누었습니다. 이 역시 도움이 되었지만 해결책은 아니었습니다. 또한 지연 시간 (Latency)과 비용이 추가되었습니다.
이 중 그 어떤 것도 허용 가능한 오류율 이하로 낮춰주지는 못했습니다. 깨진 법률 인용문들이 여전히 사용자들에게 전달되고 있었습니다.
"만약에"의 순간
패턴을 이해하려고 이러한 변형 사례 중 하나를 뚫어지게 쳐다보고 있을 때, 무언가 깨달음이 왔습니다.
이 URL들은 길고, 노이즈가 많으며, 서로 거의 동일합니다. 토큰 (Token) 관점에서 보면 이들은 고빈도 상용구 (Boilerplate)처럼 보입니다. 모델은 이를 정밀한 주소로 읽는 것이 아니라, 패턴의 변형으로 읽은 다음 기억으로부터 재구성하고 있는 것이었습니다.
만약 우리가... 모델에게 실제 URL을 아예 주지 않는다면 어떻게 될까요?
아이디어: LLM으로 보내기 전에 모든 URL을 짧은 숫자 코드로 교체합니다. 우리 측에 조회 테이블 (Lookup table)을 유지합니다. 응답을 받은 후에는 코드를 다시 원래대로 바꿉니다.
# LLM으로 보내기 전:
[§ 31](4552013#paragraf-31.odsek-2.pismeno-a) → [§ 31](=1#1)
[§ 65a](4552013#paragraf-65a) → [§ 65a](=1#2)
...
= 접두사는 코드를 시각적으로 구분해 줍니다. 모델은 이를 추론해야 할 값이라기보다 불투명한 토큰 (Opaque tokens)으로 취급하게 됩니다. =1#1은 변형할 만한 의미 있는 내용이 없기 때문에 모델이 이를 변형할 수 없습니다.
구현 방식은 단사 함수이자 전사 함수인 일대일 대응 (Bijective map)입니다. 기본 URI는 정수 ID를 할당받고, 각 기본 URI 내의 프래그먼트 (Fragment)는 하위 인덱스 (Sub-indices)를 할당받습니다.
from uri_shortener import UriShortener
shortener = UriShortener()
...
또한 우리는 토큰 절약이라는 좋은 부수 효과(side effect)도 얻었습니다. 그 긴 법률 URL들은 비용이 많이 듭니다. 이를 =1#1 스타일의 코드로 교체함으로써 컨텍스트 토큰(context token) 사용량을 유의미하게 줄였습니다. 정확한 절약 정도는 컨텍스트에 포함된 URL의 개수와 길이에 따라 다르지만, 문서에 파편(fragment)이 많을수록 더 큰 이득을 얻을 수 있습니다.
해결된 문제
URL 조작 문제: 대부분 해결됨. 만약 LLM이 =1#99와 같은 코드를 만들어냈고 우리가 =1#1부터 =1#3까지만 인코딩했다면, 사용자에게 전달되기 전에 find_hallucinated_codes()를 통해 즉시 이를 감지할 수 있습니다. 이는 매우 큰 성과였습니다.
Gemini와의 유사성
이 문제를 연구하던 중, Google Gemini가 인용 링크(citation links)를 처리하는 방식에서 흥미로운 점을 발견했습니다. Gemini의 응답에서는 답변 내의 링크가 스트리밍(streaming) 도중에는 완전히 해결(resolved)되지 않고, 스트리밍이 완료된 후에 해결됩니다. 우리는 동일한 패턴을 따랐습니다. LLM 호출 전에 인코딩하고, 짧은 코드로 응답을 스트리밍하며, 스트리밍이 완료된 후 디코딩하는 방식입니다.
그 정도 규모의 프로덕션 시스템이 개념적으로 유사한 방식을 사용하는 것을 보며
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기