내 RAG 앱이 약물 복용량을 자신 있게 지어냈습니다. 그래서 '모른다'고 말하도록 다시 만들었습니다.
요약
RAG 시스템이 관련 없는 정보를 바탕으로 잘못된 답변을 생성하는 환각(Hallucination) 문제를 다룹니다. 검색된 청크의 맥락 결여 문제를 분석하고, 시스템이 '모른다'고 답변할 수 있도록 개선하는 과정을 설명합니다.
핵심 포인트
- RAG 시스템은 검색된 정보가 질문과 무관해도 유창한 오답을 생성할 수 있음
- 표준 RAG의 3단계 과정(임베딩-검색-생성)에는 심각한 약점이 존재함
- 단순 청크 단위 검색은 정보의 맥락(Context)을 상실할 위험이 있음
- 시스템이 실패를 인지하고 '모른다'고 말할 수 있는 아키텍처 설계가 중요함
⭐ 저장소(repo) 즐겨찾기: GitHub의 ClinicaQuery-AI
🔔 팔로우하기: Dev.to의 여기와 GitHub에서 저를 팔로우해 주세요. 저는 풀스택 AI 시스템을 구축하며, 수정하는 데 4시간이 걸린 부분들을 포함하여 모든 지저분한 세부 사항들을 기록합니다.
저는 제 의료 연구 어시스턴트에게 한 번도 본 적 없는 논문에 대해 질문했습니다.
그것은 어쨌든 답변했습니다.
유창하고, 자신감 넘치며, 형식이 올바르고, 인용문과 함께 초록색 "높은 신뢰도(high confidence)" 배지까지 달고 말이죠. 그 답변의 모든 부분은 지어낸 것이었습니다. 그것이 인용한 논문은 심혈관 운동에 관한 것이었습니다. 질문은 항생제 복용량에 관한 것이었습니다.
제 시스템에서 고장 난 것은 아무것도 없었습니다. 그 점이 무서운 부분입니다. 검색(Retrieval)은 실행되었고, 가장 관련성이 적은 5개의 문단을 찾아 LLM에 전달했으며, LLM은 LLM이 하는 일을 했습니다. 즉, 무엇을 주든 유창한 텍스트를 생성한 것입니다. 오류도, 예외도, 로그 라인도 없었습니다. 그저 실험실 가운을 입은 잘못된 답변이 있었을 뿐입니다.
이것은 여러분이 첫 RAG 앱을 만들 때 아무도 경고해주지 않는 실패 모드(failure mode)입니다. 여러분의 시스템은 "답을 찾았다"와 "5개의 문단을 찾았다" 사이의 차이를 구분할 수 없습니다. 시스템은 실패라는 개념이 없기 때문에, 결코 실패를 보고할 수 없습니다.
저는 이를 해결하기 위해 2주를 보냈습니다. 해결책이 없었던 의존성 충돌(dependency conflict)과 원래 계획했던 것보다 더 나은 아키텍처를 구축하게 만든 DLL 오류를 포함하여, 제가 배운 모든 것을 여기에 담았습니다.
먼저, RAG란 정확히 무엇인가요? (이미 알고 있다면 건너뛰세요)
이 분야가 처음이라면, 전체 개념을 한 단락으로 정리해 드립니다.
LLM은 여러분의 PDF에 대해 알지 못합니다. 따라서 질문을 하기 전에, PDF를 청크(chunks)로 나누고, 각 청크를 의미를 담은 숫자 리스트인 "임베딩 (embedding)"으로 변환한 다음, 그 숫자들을 데이터베이스에 저장합니다. 질문이 들어오면 질문 역시 숫자로 변환하고, 숫자가 가장 가까운 청크들을 찾아 그 청크들을 프롬프트(prompt)에 붙여넣습니다. 모델은 기억이 아니라 여러분이 건네준 텍스트를 바탕으로 답변합니다. 이것이 검색 증강 생성 (Retrieval-Augmented Generation, RAG)입니다.
표준 RAG (Standard RAG)는 세 단계로 이루어집니다:
질문 임베딩 (embed the question) -> 가장 유사한 5개의 청크 (chunks) 찾기 -> 생성 (generate)
데모 단계에서는 충분히 잘 작동하기 때문에 작업이 끝났다고 생각할 수도 있습니다. 하지만 끝난 것이 아닙니다. 이 세 단계에는 최소 네 가지의 심각한 약점이 있으며, 저는 각각의 약점을 하나씩 살펴보고자 합니다. 왜냐하면 그 해결책이 바로 흥미로운 부분이기 때문입니다.
약점 1: 청크가 어디에서 왔는지 알지 못함
제 시스템에 있는 논문에서 가져온 실제 청크는 다음과 같습니다:
"감소량은 6.9 mmHg (95% CI: 5.2-8.6, p<0.001)였습니다."
무엇이 감소했나요? 누구를 대상으로 측정했나요? 어떤 중재 (intervention) 후에 나타난 결과인가요? 알 수 없습니다. 임베딩 모델 (embedding model) 역시 알 수 없습니다. 해당 청크는 대략 "숫자가 약간 감소했다"는 의미를 가진 벡터로 변환되는데, 이는 쓸모가 없습니다. 이 청크는 실제로 답변해야 하는 질문에 대해 결코 검색 (retrieved)되지 않을 것입니다.
Anthropic의 문맥적 검색 (contextual retrieval) 연구에서 제시한 해결책: 청크를 임베딩하기 전에, LLM에게 해당 청크가 문서 내 어디에 위치하는지 설명하는 문장을 한 문장으로 작성하도록 요청하고, 그 문장을 청크의 앞부분에 붙입니다.
동일한 청크는 다음과 같이 변합니다:
"유산소 운동 시험에 대한 메타 분석 중, 고혈압 성인의 혈압 결과 섹션에서. 감소량은 6.9 mmHg (95% CI: 5.2-8.6, p<0.001)였습니다."
이제 임베딩이 실제로 의미를 갖게 됩니다.
_CONTEXT_PROMPT = """<document>
{doc_preview}
</document>
...
이 방식은 업로드 시점에 청크당 한 번의 작은 LLM 호출 비용이 발생하며, 스레드 풀 (thread pool)에서 실행되므로 14개의 청크로 구성된 논문은 약 6초가 소요됩니다. Anthropic은 이 기술 하나만으로 검색 실패가 49% 감소했음을 측정했습니다.
중요한 세부 사항 하나는, 저는 원문 텍스트를 node.metadata["raw_text"]에 별도로 저장한다는 점입니다. 문맥 문장은 기계가 청크를 찾는 데 도움을 줍니다. 사용자는 여전히 논문이 실제로 말한 내용을 읽어야 합니다.
약점 2: 벡터 검색은 정확한 용어에 취약함
벡터 데이터베이스에 "eNOS phosphorylation at Ser1177"에 대해 물어보면, Ser1177을 전혀 언급하지 않는 산화질소 신호 전달(nitric oxide signalling)에 관한 구절을 아주 즐겁게 반환할 것입니다. 임베딩 공간(embedding space)에서 이 두 가지가 바로 옆에 붙어 있기 때문입니다.
임베딩(Embeddings)은 의미를 포착하며, 의미는 설계상 모호합니다. 이는 보통 기능(feature)으로 작용합니다. 하지만 의학 분야에서는 결함(liability)이 됩니다. 의학은 약물 이름, 유전자 기호(gene symbols), ICD 코드, 용량(dosages), p-값(p-values)과 같은 정확한 문자열(exact strings)을 기반으로 작동하기 때문입니다. 이것들이 바로 임베딩이 서로 뭉뚱그려 버리는 정확한 토큰(tokens)들입니다.
해결책: 벡터 검색(vector search)과 함께 키워드 검색(keyword search)을 실행하세요. BM25는 여기서 사용되는 고전적인 알고리즘이며, 문자 그대로의 토큰을 즉시 찾아냅니다.
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:[-_][a-z0-9]+)*")
def tokenize(text: str) -> list:
...
저 정규 표현식(regex)은 ser1177, covid-19, il-6 등을 의미 없는 파편으로 쪼개는 대신 온전하게 유지하도록 의도적으로 설계되었습니다.
저는 프로세스 내에서 실행되고 pickle 파일로 저장되는 rank_bm25를 사용했습니다. Elasticsearch 클러스터도, Docker도, 계속 켜두어야 할 두 번째 서비스도 필요 없습니다. 단일 인스턴스 앱에 딱 적당한 수준의 인프라입니다.
약점 3: 두 점수를 단순히 더할 수는 없습니다
이제 두 개의 순위 목록(ranked lists)을 갖게 되었습니다. 이들을 병합하는 과정에서 대부분의 사람들이 자신도 모르게 시스템을 망가뜨립니다.
뻔한 방법은 두 점수를 모두 0-1 사이로 정규화(normalise)하여 더하는 것입니다. 이렇게 하지 마세요. 코사인 유사도(Cosine similarity)는 범위가 제한되어 있고 대략 정규 분포를 따릅니다. 반면 BM25는 범위가 제한되지 않으며 문서 길이에 의해 심하게 왜곡됩니다. 이들을 최소-최대 정규화(Min-max normalising)하면 단 하나의 튀는 BM25 점수가 다른 모든 점수를 좁은 범위로 압착해 버리고, 여러분의 순위는 조용히 노이즈(noise)가 되어 버립니다.
해결책: 점수는 버리고 순위(ranks)만 유지하세요.
def reciprocal_rank_fusion(ranked_lists, k=60):
scores = {}
for results in ranked_lists:
...
이것이 알고리즘의 전부입니다. 2009년 SIGIR 논문에서 소개된 상호 순위 결합 (Reciprocal Rank Fusion, RRF)입니다. 상수 60은 댐퍼 (damper) 역할을 합니다. 순위 1은 1/61을 기여하고 순위 2는 1/62를 기여하므로, 단일 리스트가 지배적일 수 없으며 문서가 승리하려면 여러 리스트에서 좋은 순위를 차지해야 합니다.
Elasticsearch, Weaviate, Azure AI Search 모두 이를 사용합니다. 대부분의 발표된 비교 연구에서 정교하게 조정된 점수 정규화 (score normalisation)보다 더 나은 성능을 보입니다. 단 여섯 줄이면 됩니다.
여기에는 무료 보너스도 있습니다. 만약 특정 청크 (chunk)가 벡터 결과와 키워드 결과 모두에 나타난다면, 이는 강력한 신호입니다. 따라서 저는 이를 추적하여 UI에 "both" 배지로 표시합니다.
item["retriever_agreement"] = len(ranks[nid])
약점 4: 리트리버 (retriever)는 비교하도록 학습된 적이 없다
이것은 구조적인 문제이며, 가장 큰 개선을 이끌어낼 수 있는 부분입니다.
여러분의 벡터 데이터베이스 (vector database)는 **바이-인코더 (bi-encoder)**를 사용합니다. 이는 누군가 질문을 하기 훨씬 전인 업로드 시점에 청크를 임베딩 (embedding)합니다. 따라서 해당 청크의 벡터는 미래의 질문에 대한 정보가 전혀 없는 상태에서 생성된 압축된 요약본입니다. 그런 다음 질문을 별도로 임베딩하여 두 벡터를 비교합니다. 모든 것이 미리 계산되어 있기 때문에 빠릅니다. 하지만 같은 이유로 정보 손실 (lossy)이 발생합니다.
**크로스-인코더 (cross-encoder)**는 질문과 청크를 한 번의 패스 (pass)로 함께 읽고 단일 관련성 수치를 출력합니다. 이 방식은 본문의 "150분"이 질문의 "몇 분"에 대한 답이라는 것을 인지할 수 있습니다. 사전에 아무것도 압축되지 않았기 때문입니다.
문제는 아무것도 미리 계산할 수 없다는 점이며, 따라서 후보 하나당 한 번의 모델 호출이 필요합니다. 전체 코퍼스 (corpus)에 대해 이 방식을 실행할 수는 없습니다. 패턴은 항상 다음과 같습니다.
저렴하게 30개의 후보를 검색 (retrieve) -> 그 30개를 정밀하게 재순위화 (rerank) -> 5개를 유지
제 UI에서는 리랭커 (reranker)에 의해 승격된 모든 소스에 녹색 +4 배지가 표시됩니다. 이를 확장하면 rank 7 to 2와 같이 표시됩니다. 이는 벡터 검색이 7위로 매긴 항목을 리랭커가 포착하여 적절한 위치로 옮겼음을 의미합니다.
약점 5: 실제로 나를 두렵게 했던 것
위의 모든 사항은 검색 (retrieval) 성능을 향상시킵니다. 하지만 정답이 문서 안에 아예 존재하지 않을 때는 그 어떤 것도 도움이 되지 않습니다.
**CRAG (Corrective RAG)**는 검색 (retrieval)과 생성 (generation) 사이에 평가기 (grader)를 추가하며, 결정적으로 평가 점수가 낮을 때 파이프라인이 나아갈 방향을 제시합니다:
if max_score >= 0.70:
action = "correct" # 지금 생성
elif max_score >= 0.35:
...
마지막 분기(branch)가 바로 제가 이 시스템을 다시 만든 핵심 이유입니다.
if action == "incorrect" or not documents:
with trace.span("7_generation") as span:
span.attributes["skipped"] = True
...
생성기 (generator)가 실행되지 않습니다. 생성할 정답이 없으므로, 아무것도 생성되지 않습니다.
제가 고민했던 설계상의 한 가지 노트는 다음과 같습니다. 저는 평균값이 아닌 가장 좋은 (best) 문서를 기준으로 평가합니다. 하나의 훌륭한 출처만 있어도 답변을 잘 할 수 있으며, 평균을 내는 방식은 우연히 느슨하게 관련된 청크 (chunks) 몇 개가 함께 검색되었을 때의 훌륭한 검색 결과를 오히려 저해하기 때문입니다.
이제 제 심장학 논문에 아모시실린 (amoxicillin) 복용량에 대해 물어보면, 인덱싱된 문서에 정답이 없다고 명확하게 말해줍니다. 이것은 한계가 아닙니다. 이것이 바로 핵심 기능 (feature)입니다.
보너스: 버그처럼 들리는 기술, HyDE
밀집 검색 (dense retrieval)에는 조용한 비대칭성이 존재합니다. 당신은 **질문 (question)**을 임베딩 (embed)하고, **답변 (answers)**의 임베딩을 대상으로 검색합니다. 그것들이 어떻게 생겼는지 보십시오:
question: "운동은 혈압을 얼마나 낮추나요?"
passage : "89회의 임상 시험 결과, 수축기 혈압이 평균 6.9 mmHg 감소했습니다."
공유되는 어휘가 거의 없습니다. 문법도 다르고, 어조 (register)도 다릅니다. 이들의 임베딩은 당신이 원하는 것보다 더 멀리 떨어져 있으며, 검색기 (retriever)는 아무도 메우도록 훈련시키지 않은 간극을 메우라는 요구를 받고 있는 셈입니다.
HyDE는 쿼리 (query)를 먼저 답변 공간 (answer-space)으로 이동시킴으로써 이 간극을 메웁니다. LLM에게 그럴듯한 답변을 작성하도록 요청하고, 그것이 사실인지 여부는 무시한 채 그 답변을 임베딩하는 것입니다.
네, 당신은 의도적으로 환각 (hallucination)을 생성하고 그것으로 검색을 수행하는 것입니다. 이것이 작동하는 이유는 가짜 구절 (passage)이 실제 구절과 유사한 형태를 띠고 있어, 벡터 공간 (vector space)에서 실제 구절 근처에 위치하게 되어 인덱스에서 진짜 출처를 끌어내기 때문입니다.
환각 (hallucination)은 사용자에게 절대 도달하지 않습니다. 검색 (retrieval)이 끝나는 즉시 폐기됩니다. 그것의 유일한 역할은 올바른 형태를 갖추는 것뿐입니다.
이제 모든 것이 망가진 부분
여섯 개의 깔끔한 레이어. 아름다운 아키텍처. 그러고 나서 저는 pip install -r requirements.txt를 실행했습니다.
제1막: 로드되지 않는 DLL
ImportError: DLL load failed while importing onnxruntime_pybind11_state
ChromaDB는 제가 전혀 사용하지 않는 기본 임베딩 함수 (default embedding function)를 위해 모듈 로드 시점에 onnxruntime을 임포트합니다. 제 임베딩은 Gemini에서 가져오기 때문입니다. 하지만 상관없었습니다. ChromaDB가 임포트되려면 해당 임포트가 반드시 성공해야 했고, 앱 전체가 첫 번째 줄에서 죽어버렸습니다.
onnxruntime<1.20으로 버전을 고정 (pinning)하니 해결되었습니다. 그리고 이는 즉시 다음 문제를 일으켰습니다:
제2막: 다른 것을 망가뜨린 해결책
opentelemetry-proto 1.42.1 requires protobuf<7.0,
but you have protobuf 7.35.1 which is incompatible.
onnxruntime을 설치하면서 최신 버전의 protobuf가 딸려 들어왔고, 이것이 ChromaDB의 텔레메트리 계측 (telemetry instrumentation)을 망가뜨렸습니다. protobuf 버전을 고정합니다. 그리고 다음으로 넘어갑니다.
제3막: 해결책이 없는 충돌
이 부분이 제가 이야기하고 싶은 내용입니다.
llama-index 0.14.23 requires llama-index-llms-openai>=0.7.0
llama-index-llms-openai-like 0.5.3 requires llama-index-llms-openai<0.6
저 두 줄을 다시 읽어보세요. 패키지 A는 7 버전 이상을 필요로 합니다. 패키지 B는 6 미만의 버전을 필요로 합니다. llama-index-llms-groq가 openai-like에 의존하기 때문에 두 패키지 모두 제 의존성 트리 (dependency tree)에 들어와 있습니다.
두 조건을 모두 만족하는 버전은 존재하지 않습니다. 저는 pip가 두 패키지 사이를 네 번이나 왔다 갔다 하며, 설치할 때마다 한쪽을
stream = _get_llm().chat.completions.create(
model=_LLM_MODEL,
messages=[{"role": "user", "content": prompt}],
...
충돌 해결. 영구적으로. 더 적은 패키지, 더 적은 추상화 (abstraction), 그리고 스트림 (stream)에 대한 더 큰 제어권.
내가 실제로 얻은 교훈: 의존성 충돌 (dependency conflict)에 해결책이 없을 때는, 해결하려고 애쓰는 것을 멈추고 그 의존성이 당신에게 무엇을 제공하고 있었는지 묻기 시작해야 합니다. 때로는 그것이 프레임워크 (framework)일 수도 있습니다. 때로는 당신이 직접 작성할 수 있는 6줄의 코드를 감싸고 있는 래퍼 (wrapper)일 뿐이며, 당신은 그 코드를 직접 타이핑하는 것을 피하기 위해 의존성 트리 (dependency tree)를 빌려 쓰고 있었던 것일 수도 있습니다.
제4막: 아키텍처를 개선한 오류
마지막 하나입니다. 그리고 제가 가장 좋아하는 부분인데, 왜냐하면 그 오류가 시스템을 진정으로 더 낫게 만들었기 때문입니다.
[reranker] local model unavailable
([WinError 1114] A dynamic link library (DLL) initialization
routine failed. Error loading torch\lib\c10.dll)
Windows에서 PyTorch가 로드되지 않았습니다. 이는 나의 교차 인코더 (cross-encoder)가 로드되지 않음을 의미했습니다. 즉, 코드 자체에는 아무런 문제가 없는 머신에서 전체 파이프라인 (pipeline) 중 가장 영향력이 큰 단일 단계가 작동 불능 상태가 되었음을 의미했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기