당신의 RAG 평가(Evals)는 거짓말을 하고 있습니다. 버그는 LLM이 아니라 검색(Retrieval)에 있습니다.
요약
RAG 시스템 평가 시 발생하는 성능 변동의 원인이 LLM이 아닌 검색(Retrieval)의 비결정성에 있음을 분석합니다. 동일한 쿼리에도 검색 결과가 달라지는 현상을 통해 잘못된 모델 평가를 방지하는 방법을 제시합니다.
핵심 포인트
- RAG 평가 수치 변동의 주범은 LLM이 아닌 검색 노이즈일 수 있음
- 검색 결과가 매번 달라지는 비결정성 문제를 반드시 확인해야 함
- ANN 인덱스의 타이 브레이킹(Tie-breaking)이 검색 순서에 영향을 줌
- 하이브리드 검색 시 병렬 리트리버 사용이 변동성을 유발할 수 있음
나는 LLM이 결코 문제의 원인이 아니었다는 사실을 인정하기 전까지, 나의 RAG 평가(Evaluation) 파이프라인을 세 번이나 다시 구축했습니다.
문제는 검색(Retrieval)이었습니다. 그리고 평가는 모델의 품질이 아니라 검색 노이즈(Retrieval noise)를 측정하고 있었습니다. 6주 동안 내가 내렸던 모든 "모델이 점점 나빠지고 있다"는 결론은, 사실 동일한 쿼리에 대해 실행할 때마다 검색(Retrieval)이 서로 다른 청크(Chunk)를 반환했기 때문에 발생한 것이었습니다.
만약 당신이 RAG 변경 사항을 배포하고, 평가(Eval) 대시보드가 흔들리는 것을 보며 "파인튜닝(Fine-tuning)이 필요해" 또는 "더 큰 모델이 필요해"라고 결론을 내린 적이 있다면 — 이 글은 내가 가장 먼저 읽었어야 했던 글입니다.
증상: 불안정한 평가 (Flaky Evals)
무슨 일이 일어나고 있었는지 설명하겠습니다. 나에게는 "골든(Golden)" 평가 세트가 있었습니다. 200개의 질문/답변 쌍으로 구성되었으며, 각 쌍은 알려진 정답 코퍼스(Corpus) 내의 알려진 정답 청크 ID(Chunk ID)를 가지고 있었습니다. 나는 동일한 모델, 동일한 temperature=0, 동일한 코퍼스, 동일한 프롬프트(Prompt)를 사용하여 동일한 평가를 세 번 연속으로 실행했습니다.
결과 수치는 매번 달랐습니다. 조금 차이가 나는 수준이 아니라, 동일한 커밋(Commit)에서 회귀 테스트(Regression test)의 결과를 초록색(통과)에서 빨간색(실패)으로 바꿀 수 있을 정도였습니다.
Run 1: recall@5 = 0.78 faithfulness = 0.84
Run 2: recall@5 = 0.71 faithfulness = 0.81
Run 3: recall@5 = 0.74 faithfulness = 0.79
내가 가장 먼저 한 일은 LLM을 의심하는 것이었습니다. 두 번째로 한 일은 temperature를 0으로 낮추는 것이었습니다. 세 번째로 한 일은 특정 모델 버전을 고정하는 것이었습니다. 그 중 어느 것도 분산(Variance)을 의미 있게 변화시키지 못했습니다.
네 번째로 내가 한 일은 검색된 청크 ID(Retrieved chunk IDs)를 기록하는 것이었습니다. 그때 모든 것이 명확해졌습니다.
Question: "How do I configure SSL offloading on the edge gateway?"
Run 1: chunk_ids = [a17, b04, c91, d22, e55]
Run 2: chunk_ids = [a17, b04, c92, d22, e58] ← c91 → c92, e55 → e58
...
동일한 쿼리. 동일한 코퍼스. 하지만 다른 청크들. LLM은 전달받은 데이터로 최선을 다하고 있었고, 평가는 머리(LLM)가 아니라 손(검색)을 측정하고 있었던 것입니다.
검색 비결정성(Retrieval Non-Determinism)의 세 가지 원인
조사를 시작하자, 동일한 코퍼스에서 동일한 쿼리가 서로 다른 청크를 반환할 수 있는 세 가지 지점을 발견했습니다.
1. 근사 최근접 이웃 (Approximate Nearest Neighbor, ANN) 타이 브레이킹 (Tie-breaking).
대부분의 벡터 인덱스(Vector Index) — HNSW, IVF, ScaNN — 는 두 벡터가 쿼리로부터 거의 동일한 거리에 있을 때 약간씩 다른 순서를 반환합니다. 점수는 소수점 넷째 자리까지 동일해 보이지만, 순서는 뒤바뀝니다. 만약 당신의 top-k=5가 우연히 동점 경계(tie boundary)에 걸린다면, 이 교체 현상이 전파됩니다.
2. 병렬 리트리버(Parallel Retrievers)를 사용한 하이브리드 검색 (Hybrid Search).
BM25와 밀집 검색 (Dense Retrieval)을 결합하는 경우, 일반적으로 두 개의 병렬 호출과 퓨전(Fusion) 단계가 있습니다. 어떤 작업이 먼저 완료되는지, 배치(Batching) 순서, 그리고 동시적인 인덱스 수정 여부에 따라 퓨전 가중치가 미세하게 변할 수 있습니다. 이는 4위와 5위의 결과를 바꿀 만큼 충분한 차이를 만듭니다.
3. 재수집(Re-ingestion) 후의 청킹 드리프트 (Chunking Drift).
이것은 아무도 의심하지 않는 부분입니다. 만약 당신의 수집 파이프라인(Ingestion Pipeline)이 오버랩 윈도우(Overlap windows), 문장 경계 스냅핑(Sentence-boundary snapping), 또는 병렬 청커(Parallel chunker)와 같이 어떤 무작위성을 가지고 코퍼스(Corpus)를 청킹했다면, 재수집 후에 평가 세트(Eval set)의 청크 ID가 실제 운영 인덱스의 청크 ID와 일치하지 않을 수 있습니다. 평가는 더 이상 존재하지 않는 청크를 대상으로 채점하고 있는 것입니다.
해결책: 평가를 위한 결정론적 검색 (Deterministic Retrieval)
해결책은 운영 환경(Production)에서의 검색을 결정론적으로 만드는 것이 아닙니다. 운영 환경의 검색은 약간의 확률적(Stochastic) 특성을 가져도 괜찮습니다. 모델이 이를 보완할 수 있으며, 약간의 변동은 허용됩니다. 해결책은 평가용 검색을 운영용 검색으로부터 분리(Decouple)하는 것입니다.
from dataclasses import dataclass
from typing import Optional
import hashlib
...
핵심은 oversample_factor입니다. 필요한 것보다 4배 더 많은 후보를 검색한 다음, LLM을 사용하여 결정론적으로 재순위화(Rerank)합니다. 20개의 후보에 대한 top-k는 가공되지 않은 5개의 ANN 결과에 대한 top-k보다 훨씬 안정적입니다. 왜냐하면 LLM 재순위화기(Reranker) 자체가 temperature=0에서 대부분 결정론적이기 때문입니다.
def deterministic_retrieve(
query: str,
index,
...
청크 버전 고정 (Pin the Chunk Version)
이것은 제가 두 번이나 놓쳤던 부분입니다. 당신의 평가 세트에는 청크 ID가 포함되어 있습니다. 만약 당신이 청커(Chunker)를 변경한다면 — 설령 chunk_size를 512에서 480으로 조정하는 정도라 할지라도 — 모든 청크 ID는 무효화됩니다.
해결책: 청킹 설정(chunking config)과 코퍼스(corpus)를 해싱(hash)하고, 해시가 일치하지 않으면 평가(eval) 실행을 거부하도록 합니다.
def corpus_chunk_hash(chunker_config: dict, corpus_path: str) -> str:
"""청커 설정(chunker config) + 코퍼스의 콘텐츠 해시를 해싱합니다."""
config_str = json.dumps(chunker_config, sort_keys=True)
...
누군가 PR(Pull Request)에서 청커 설정을 살짝 변경한 것이 원인이 되어, 일주일 동안 "평가 회귀(eval regression)"를 추적하기 전까지는 이 방법이 지나치게 까다롭다고 느껴질 수 있습니다. 하지만 이제 평가는 스스로의 결과와 조용히 불일치하는 대신, 명확한 메시지와 함께 요란하게 실패를 알립니다.
이후 수치 변화
ef_search를 고정하고, 오버샘플 리랭크(oversample rerank)를 추가하며, 청크 버전에 대한 게이트(gate)를 설정한 후, 동일한 평가 세트(eval set)를 실행한 결과는 다음과 같습니다:
Run 1: recall@5 = 0.78 faithfulness = 0.84
Run 2: recall@5 = 0.78 faithfulness = 0.84
Run 3: recall@5 = 0.78 faithfulness = 0.84
마지막 소수점 자리까지 동일합니다. 이제 평가 결과가 변한다면, 그것은 모델이 변했거나, 코퍼스가 변했거나, 혹은 청커가 변했기 때문입니다. 검색(retrieval)이 동전 던지기를 하고 있기 때문이 아닙니다.
진정한 승리는 이 이후에 찾아왔습니다. 마침내 평가 결과를 신뢰할 수 있게 된 것입니다. 제가 "노이즈(noise)"라며 무시했던 세 가지 변경 사항 — 리랭커(reranker) 교체, 청커 크기 미세 조정, 하이브리드 가중치 조정 — 이 실제적인 개선 사항이었음이 드러났습니다. 평가는 이미 그것들에 대해 옳게 말하고 있었지만, 분산(variance)이 너무 커서 신호(signal)를 볼 수 없었을 뿐입니다.
"내 모델이 더 나빠졌다"라고 말하기 전에 던져야 할 질문
RAG 평가 결과가 변해서 모델을 탓하고 싶은 유혹이 든다면, 대신 다음과 같이 질문하십시오:
- 동일한 쿼리를 두 번 실행했을 때 청크 ID(chunk IDs)가 일치하는지 확인했는가?
- 매 실행마다
ef_search(또는 그에 상응하는 설정)가 동일한가? - 평가가 구축된 이후로 청커 설정(chunker config)이 변경되지 않았는가?
- 리랭킹(reranking) 전에 오버샘플링(oversampling)을 하고 있는가, 아니면 LLM이 가공되지 않은 ANN top-k를 보고 있는가?
만약 이 중 하나라도 "모른다" 또는 "아니오"라면, 평가는 당신이 측정하고자 하는 것을 측정하고 있는 것이 아닙니다. 검색(retrieval)의 유죄가 입증될 때까지 모델은 무죄입니다.
평가 세트(eval set)는 진실의 근원(source of truth)이며, 진실의 근원은 재현 가능(reproducible)해야 합니다. 만약 그렇지 않다면, 당신이 배포하는 모든 "개선(improvement)"은 동전 던지기와 다를 바 없습니다. 대시보드에서 개선된 것처럼 보이는 것들이 사실은 평가 과정에서 계속 숨겨져 왔던 노이즈(noise)일 수도 있습니다.
내가 배운 것
검색(Retrieval)은 가장 마지막에 비난받고, 가장 먼저 실패하는 계층입니다.
나는 평가가 불안정하다는 이유로 모델에 문제가 있다고 생각하며 6주를 허비했습니다. 평가를 재현 가능하게 만든 후(ANN 검색 파라미터 고정, 리랭크(rerank) 전 오버샘플링(oversampled) 수행, 청크(chunk) 버전에 따른 게이팅(gated) 적용), 불안정함은 사라졌고 실제 개선 사항들이 드러났습니다. "도움이 되지 않는다"며 롤백(roll back)했던 것들 중 세 가지가 알고 보니 5~10%의 확실한 승리였다는 것이 밝혀졌습니다.
만약 당신이 프로덕션 환경에서 RAG를 구축하고 있는데 평가가 불안정하다면, 버그는 거의 절대 LLM의 문제가 아닙니다. 그것은 검색(retrieval)의 문제입니다. 그리고 해결책은 수천 줄의 파인튜닝(fine-tuning)이 아니라, 수백 줄의 설정(config)에 있습니다.
만약 당신이 검색의 비결정성(non-determinism)에 관한 다른 원인을 발견했다면, 진심으로 알고 싶습니다. 특히 하이브리드 퓨전(hybrid fusion)이나 그래프 증강 검색(graph-augmented retrieval)과 관련된 것이라면 더욱 그렇습니다. "시드(seed)를 설정하라"는 조언은 일반적인 ANN에는 효과가 있습니다. 그 외의 경로들은 아직 덜 정립되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기