본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 09. 18:37

검색 버그인가 모델 버그인가 - RAG 시스템을 구분하여 테스트하기

요약

RAG 시스템의 오류 원인이 검색(Retrieval) 단계인지 생성(Generation) 단계인지 구분하기 위한 테스트 방법론과 테스트 스위트를 소개합니다. 검색 결과의 정확도와 모델의 환각 현상을 분리하여 검증하는 것이 시스템 개선의 핵심임을 강조합니다.

핵심 포인트

  • RAG 오류는 검색 실패와 모델 생성 오류로 구분하여 테스트해야 함
  • 벡터 검색과 BM25 검색을 결합한 하이브리드 리트리버 구조 활용
  • 문서에 없는 지식을 실제 지식인 것처럼 답변하는 환각 현상 주의
  • 검색 결과와 모델 답변의 일치성을 검증하는 테스트 스위트 구축 필요

저는 자동화 테스터입니다. 보통 제 업무는 간단합니다. 동일한 입력은 매번 동일한 출력을 내놓아야 한다는 것이죠. 하지만 언어 모델 (Language models)은 그렇게 작동하지 않습니다. 같은 질문을 두 번 던지면 서로 다른 두 개의 답변을 얻을 수 있으며, 두 답변 모두 정답일 수 있습니다.

RAG 시스템 — 검색 증강 생성 (Retrieval-Augmented Generation) — 은 상황을 더욱 어렵게 만듭니다. 이 시스템은 사용자의 문서를 검색하고, 찾아낸 내용으로부터 모델이 답변을 작성하게 합니다 (PDF와 채팅하거나, 회사의 도움말 페이지를 기반으로 답변하는 지원 봇 등). 따라서 잘못된 답변에는 두 가지 가능한 원인이 있습니다. 검색이 잘못된 페이지를 선택했거나, 올바른 페이지를 선택했음에도 모델이 여전히 틀린 답변을 내놓았거나 하는 것입니다. 사용자에게 이 두 가지는 똑같아 보입니다. 하지만 이는 서로 다른 문제이며 해결 방법도 다릅니다. 만약 테스트가 이 둘을 구분할 수 없다면, 당신은 어느 쪽을 수정해야 할지 알 수 없게 됩니다.

그래서 저는 작은 RAG 시스템과 이 둘을 구분하기 위해 설계된 테스트 스위트 (test suite)를 구축했습니다.

Repo: https://github.com/sbezjak/llm-rag

구성 요소

청크 (chunk) 단위로 나뉘고 인덱싱된 (corpus) 12페이지 분량의 pytest 문서입니다. 시스템은 두 개의 서비스가 일렬로 연결된 구조입니다: 가장 관련성 높은 청크를 찾는 리트리버 (retriever), 그리고 해당 청크들을 읽고 인용(citations)과 함께 답변을 작성하는 제너레이터 (generator) (llama3.2, Ollama를 통해 로컬에서 실행)입니다.

query -> [ vector search ] + [ BM25 search ]
      -> fuse (RRF) -> rerank -> top 5
      -> generator -> answer + citations

리트리버는 네 단계를 거칩니다: 의미 기반의 벡터 검색 (vector search)과 키워드 기반의 BM25 검색이 동시에 실행되고, 그 다음 단계에서 두 검색 결과의 순위를 병합(merge)하며, 그 후 리랭커 (reranker)가 각 최종 후보군을 질문과 대조하여 다시 읽고 가장 좋은 5개를 유지합니다. 대부분의 버그는 리트리버에서 제너레이터로 넘어가는 단계에서 숨어 있습니다.

그다음, 답변이 잘못될 수 있는 각 방식에 대해 다섯 그룹의 테스트를 구성했습니다:

테스트 그룹포착하는 내용
Retrieval (검색)검색이 올바른 페이지를 전혀 찾지 못함
...

모델이 거절해야 할 질문에 답변함

시스템에 답을 알 수 없는 질문 6개를 던지며 "모르겠습니다"라고 말하라고 지시했습니다. 그중 5개는 거절했습니다. 여섯 번째 질문은 "나만의 pytest 플러그인을 어떻게 작성하나요?"였습니다. 이는 실제 pytest 기능이지만, 제가 제공한 12페이지 분량의 문서에는 없는 내용이었습니다. 그럼에도 모델은 답변을 내놓았습니다. 실제 pytest 문법도 아닌 @pytest.addoption을 중심으로 구성된, 자신만만한 2,000자 분량의 답변이었습니다. 그리고 모델은 실행할 때마다 매번 이렇게 행동합니다. 주제에서 벗어난 질문은 거절하기 쉽습니다. 하지만 문서에는 없지만 실제 주제인 경우를 다루는 것이 어려운 과제입니다. 모델은 그 공백을 실제 pytest처럼 보이는 내용으로 채워버립니다. 이것이 바로 프로덕션(production) 환경에서 중요한 사례입니다.

또한 모델은 그 지어낸 답변에 대해 출처를 인용했는데, 그 페이지는 코퍼스(corpus)에 없는 페이지였습니다. 저의 환각(hallucination) 테스트가 이를 잡아냈어야 했지만, 테스트 자체의 버그 때문에 잡아내지 못했습니다. 인용을 확인하기 위해, 테스트는 먼저 실제 페이지가 아닌 모든 ID를 제거한 다음 남은 것들을 살펴보았습니다. 아무것도 남지 않았고, "인용된 것이 없음"은 깔끔한 거절의 전형적인 모습이기에, 조작된 인용이 거절로 통과되어 버렸습니다. 해당 제거 단계는 이 테스트를 위해 작성된 것이 아니었습니다. 그것은 모델이 실제로 전달받은 ID만 유지하는 것이 올바른 동작인 생성기(generator)에서 가져온 것이었습니다. 저는 가정을 재확인하지 않고 여기서 그 함수를 재사용했는데, 환각 테스트에서는 제거된 ID가 바로 핵심입니다. 저는 인용을 반대 방향으로 확인함으로써 겨우 이를 발견할 수 있었습니다. 즉, 모델이 작성한 모든 ID를 실제든 가짜든 그대로 유지하고, 인덱스(index)에 없는 것은 모두 플래그(flag)를 지정하는 방식입니다. 환각을 잡아내기 위해 만든 테스트가 정작 자신이 잡아내려 했던 바로 그 사례를 놓칠 뻔했습니다.

순수 함수 이름으로 검색했을 때 아무것도 찾지 못함

검색창에 pytest.warns를 입력했습니다. 주변 내용 없이 오직 함수 이름만 입력했을 뿐인데, 해당 페이지가 인덱스에 있음에도 불구하고 4단계 모두 그 페이지를 찾아내지 못했습니다.

흥미로운 점은 그 이유가 예상치 못한 곳에 있다는 것입니다. 의미론적 검색 (Meaning-search)은 용어 주변의 문맥 (context)이 필요합니다. 함수 이름만으로는 아무런 정보도 제공하지 못하므로, 관련 페이지("warnings", "exceptions")로 표류하게 됩니다. 키워드 검색 (Keyword-search)은 정확한 문자열을 포착해야 하지만, pytest가 모든 청크 (chunk)에 포함되어 있어 아무런 신호 (signal)를 주지 못하며, warns 단독으로는 올바른 페이지를 끌어올리기에 충분하지 않습니다. 결과적으로 두 검색 방식 모두 실패합니다. 병합 (merge) 단계는 단순히 두 목록을 결합할 뿐이며, 두 검색 모두에서 반환되지 않은 청크를 찾아낼 수는 없습니다. 그리고 리랭커 (reranker)는 전달받은 것들의 순서만 재조정할 뿐이므로, 올바른 페이지가 리랭커에 도달조차 하지 못합니다.

더 나은 검색 엔진을 도입한다고 해결될 문제가 아닙니다. 해결책은 상류 (upstream)에 있습니다. 즉, 단순한 쿼리 (query)를 문맥이 포함된 형태로 다시 작성하거나, 식별자 (identifier)를 인덱싱할 때 주변 단어들과 함께 인덱싱하여 매칭될 수 있는 요소를 만들어야 합니다.

리랭커가 올바른 페이지를 찾았지만, 잘못된 페이지를 첫 번째로 순위를 매기는 경우

리랭커는 마지막 단계로, 선정된 각 청크를 질문과 대조하여 다시 읽고 순서를 재조정합니다. 16개의 쿼리 중 10개에서 잘못된 청크를 첫 번째로 배치했습니다. 아예 놓친 것이 아니라 — 올바른 청크는 거의 항상 상위 5위 안에 들었습니다 — 실제 정답인 하위의 좁은 섹션 (subsection) 대신, 광범위한 페이지 개요 (page-overview) 청크가 앞 순위에 놓인 것입니다. 이는 이 프로젝트에서 가장 큰 단일 클러스터 (cluster)였으며, 고정된 실패 사례 26개 중 10개를 차지했습니다.

리랭커는 청크가 쿼리에 대해 얼마나 ".관한(about)" 것인지 점수를 매깁니다. 페이지 개요 청크는 쿼리의 용어를 더 많이 언급하고 더 넓은 주제를 다루기 때문에, 단 하나의 특정 질문에만 답하는 좁은 섹션보다 "이 주제에 대해 더 많이 다루고 있다"라고 읽힙니다. 문제는 쿼리에 대해 가장 ".관한(about)" 것이라는 점과 쿼리에 가장 ".유용한(useful)" 것이라는 점이 같지 않다는 것입니다. 개요는 주제에 더 가깝고 (topical), 섹션은 관련성이 더 높습니다 (relevant). 리랭커는 전자를 최적화하고 사용자는 후자를 필요로 하므로, 광범위한 청크가 이겨서는 안 될 경쟁에서 승리하게 됩니다. 이런 현상을 직접 확인할 수 있습니다: "how do I clear the cache"라고 물으면, 실제 --cache-clear 플래그 (flag)를 설명하는 단락보다 캐시 페이지의 도입부를 더 높은 순위로 배치합니다.

이것은 코드의 버그가 아닙니다. 이 특정 기성 (off-the-shelf) 리랭커 (bge-reranker-base)가 이와 같은 문서에서 작동하는 방식이며, 인터페이스가 단 하나의 최선의 답변만을 보여주는 모든 곳에서 문제가 됩니다. 대략적인 노력의 순서에 따라 세 가지 실제 해결책이 있습니다: 단 하나의 1위 답변 대신 상위 몇 개의 후보를 보여주어, 순위가 약간 잘못 매겨진 답변이라도 화면에 남아 있게 하는 것; 리랭커를 자체적인 질의응답 (query-and-answer) 쌍으로 미세 조정 (fine-tune)하여, 귀하의 콘텐츠에 대해 하위 섹션이 개요보다 우선한다는 것을 학습시키는 것; 또는 더 강력한 리랭커로 교체하는 것입니다. 당신이 할 수 없는 것은 1위 답변을 있는 그대로 신뢰하는 것입니다.

의미 검색 (Meaning search)이 상위 5위 바로 밖에 있는 올바른 페이지를 묻어버리는 경우

이것이 4단계 테스트가 필요한 사례입니다. pytest -m select tests by marker와 같은 질의에 대해, 의미 검색 (meaning-search)은 올바른 페이지를 6위로 배치했습니다. 즉, 상위 10위 안에는 있었지만 사용자가 실제로 보는 상위 5위 바로 바깥에 있었습니다. 키워드 검색 (Keyword-search)은 리터럴 pytest -m을 포착했고, 병합 (merge) 단계가 이를 상위 5위로 끌어올렸으며, 리랭커가 이를 1위로 배치했습니다. 이것이 검색들을 결합해야 하는 전체 논거입니다. 플래그와 함수 이름으로 가득 찬 사람들이 실제로 입력하는 질의는, 의미 검색이 인터페이스가 보여주는 정확한 범위 내에서 순위를 약간 낮게 매기는 바로 그 질의들입니다. 재현율 (Recall)과 순위 지정 (Ranking)은 별개의 문제이며 별개의 단계로 해결됩니다. 상위 5위만을 확인하는 테스트는 순위 지정 버그를 조용히 통과하게 되므로, 각 단계마다 별도의 테스트를 수행해야 합니다.

분리가 중요한 이유

가장 명확한 사례는 다음과 같습니다. 한 답변이 유사도 (similarity) 점수에서 통과 기준선 아래인 0.588을 기록했습니다. 이는 모델이 부실한 답변을 작성한 것처럼 보였습니다. 하지만 그렇지 않았습니다. 하나의 청크 (chunk)에 가공되지 않은 문서 마크업 (markup) 조각이 남아 있었고, 모델이 그 마크업을 답변에 그대로 복사한 것이었습니다. 버그는 모델이 아니라 문서를 정제하는 방식에 있었습니다. 실패한 테스트가 검증된 양질의 컨텍스트 (context) 상에서 실행되는 생성 (generation) 테스트였기 때문에, 저는 검색 (retrieval) 단계는 문제가 없음을 알고 올바른 곳을 찾아낼 수 있었습니다. 마크업을 정제하자 점수는 0.588에서 0.684로 상승했습니다. 입력값만 수정했을 뿐인데 실제적인 이득을 얻은 것입니다. "모델이 나쁜 답변을 내놓았다"라고 생각했던 사례의 상당수는 사실 "모델에게 나쁜 입력이 주어졌다"는 것으로 밝혀집니다.

몇 가지 추가 발견 사항

  • 하나가 아닌 세 명의 평가자. 모든 답변은 세 명의 평가자에 의해 채점되지만, 오직 하나인 유사도 (similarity) 점수만이 통과 여부를 결정합니다. 나머지 두 가지인 단어 중복 점수 (ROUGE)와 LLM 판사 (LLM judge)는 옆에 기록됩니다. 이들을 유지할 가치가 있는 이유는 이들이 유용하게 서로 다른 의견을 내놓기 때문입니다. 예를 들어, 짧지만 정답인 답변은 더 긴 참조 답변 (reference)과 비교되기 때문에 유사도 점수가 낮게 나와 통과하지 못할 수 있습니다. 반면 판사는 동일한 답변을 정답으로 읽고 통과시킵니다. 어느 쪽도 틀리지 않았습니다. 이들은 서로 다른 것을 측정하며, 이것이 오직 하나만이 결정권을 갖는 이유입니다.

  • 관측 가능성 (Observability) 및 드리프트 (drift). 1일 차 기준선 (baseline)은 지연 시간 (latency)과 토큰 사용량을 기록하며, 드리프트 체크 (drift check)는 동일한 쿼리 (query)를 해당 기준선에 대해 다시 실행합니다. 검색 (retrieval) 단계는 처음에는 실제보다 훨씬 느린 것처럼 보였는데, 이는 동일한 머신에서 다른 모든 요소가 로드되는 동안 시간이 측정되었기 때문입니다.

  • Temperature 0은 결정론적 (deterministic)이지 않습니다. 단일 실행 내에서는 안정적이지만, 세션 전반에 걸쳐 경계선에 있는 질문이 8번의 재실행 중 8번 모두 출처를 인용했다가, 다음에는 8번 중 0번만 인용하는 식으로 뒤집혔습니다. Temperature 0은 실행 내에서 반복 가능하다는 의미이지, 영원히 같은 답변을 내놓는다는 의미가 아닙니다.

  • 참조 답변 (Reference answers)은 튜닝해야 하는 고정 요소입니다. 참조 답변을 질문이 요구하는 내용으로만 축소하는 것이 어떤 답변에는 도움이 되었지만 다른 답변에는 해가 되었습니다. 한 답변은 판사가 여전히 10/10점을 주었음에도 불구하고 점수가 0.857에서 0.696으로 떨어졌습니다.

유사도 측정기 (similarity scorer)는 대칭적이기 때문에, 정답보다 내용이 적은 참조(reference)는 내용이 더 많은 참조와 동일하게 점수가 낮게 나옵니다. "질문에 맞춰 다듬기"와 같은 규칙은 없습니다. 참조는 모델이 실제로 내놓은 답변의 길이와 일치해야 합니다.

  • Ragas와 대조 확인. Ragas는 RAG 평가를 위한 표준 라이브러리이므로, 제가 만든 측정기들과 대조하여 실행해 보았습니다. 두 라이브러리가 공유하는 지표에서 Ragas는 제 결과와 거의 정확히 일치했으며, 이는 제 설정이 견고하다는 것을 의미합니다. Ragas는 전체 답변을 한 번에 점수 매기는 대신 각 주장 (claim)을 개별적으로 점수 매기기 때문에, 제 판사(judge)보다 정확성 (correctness)을 더 엄격하게 평가합니다. 또한 제 측정기에는 없는 기능이 있는데, 바로 답변이 실제로 출처에 의해 뒷받침되는지를 확인하는 지표입니다. 마지막 이 부분이 Ragas를 사용해야 하는 진짜 이유입니다. 이는 거부(refusal) 탐지가 요구했던 근거 확인 (grounding check)이며, 단순히 답변의 품질뿐만 아니라 환각 (hallucination)에 신경 쓰기 시작할 때 반드시 필요한 기능입니다.

재사용할 점

두 가지가 있습니다. 테스트를 어떤 부분이 틀릴 수 있는지에 따라 분리하십시오. 그래야 겉보기에 동일해 보이는 실패가 별도의 테스트 그룹으로 분류됩니다. 그리고 알려진 모든 실패 사례를 엄격한 pytest.xfail과 작성된 이유와 함께 고정(pin)하십시오. 이렇게 하면 테스트는 통과(green) 상태를 유지하면서 한계점을 기록할 수 있고, 만약 그 한계점이 사라지면 빌드가 의도적으로 깨지게 되어 이를 인지할 수 있습니다. 저는 이와 같은 사례를 26개 가지고 있습니다 (몇 개는 실행할 때마다 동작이 바뀌는 특성 때문에 비엄격(non-strict) 모드로 유지했습니다). 이것들은 이 시스템이 무엇을 틀리는지에 대한 기록입니다. 두 아이디어 모두 이 프로젝트 이전의 프로젝트에서 가져온 것이며, 둘 다 AI에만 국한된 것은 아닙니다.

솔직한 한계

하나의 코퍼스 (corpus), 하나의 모델, 작은 쿼리 세트 (검색 16개, 생성 10개, 코퍼스 외 6개)를 사용했습니다. 여기에 있는 모든 결과는 이 스택에서 실제적이며 재현 가능하지만, 이 중 어느 것도 RAG에 대한 보편적인 주장은 아닙니다. 더 큰 모델이나 코퍼스에서는 다르게 동작할 수 있습니다. 또한 llama3.2가 측정기 중 하나에서 자신의 답변을 직접 평가하는데, 이는 제가 해결하기보다는 우회하여 처리한 알려진 약점입니다.

실행하기

brew install ollama
ollama serve            # 별도의 터미널에서 실행 상태를 유지하세요
ollama pull llama3.2    # 두 번째 터미널: 모델을 다운로드합니다
...

HTML 보고서의 모든 테스트 행은 모델에 전송된 정확한 프롬프트 (prompt)와 모델이 반환한 정확한 답변을 보여주므로, 어느 쪽 절반이 잘못되었는지 직접 확인할 수 있습니다.

Repo: https://github.com/sbezjak/llm-rag

이것은 AI 시스템 테스트에 관한 5가지 프로젝트 중 두 번째 프로젝트입니다. 첫 번째는 eval harness (단 하나의 정답이 없을 때 답변을 어떻게 점수화할 것인가?)였습니다. 다음은 레드팀 (red-teaming) - 모델을 의도적으로 망가뜨리려고 시도하는 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0