본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 24. 21:53

내 평가 하네스(eval harness)가 첫 실행에서 제값을 한 이유: 0.57, 0.96, 그리고 유닛 테스트로는 잡을 수 없었던 두 가지

요약

RAG 파이프라인 개발 중 유닛 테스트를 통과했음에도 낮은 성능을 보였던 사례를 통해, 엔드 투 엔드 평가 하네스(eval harness)의 중요성을 설명합니다. 실제 임베딩과 에이전트 루프를 사용하는 평가 방식이 어떻게 숨겨진 버그를 찾아내는지 다룹니다.

핵심 포인트

  • 유닛 테스트만으로는 RAG 시스템의 논리적 오류를 잡기 어려움
  • 실제 임베딩과 에이전트 루프를 포함한 엔드 투 엔드 평가가 필수적임
  • LLM-as-judge를 활용한 충실도 및 인용 정확성 측정 방식 제안
  • 모델에게 전달되는 데이터(스니펫 vs 전체 텍스트)의 정합성 확인 필요

저는 특정 질문에 대해 정확히 맞는 문서를 인용하면서도, 정작 사용자에게는 그 문서에 답이 없다고 말하는 RAG 파이프라인을 거의 출시할 뻔했습니다.

모든 유닛 테스트(unit test)는 통과(green) 상태였습니다. 검색(retrieval)은 올바른 청크(chunk)를 반환했습니다. API는 200 상태 코드를 반환했습니다. 인용(citation)도 응답에 첨부되었습니다. 제가 가진 모든 체크 항목에서는 정상 작동했습니다. 하지만 제 평가 하네스(eval harness)의 첫 실행 점수는 0.57이었고, 사용자들이 발견하기 전에 이를 알아낼 수 있었던 유일한 이유는 바로 그 숫자 덕분이었습니다.

이것은 두 가지 버그에 관한 이야기이며, 왜 제가 작성할 수 있었던 그 어떤 유닛 테스트로도 이 버그들을 잡을 수 없었는지, 그리고 왜 이제는 GenAI 프로젝트에서 평가 하네스(eval harness)가 "시스템이 안정화된 후"가 아니라 "첫날부터" 포함되어야 한다고 믿는지에 대한 이야기입니다.

평가 하네스(eval harness)가 실제로 하는 일

제가 구축 중이던 RAG 스타터 키트인 "문서와 대화하기"를 위해, 저는 사용자가 실제로 수행하는 작업을 엔드 투 엔드(end to end)로 실행하는 테스트를 원했습니다. 그래서 하네스는 다음과 같이 작동합니다:

  1. 소규모 피스처 코퍼스(fixture corpus, 몇 개의 퍼블릭 도메인 문서)를 **실제 임베딩 (real embeddings)**과 함께 전용 데이터베이스에 수집합니다 — 모의 객체(mocks)를 사용하지 않습니다.
  2. 각 질문에 대해 **실제 에이전트 루프 (real agent loop)**를 실행합니다: 프로덕션 환경과 동일한 도구 호출(tool-calling), 동일한 검색(retrieval), 동일한 프롬프트(prompts)를 사용합니다.
  3. LLM-as-judge를 사용하여 모든 답변을 채점하며, 두 가지 항목을 점수화합니다: 충실도 (faithfulness) (답변이 검색된 컨텍스트에 의해 뒷받침되는가?) 및 인용 정확성 (citation correctness) (인용이 올바른 지점을 가리키는가?).
  4. **부정 사례 (negative cases)**를 포함합니다 — 정직한 답변이 "이 문서에는 해당 내용이 없습니다"가 되어야 하는 질문들로, 여기서 올바른 동작은 확신에 찬 추측이 아니라 거절하는 것입니다.

이 방식은 결정론적(deterministic-ish)입니다 (온도(temperature) 0, 고정된 judge 프롬프트). 점수는 커밋(commit)마다 기록되므로, 검색(retrieval)에서 회귀(regression)가 발생하면 PR에서 숫자가 눈에 띄게 떨어집니다.

그 후 처음으로 실행해 보았습니다. 0.57이었습니다.

버그 #1: 모델에게 증거가 아닌 미리보기가 보여짐

신뢰도가 낮은 사례들을 깊이 파고들어 본 결과, 에이전트(agent)가 올바른 청크(chunk)를 검색하여 인용(citation)으로 첨부했음에도 불구하고, 해당 정보가 없다고 답변하는 경우를 발견했습니다. 소스를 인용하면서 동시에 그 내용을 부정하고 있었던 것입니다. 이는 상식적으로 말이 되지 않습니다. 하지만 모델에게 실제로 무엇이 전달되었는지를 확인하기 전까지는 말이죠.

에이전트가 검색 도구(search tool)를 호출했을 때, 도구로부터 반환된 결과는 전체 청크 텍스트가 아니라 인용 칩(citation chip)에 표시되는 작은 미리보기인 **400자 UI 스니펫(snippet)**이었습니다. 이 스니펫은 사이드바를 훑어보는 사람들을 위해 만들어진 것이었습니다. 모델은 실제 증거가 담긴 필드는 전혀 보지 못한 채, 미리보기만을 보고 답변하도록 요구받고 있었습니다. 만약 정답이 청크의 400자 이후에 위치한다면, 모델은 실제로 그것을 볼 수 없었을 것이고, 정보가 없다고 충실하게 보고한 것입니다.

해결책은 두 가지 관심사를 분리하는 것이었습니다: UI를 위한 snippet과 모델이 추론할 수 있는 전체 content로 나누는 것입니다.

class Citation:
    snippet: str   # UI 칩을 위한 짧은 미리보기
    content: str   # 모델의 추론을 위한 전체 청크 텍스트

이 포스트에서 중요한 부분은 이것입니다: 이 문제를 발견한 후에도 모든 유닛 테스트(unit test)는 여전히 통과했으며, 발견하기 전에도 모든 유닛 테스트는 통과했습니다. 검색(Retrieval)은 올바른 청크를 반환했습니다 (✓). 인용(Citation)은 첨부되었습니다 (✓). 엔드포인트(endpoint)는 200을 반환했습니다 (✓). 버그는 개별 유닛에 있었던 것이 아니라, 유닛 간의 계약(contract between units), 즉 런타임 시 루프 내부에서 한 컴포넌트가 다른 컴포넌트에 전달하는 내용에 있었습니다. 이를 확인할 수 있는 유일한 방법은 실제 질문에 대한 전체 시스템의 동작을 살펴보고 "이 답변이 실제로 좋은가?"라고 묻는 것이었습니다. 이 질문이야말로 평가 하네스(eval harness)가 던지는 질문이며, 유닛 테스트는 던지지 않는 질문입니다.

버그 #2: 에이전트가 검색 대신 기억에 의존하여 답변함

부정적 사례(negative cases)들이 두 번째 버그를 드러냈습니다. 일반 지식 질문에 대해 에이전트는 종종 그냥... 답변을 해버렸습니다. 그것도 아주 자신 있게 말이죠. 검색 도구(search tool)를 전혀 호출하지 않은 채, 모델 자체의 파라미터 메모리(parametric memory)로부터 답변을 내놓았습니다. 때로는 답변이 맞을 때도 있었는데, 이는 더 나쁜 상황입니다. 데모에서는 괜찮아 보이는 방식으로 동작이 신뢰할 수 없음을 의미하기 때문입니다.

"문서와 대화하기" 제품의 경우, 이는 정확성(correctness) 버그입니다. 제품의 핵심 가치는 답변이 인용(citation)과 함께 사용자의 문서에 근거(grounded)한다는 점에 있기 때문입니다. 검색(retrieval)을 건너뛴 답변은 설령 우연히 맞더라도 계약 위반(off-contract)이며, 이는 중요한 질문들에 대해 확신에 찬 환각(hallucination)을 유발하는 원인이 됩니다.

해결책은 프롬프트 수준에서 이루어졌습니다. 검색을 우선시하도록 강화된 시스템 프롬프트(system prompt)와 "답변하기 전에 검색하라"를 기본값으로 만드는 더 날카로운 도구 설명(tool description), 그리고 약한 매칭이 출처로 포장되지 않도록 인용에 대한 관련성 점수 하한선(relevance-score floor)을 설정했습니다. 평가 하네스(eval harness)는 이 해결책이 단순히 느낌상 좋아진 것이 아니라 실제로 작동했다는 것을 알려주었습니다. 부정적 사례들이 통과되기 시작하면서도, 근거가 있어야 할 답변들의 품질을 떨어뜨리지 않았기 때문입니다.

두 가지 수정 사항을 적용한 후, 다음 실행 결과는 0.96(충실도(faithfulness) 0.99, 인용 정확도(citation correctness) 0.93)으로 돌아왔습니다. 동일한 코드 경로, 동일한 코퍼스(corpus)였지만, 제가 추측하는 대신 하네스가 그 차이를 측정해 주었습니다.

이것이 왜 나의 생성형 AI(GenAI) 기능 구축 방식을 바꾸었는가

유닛 테스트(Unit tests)는 함수가 작성된 대로 동작하는지를 검증합니다. 유닛 테스트는 필수적이며, 저의 두 버그 모두 테스트가 통과(green suite)되었음에도 빠져나갔습니다. 왜냐하면 두 함수 모두 작성된 대로 정확히 동작했기 때문입니다. 단지 _시스템_이 나쁘게 동작했을 뿐입니다. LLM 기반 기능은 다른 계층에서 실패합니다. "이 함수가 잘못된 값을 반환했다"가 아니라, "이 비결정론적 파이프라인(non-deterministic pipeline)이 불충실한 답변을 생성했다"는 식입니다. assertEqual만으로는 이러한 문제를 단언(assert)할 수 없습니다.

평가 하네스(eval harness)는 바로 그 계층을 위한 테스트입니다. 이제 제가 타협할 수 없는 사항으로 간주하는 몇 가지는 다음과 같습니다:

  • 실제 파이프라인을 실행하세요 (Run the real pipeline). 모킹 (Mocks)을 사용했다면 두 버그 모두 숨겨졌을 것입니다. 코드 조각/콘텐츠 분리 (snippet/content split) 문제와 검색 건너뛰기 (skipped search) 문제는 실제 루프(loop)에서만 존재하기 때문입니다.
  • 문자열이 아닌 동작을 평가하세요 (Grade behavior, not strings). "이 답변이 문맥(context)에 충실한가?"가 핵심 질문입니다. 고정된 루브릭 (rubric)과 구조화된 출력 (structured output)을 갖춘 LLM 판사 (LLM judge)를 사용하는 것이 이를 대규모로 실행할 수 있는 실질적인 방법입니다.
  • 부정적인 사례를 포함하세요 (Include negative cases). "답변이 코퍼스(corpus)에 없음"이라는 케이스는 긍정적인 사례(positive cases)로는 절대 잡을 수 없는 '확신에 찬 환각 (confident-hallucination)' 실패 모드를 잡아냅니다.
  • 점수를 커밋하세요 (Commit the score). 모든 PR(Pull Request)에 숫자가 포함되면, "검색(retrieval) 성능이 퇴보했는가?"라는 질문이 단순한 느낌(vibe)이 아닌 차이(diff)가 됩니다.
  • 첫날부터 시작하세요 (Do it on day one). 이 하네스(harness)를 만드는 데는 오후 한때가 걸렸지만, 첫 실행에서 배포를 막는 두 가지 버그를 잡아냈습니다. "평가는 나중에 추가하자"라는 말은 곧 0.57 버전의 결과물을 그대로 배포하겠다는 뜻입니다.

하네스의 정확한 형태가 필요하다면, 러너 (runner), 피스처 코퍼스 (fixture corpus), 판사 (judge), 그리고 커밋별 결과물로 구성된 전체 하네스는 스타터 킷에 오픈 소스 (MIT)로 공개되어 있습니다:

👉 github.com/delmalih/saas-genai-starter

불편한 교훈: 제 파이프라인은 0.57 버전에서 "완료"되었고 모든 테스트가 통과(green)된 상태였습니다. 그 상태와 실제 프로덕션 사이를 가로막고 있던 유일한 것은, 답변이 정말로 괜찮은지를 실제로 물어보는 테스트에서 나온 단 하나의 숫자였습니다. 만약 RAG나 에이전트(agent) 형태의 무언가를 구축하고 있다면, 그 테스트를 가장 먼저 작성하세요.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0