WIKI새김 Eval
개인 제품·2026 ~ (개발 중)·기획·개발 단독

새김 Eval

정답지 없이 채점하고 심판까지 검증하는 엔진.

PythonpydanticAnthropicOpenAIGitHub ActionsLLM-as-judge

소스 · github.com/gnldnd11/saegim-eval

새김 Eval은?

정답지 없이 LLM 출력 품질을 평가하는 엔진이다. 기준(rubric)에 대고 LLM 심판이 자유 출력을 PASS/FAIL로 채점한다. 차별점은 하나다. "믿을 수 있는 점수". LLM 심판이 가진 편향을 하나씩 보정하고, 심판 자체가 제대로 채점하는지까지 검증해서, 점수를 신뢰할 수 있게 만든다. 그리고 일회성이 아니라 결과가 쌓이고(history) 게이트가 회귀를 잡고 CI가 자동으로 굴리는 시스템이다.

처음엔 v0 spike였다. "관점은 또렷한데 교차채점은 아직 미배선"이었다. 이후 그 "나중에"들을 실제로 배선하고 검증했다. 교차 채점이 돌고, 심어둔 환각을 심판이 잡고, GitHub Actions에서 게이트가 초록불이다. 최근엔 답변 충실도에 더해 검색 품질을 재는 두 번째 계층과 응답 시간·비용 계측까지 붙였다. 다만 표본이 작다는 정직한 한계는 그대로 안고 간다. 아래는 갱신 후의 사실이다.

왜 만들었나, 정답지 없는 세계

한국어 LLM 벤치마크로 학술 벤치마크(정답지 대조)를 만들어봤더니 곧 벽에 부딪혔다. 실제 AI 서비스의 자유 출력엔 정답지가 없다. 챗봇 답변이 "정확하고 안전하고 지시를 지켰는지"엔 채점할 정답 키가 없다.

그럼 LLM에게 채점을 시키면? 여기에도 함정이 있다. self-preference bias(자기 출력 편애) 같은 것 때문에 점수를 못 믿는다. 그래서 "정답지 없이도, 심판의 편향을 보정하고 심판을 검증해 믿을 수 있는 점수를 내는" 재사용 가능한 평가 엔진으로 문제를 일반화했다. 그리고 새김AI 자신의 RAG 답변 품질을 실제로 재는 데 물렸다.

전체 구조

Suite
평가 종류 (기준 채점 · faithfulness · 검색 품질)
EvalEngine
생성(대상) → 교차 채점 → 리포트
EvalReport
pass율 + Wilson CI · history 누적 (LLM 의존 0)
Gate
임계 미달·심판 놓침 시 실패
CI
GitHub Actions — 게이트 실패면 빌드 깨짐

새 평가 종류가 필요하면 Suite 하나만 구현하면 된다. 엔진·리포트·게이트·영속화는 그대로 재사용된다. 지금은 두 계층이 물려 있다 — 답변이 검색된 context에 충실한지(evaluate)와, 검색이 애초에 맞는 걸 물어왔는지(evaluate_retrieval). eval_core는 공개 API(플러그 경계)라, 나중에 다른 시스템에 컴포넌트로 장착할 수 있게 설계했다.

믿을 수 있는 점수

이 프로젝트의 전부는 LLM-as-judge를 순진하게 쓰지 않는 것이다. 편향을 보정하고, 심판을 검증하고, 그걸 CI에 태워 상시화했다.

0
정답지 (reference-free)
2
프로바이더 교차 채점
17
표본 (정상 12 + 함정 5)
2/2
심은 환각 검출 (control)
CI
게이트 초록불
~1.8k
Python LOC
Reference-free — 정답지 없이 rubric으로 채점
교차 채점 — 심판 편향을 회피하고 수치로 측정
심판 검증 (control) — 심은 환각을 잡는지 확인
Faithfulness — claim 단위 정밀도 + 심각도(날조/과단정)
검색 품질 — recall@k + context sufficiency 두 신호
운영 계측 — 응답 시간, 토큰, 비용(USD)
CI 게이트 + 데일리 heartbeat — 매일 자동

Reference-free 채점

정답지 대조를 하지 않는다. 대신 기준(정확성·안전성·지시준수 등)마다 심판에게 yes/no를 묻는다. 심판 프롬프트엔 "정답지는 없다, 기준 충족 여부만 봐라 / 형식·길이가 아니라 기준으로 / 명확한 답이면 직접 답한 걸 감점하지 마라" 같은 판정 원칙을 박았다.

교차 채점으로 편향을 회피하고 측정한다

핵심 차별점. Claude가 낸 출력은 GPT가 채점하고, GPT가 낸 출력은 Claude가 채점하게 해서 self-preference bias를 피한다. 여기서 한 걸음 더 간다. self(자기 진영 심판)와 cross(상대 진영 심판)를 나란히 돌려 그 편향을 수치로 뽑는다. 실측: gpt-4o는 자기 진영 심판이 약 17%p 후하게 봤고, opus는 오히려 자기 진영이 박했다.

정직하게: 이건 n=4(입력) 규모라 통계적으로 증명한 결론이 아니라, 방법이 잡아낸 후보 신호다. Wilson 신뢰구간이 넓다. 요점은 "+17%p를 증명했다"가 아니라 "편향을 정량화하는 하네스를 만들었고, 신호를 잡았다"는 것이다.

심판 검증 (control)

"전부 통과"가 정말 잘해서인지, 심판이 물러서 그런지 어떻게 아나. 사람이 명백한 환각을 일부러 심어 두고, 심판이 그걸 FAIL로 잡는지 확인한다. 실측: 심은 환각을 haiku·gpt-4o-mini 두 심판이 2/2로 잡았다. 채점 결과를 넘어 채점기 자체를 채점하는 지점이다.

RAG faithfulness, 주장 단위로 잰다

답변이 검색된 context에만 근거했는지(환각 여부)를 잰다. 처음엔 이진 PASS/FAIL이었는데, RAGAS 방식으로 고도화했다. 답변에서 주장(claim)을 추출하고 각각 context에 대조해서 score = supported / total(0~1)로 부분 점수를 매긴다. 그리고 심각도를 분류한다. context에 없는 사실을 만들어낸 날조(fabrication), 있는 사실을 부풀린 과단정(embellishment). 게이트는 날조만 치명적 실패로 보고, 과단정은 지표만 남긴다. 함정 케이스(context에 답이 없는 질문)를 넣어 봤더니, 지어내지 않고 정직하게 물러서는 걸 확인했다.

검색 품질, 답변 전에 검색부터 잰다

faithfulness엔 사각이 있다. "검색된 context에 충실했나"만 보니까, 검색이 애초에 엉뚱한 걸 물어오면 심판은 "context에 답이 없으니 모른다"고 한 답변을 오히려 정직하다고 통과시킨다. 검색 실패를 답변 계층이 못 잡는 것이다. 그래서 검색 자체를 재는 두 번째 계층을 붙였다(evaluate_retrieval — 답변 생성 없이 검색된 CONTEXT 자체를 채점).

두 신호를 낸다. recall@k는 gold로 표시한 정답 문서가 top-k 안에 들어왔는지 결정적으로 센다(LLM 안 씀, 라벨 필요). context sufficiency는 "이 질문에 답할 정보가 검색된 CONTEXT에 실제로 있나"를 심판에게 묻는다 — 답을 생성하지 말고 정보 존재 여부만, 질문이 여러 개면 전부 있어야 PASS. 이쪽은 정답 라벨이 필요 없어서(reference-free) 대상 시스템의 retrieve()만 가리키면 라벨 없는 코퍼스에도 붙는다. 답변 계층과 똑같이 교차 심판 패널로 채점한다. 청커·임베딩·파서를 바꾸면 이 지표가 움직여서, 검색 회귀를 여기서 국소화한다. 실측(2026-07-10): recall 2/2, sufficiency 4/4.

말로만 하면 안 믿길 것 같아서, 같은 코퍼스를 청크 크기 1200(프로덕션 설정)과 30으로 두 번 색인해 검색 결과가 실제로 달라지는 걸 보여주는 PoC 스크립트를 따로 뒀다. 답변 계층은 이 변화를 못 본다. 검색된 게 뭐든 그 위에서 성실히 답하면 통과하니까. 두 계층이 왜 필요한지가 이 대조 하나로 보인다.

구조화 출력 · Wilson 신뢰구간

심판은 반드시 { passed, reason } JSON으로 답한다(Claude messages.parse · OpenAI json_object). 파싱 흔들림과 장황함을 막고 근거 한 문장을 남긴다. 그리고 표본이 적을 때 pass율을 점이 아니라 Wilson 95% 신뢰구간으로 본다. 작은 n의 불확실성을 숨기지 않기 위해서다.

운영 지표, 정확도만 재지 않는다

점수가 믿을 만해도 "얼마나 느리고 비싼가"를 모르면 반쪽이다. 그래서 어댑터가 호출마다 응답 시간·토큰을 계측하고(생성·심판 시그니처는 그대로 두고), 실행 끝에 합산해 평균 응답 시간·총 토큰·비용(USD)을 리포트에 싣는다.

여기서도 지어내지 않는다. 단가가 확인된 모델만 비용을 계산하고, 미확인 모델은 추정해 채우지 않고 그대로 비운 채 cost_complete: false로 표시한다. 지금은 단가 테이블에 opus·haiku·임베딩에 더해 gpt-5.5($5/$30)·gpt-4o($2.50/$10)까지 공개 단가를 확인해 채워 넣었다. 아래 모델 스윕은 그 전(2026-07-09)에 돌린 실측이라 GPT 쪽 비용 칸이 비어 있다. opus 4/4 · 2.53s · $0.0146, haiku 4/4 · 1.53s · $0.0032, gpt-4o 4/4 · 0.77s. 이 케이스에선 네 모델 다 faithfulness를 통과해서, opus가 haiku보다 4배 넘게 비싸고 더 느리다는 게 그대로 드러난다 — 이 난이도엔 굳이 opus일 이유가 없다는 신호다(표본이 작으니 결론은 아니다).

CI로 상시화

eval은 제품이 아니라 계기다. 그래서 화면(UI)이 아니라 자동 게이트로 상시화했다. ci_gate.py가 값싼 모델로 스모크를 돌려 exit code로 빌드를 제어하고, GitHub Actions가 push(eval 경로 변경 시)·야간·수동으로 트리거한다. faithfulness가 회귀하거나 심판 헬스체크가 실패하면 빌드가 깨진다. 회귀 감지의 대상은 특정 고가 모델이 아니라 프롬프트와 파이프라인이라, 스모크는 haiku·gpt-4o-mini 같은 값싼 모델로 돈다.

CI 게이트가 커밋을 지킨다면, 시간축은 매일 heartbeat가 채운다. 로컬 스케줄러(launchd)가 매일 10시에 프로덕션 설정(유사도 임계 0.18 · top-k 5)으로 한 번 돌려 두 계층 결과를 daily.jsonl에 하루 한 점씩 시계열로 쌓고, 그 점들이 0to1 /lab의 daily.json으로 넘어가 라이브 현황판에 뜬다. 여기까지가 사람 손 없이 간다. 마지막 push도 자동이지만 조심스럽게 다룬다. daily.json과 automations.json 두 경로만 add하고(다른 작업 미커밋본을 건드리지 않게), 원격 통합은 머지로, 충돌이 나면 abort하고 push를 생략한다. 매일 도는 게 목적이라 표본은 줄여 쓴다. 전체 17개가 아니라 정상 4 + 함정 2 + control 1, 총 7 케이스다. 줄이는 방식에도 규칙이 있다. 정상 질의는 아무거나 4개가 아니라 정답 문서가 서로 겹치지 않게 골라서, 적은 표본으로도 검색이 여러 문서를 맞히는지 본다. 그리고 무작위가 아니라 매일 같은 케이스가 돌아야 시계열끼리 비교가 된다. 모델도 값싼 쪽(haiku · gpt-4o-mini)이고, opus·gpt-5.5는 수동 쇼케이스로 남겼다. 개발일지 자동화와 같은, 로컬에 묶인 heartbeat다.

돌아가는 것만큼 어려운 게 돌아간다는 표시였다. 라이브 현황판의 eval 칸이 두 번 얼어붙었는데, 두 번 다 eval이 멈춘 게 아니라 표시만 죽은 것이었다. 처음엔 실행 결과를 daily.json에만 쓰고 현황판 쪽(automations.json)은 안 찍어서, 다음엔 launchd의 최소 PATH에서 그 찍는 도구(node)를 못 찾아서. 지금은 실행 끝에 최신 점의 시각·faithfulness·게이트를 읽어 현황판 칸까지 알아서 찍는다. 자동화는 도는 것과 도는 걸 보여주는 것이 별개의 배선이고, 후자도 똑같이 깨진다.

기술 스택

  • 코어: Python · pydantic (구조화 출력 강제) · 포트&어댑터(헥사고날) 설계
  • 모델: anthropic (messages.parse) · openai (json_object), 스텁 어댑터(오프라인 테스트)
  • 대상/심판: opus·gpt-5.5·gpt-4o (full/스윕) · haiku·gpt-4o-mini (CI 스모크·데일리). .env로 교체
  • 자동화: GitHub Actions (게이트 · exit code 기반) · launchd (매일 10시 heartbeat)

왜 이 스택

  • pydantic (구조화 출력 강제): 심판이 반드시 { passed, reason } JSON으로 답하게 강제해야 파싱이 안 흔들리고 근거가 남는다. pydantic으로 스키마를 강제했다.
  • 멀티프로바이더 (anthropic + openai): 교차 채점(Claude 출력은 GPT가, GPT 출력은 Claude가 채점)으로 self-preference 편향을 피하려면, 프로바이더를 갈아끼울 수 있는 공통 인터페이스가 전제다.
  • 포트&어댑터(헥사고날): 대상·심판·저장을 갈아끼울 수 있어야 재사용 가능한 평가 엔진이 된다. 그래서 경계를 어댑터로 뺐고 스텁 어댑터로 오프라인 테스트도 된다.
  • GitHub Actions (CI 게이트): UI가 아니라 CI 게이트로 상시화했다. 평가를 사람이 돌리는 도구가 아니라, 회귀하면 빌드가 깨지는 방어선으로 두려는 선택이다.

Decision Log

Decision #01Reference-free — 정답지 대조를 버림

실제 LLM 앱의 출력엔 정답지가 없다. 학술 벤치(한국어 LLM 벤치마크)의 한계를 넘으려면 정답 없이 기준으로 채점해야 한다.

Decision #02차별점 = '믿을 수 있는 점수' (편향 보정 + 심판 검증)

LLM-as-judge를 순진하게 쓰면 self-preference bias로 점수를 못 믿는다. 그래서 편향 보정과 심판 검증 자체를 도구의 정체성으로 뒀다.

Decision #03교차 채점을 배선하고, 편향을 측정한다

편향 회피에 그치지 않고, self와 cross를 나란히 돌려 그 편향을 수치로 뽑는다. "얼마나 후하게/박하게 보는가"를 정량화한다.

Decision #04심판 검증(control) — 채점기를 채점

심어둔 명백한 환각을 심판이 잡는지 확인해, "전부 통과"가 심판이 물러서가 아님을 증명한다.

Decision #05UI 아닌 CI로 시스템화

eval은 계기지 제품이 아니다. 상시화의 정답은 화면이 아니라 자동 게이트다. 값싼 스모크로 회귀를 잡고, 플러그 경계(eval_core)로 미래 시스템에 컴포넌트로 장착할 수 있게 뒀다.

Decision #06두 계층 분리 — faithfulness는 검색 누락을 못 잡는다

답변 충실도만 재면, 검색이 엉뚱한 걸 물어왔을 때 "context에 없어서 모른다"고 한 답이 오히려 통과한다. 검색 회귀(청커·임베딩·파서)를 국소화하려면 검색 계층(recall@k + context sufficiency)을 답변 계층과 따로 둬야 한다.

Decision #07운영 비용은 정직하게 비운다

단가가 확인 안 된 모델은 비용을 추정해 채우지 않는다. 그대로 비우고 cost_complete: false로 표시한다. 그럴싸한 숫자보다 "이 값은 아직 모른다"가 지표의 신뢰를 지킨다.

회고: 관점, 그리고 이번엔 실물

일회성 벤치를 하나 더 만든 게 아니다. 평가라는 문제 자체를 자동 파이프라인으로 일반화하는 중이다. 한국어 LLM 벤치마크(정답지 학술벤치)에서 한계를 보고, 새김 Eval(reference-free + 신뢰성 보정)로 넘어와, 교차채점 배선·faithfulness·CI 게이트·플러그 경계까지 세웠다.

"관점만 또렷"에서 "관점 + 실제로 도는 시스템 + CI 게이트 + 실측 결과"로 올라섰다. 정직한 한계는 그대로다. 표본이 작아 bias 수치는 증명이 아니라 신호이고, context는 배포 실로그가 아니라 대표 재현이다. 그 뒤로 적어둔 다음 과제도 일부 실물이 됐다. 검색 품질 계층과 운영 지표(응답 시간·비용)는 붙였고, "실로그 연결"은 하드코딩 context를 버리고 새김 백엔드의 진짜 검색 파이프라인(청킹→임베딩→유사도)을 호출하는 데까지 왔다 — 다만 아직 실사용자 로그가 아니라 임의 코퍼스다. 남은 건 실사용자 트래픽 연결, 케이스 확장(17 → 수십), position bias·calibration, 그리고 eval_core를 미래 OS에 컴포넌트로 장착하는 것이다.