본문 바로가기
삵
AI

Ragas로 RAG 파이프라인 점수 매기기

주주말만기다려·2026년 11월 12일·조회 0

RAG를 한번 붙여 놓고 나면, 청킹 크기를 바꾸거나 리랭커를 끼울 때마다 "답이 좋아진 것 같다"는 느낌으로 판단하게 된다. 체감은 가는데 손에 쥔 숫자가 없으니, 어제 손본 게 진짜 개선인지 그냥 운인지 구분이 안 된다. 그래서 바꾸기 전에 점수를 박아 두고 바꾼 뒤와 비교하는 이야기를 정리한다.

Ragas 0.4.x의 지표를 두 묶음으로 나눠 보는 게 핵심이다. faithfulness와 answer relevancy로 답을 만드는 생성기(LLM)를, context precision과 context recall로 문맥을 가져오는 검색기(retriever)를 따로 측정한다. 이렇게 갈라 두면 점수가 떨어졌을 때 생성기가 헛소리를 한 건지, 검색이 엉뚱한 걸 물어 온 건지 바로 좁힐 수 있다.

생성기와 검색기를 따로 재는 이유

RAG의 품질 문제는 두 군데서 생긴다. 검색기가 질문에 맞는 문서를 못 가져왔거나, 문서는 잘 가져왔는데 생성기가 그걸 무시하고 지어낸 경우다. 한 덩어리의 종합 점수만 보면 둘이 섞여서 어디를 손봐야 할지 안 보인다.

네 지표가 가리키는 곳은 이렇게 갈린다. faithfulness는 답에 담긴 주장들이 가져온 문맥으로 전부 뒷받침되는지를 0~1로 잰다. 낮으면 환각이다. answer relevancy는 답이 질문의 의도에 맞는지를 본다. 사실 여부가 아니라 "질문에 제대로 대답했는가"다. 이 둘은 생성기 담당이다.

context precision은 가져온 청크 중 관련 있는 것이 상위에 랭크됐는지를 본다. 리랭커나 정렬이 잘 되면 올라간다. context recall은 정답을 만드는 데 필요한 정보가 검색 결과 안에 다 들어왔는지를 본다. 청킹, 임베딩, top_k가 부실하면 떨어진다. 이 둘은 검색기 담당이다.

진단은 조합으로 읽는다. faithfulness는 높은데 답이 틀렸다면 검색이 관련 문서를 못 가져온 것이니 recall을 본다. 문맥은 멀쩡한데 faithfulness가 낮으면 생성기가 문맥을 벗어나 지어낸 것이다. recall은 높은데 precision이 낮으면 관련 문서는 들어왔지만 순위가 엉켜 있다는 뜻이라 리랭커를 손볼 자리다.

reference가 필요한 지표와 그렇지 않은 지표

0.4의 collections 지표는 입력 요구가 제각각이라 여기서 한 번 걸리기 쉽다. faithfulness, answer relevancy는 질문과 답(그리고 문맥)만 있으면 돌아가므로 정답지 없이 운영 로그만으로도 잴 수 있다. 하지만 검색기 지표는 정답지를 요구한다.

특히 주의할 점. collections의 기본 ContextPrecision은 reference(정답 답변)를 필수로 요구하는 변형이다. "precision은 reference 없이 돈다"고 알고 있으면 실행이 깨진다. reference 없이 precision을 재고 싶으면 별도 클래스인 ContextUtilization(=ContextPrecisionWithoutReference)을 써야 한다. 이쪽은 reference 대신 생성된 response를 기준으로 청크의 관련성을 판단한다.

ContextRecall도 reference가 필요하다. 정답 답변을 주장 단위로 쪼갠 뒤 각 주장이 검색 문맥에 들어 있는지를 세기 때문이다. 정리하면 검색기를 제대로 진단하려면 질문마다 정답 답변을 붙인 평가셋(골든셋)을 먼저 만들어 둬야 한다. 정답 청크 목록을 알고 있다면 LLM 없이 문자열 유사도로 재는 NonLLMContextPrecisionWithReference, NonLLMContextRecall 변형도 있다.

네 지표를 코드로 돌리기

설치는 ragas 0.4 이상과 LLM 클라이언트만 있으면 된다. 저지(judge) 모델 호출이 들어가므로 OpenAI 키 같은 것이 필요하다.

$ pip install "ragas>=0.4" openai
...
$ python -c "import ragas; print(ragas.__version__)"
0.4.x   # 설치 시점에 따라 패치 버전은 다르다

0.4부터는 llm_factory와 embedding_factory로 저지 모델과 임베딩을 감싸고, 지표를 ragas.metrics.collections에서 가져와 ascore(...)를 키워드 인자로 호출한다. v0.3의 SingleTurnSample + single_turn_ascore 방식과 다르다. 반환값은 float이 아니라 MetricResult라서 .value로 점수를 꺼낸다.

import os, asyncio
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.embeddings import embedding_factory
from ragas.metrics.collections import (
    Faithfulness,
    AnswerRelevancy,
    ContextPrecision,      # 기본형: reference 필수
    ContextUtilization,    # reference 없이 response 기준
    ContextRecall,
)

client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
llm = llm_factory("gpt-4o-mini", client=client)
emb = embedding_factory(provider="openai",
                        model="text-embedding-3-small",
                        client=client)

faithfulness = Faithfulness(llm=llm)
relevancy    = AnswerRelevancy(llm=llm, embeddings=emb)
precision    = ContextPrecision(llm=llm)   # reference 필요
recall       = ContextRecall(llm=llm)      # reference 필요

평가 샘플 하나를 두고 네 지표를 돌려 본다. reference는 그 질문의 정답 답변이다.

sample = dict(
    user_input="환불은 며칠 안에 신청해야 하나",
    response="구매일로부터 14일 이내에 신청하면 전액 환불된다.",
    retrieved_contexts=[
        "환불은 구매일 기준 14일 이내 신청 시 전액 환불된다.",
        "배송비는 주문 금액 5만원 이상이면 무료다.",
    ],
    reference="구매일로부터 14일 이내에 환불을 신청해야 한다.",
)

async def score_one(s):
    f = await faithfulness.ascore(
        user_input=s["user_input"], response=s["response"],
        retrieved_contexts=s["retrieved_contexts"])
    r = await relevancy.ascore(
        user_input=s["user_input"], response=s["response"])
    p = await precision.ascore(
        user_input=s["user_input"],
        retrieved_contexts=s["retrieved_contexts"],
        reference=s["reference"])
    c = await recall.ascore(
        user_input=s["user_input"], response=s["response"],
        retrieved_contexts=s["retrieved_contexts"],
        reference=s["reference"])
    return {
        "faithfulness": f.value,
        "answer_relevancy": r.value,
        "context_precision": p.value,
        "context_recall": c.value,
    }

print(asyncio.run(score_one(sample)))
{'faithfulness': 1.0, 'answer_relevancy': 0.94,
 'context_precision': 1.0, 'context_recall': 1.0}

깨끗한 샘플이라 대부분 1.0 근처가 나온다. 두 번째 청크(배송비)는 질문과 무관하지만 관련 청크가 1위에 있어 precision이 깎이지 않았다. 점수의 소수점은 저지 모델과 실행마다 조금씩 흔들리니 절대값보다 추세로 읽어야 한다.

reference를 붙이기 어려운 운영 로그만 있을 때는 precision 대신 ContextUtilization으로 바꾼다. 같은 자리에서 reference를 빼고 response를 넘긴다.

util = ContextUtilization(llm=llm)

u = await util.ascore(
    user_input=sample["user_input"],
    response=sample["response"],              # reference 대신 response
    retrieved_contexts=sample["retrieved_contexts"])
print(u.value)

청킹과 리랭커를 바꾼 뒤 점수 회귀 잡기

지표의 진짜 쓸모는 변경 전후 비교에 있다. 질문과 정답 답변을 묶은 평가셋을 고정해 두고, 설정을 바꿀 때마다 같은 셋으로 돌려 평균을 비교한다. 여러 샘플을 모아 평균 내는 함수는 이렇게 쓴다.

import statistics

async def run_suite(dataset):
    rows = [await score_one(s) for s in dataset]
    keys = rows[0].keys()
    return {k: round(statistics.mean(r[k] for r in rows), 3) for k in keys}

# dataset: 질문/정답/문맥을 담은 dict 리스트 (검색기 설정마다 retrieved_contexts만 교체)
baseline = asyncio.run(run_suite(dataset_baseline))
reranked = asyncio.run(run_suite(dataset_reranked))
print("baseline:", baseline)
print("reranked:", reranked)
baseline: {'faithfulness': 0.97, 'answer_relevancy': 0.91, 'context_precision': 0.74, 'context_recall': 0.88}
reranked: {'faithfulness': 0.97, 'answer_relevancy': 0.92, 'context_precision': 0.90, 'context_recall': 0.87}

이 비교에서 읽을 수 있는 것. precision이 0.74에서 0.90으로 뛰었고 생성기 지표(faithfulness, answer relevancy)는 그대로다. 리랭커가 관련 청크를 위로 끌어올렸다는 신호다. recall이 0.88에서 0.87로 거의 안 움직인 건, 리랭커가 순서만 바꿀 뿐 검색 후보 자체를 넓히지 못하기 때문이다. recall을 올리려면 청크 크기나 top_k, 임베딩 쪽을 손봐야 한다.

반대 사례도 있다. 청크를 잘게 쪼개면 recall은 오르는데 조각이 많아져 precision이 깎이고, 문맥이 토막 나 faithfulness까지 내려가기도 한다. 한 지표만 보면 "recall 올랐으니 개선"이라고 착각하기 쉽다. 네 지표를 나란히 두면 이런 맞교환이 숫자로 드러난다. 이 표를 CI에 넣고 PR마다 돌려서, 평균이 일정 폭 이상 떨어지면 막는 회귀 게이트로 쓰면 된다.

돌리기 전에 알아둘 함정

첫째, 기본 ContextPrecision에서 reference를 빼면 실행이 깨진다. 앞서 말한 대로 reference 없이 가려면 ContextUtilization으로 바꾼다. 이걸 혼동하면 가장 먼저 막히는 지점이다.

둘째, 저지가 LLM이라 점수는 비결정적이다. 같은 입력도 실행마다 소수점 둘째 자리가 흔들린다. 그래서 임계값을 0.90처럼 빡빡하게 걸기보다, baseline 대비 하락 폭(예: 평균 -0.03 이상이면 실패)으로 거는 편이 덜 흔들린다. 임계값은 블로그 숫자가 아니라 자기 셋에서 뽑은 baseline으로 정해야 한다.

셋째, 지표마다 저지 LLM을 호출하므로 평가셋이 커지면 토큰과 시간이 든다. 처음엔 gpt-4o-mini 같은 저렴한 저지로 시작하고, 평가셋은 대표 질문 수십 건으로 시작해 서서히 늘린다. answer relevancy는 임베딩으로 코사인 유사도를 재므로 임베딩 모델도 함께 넘겨야 한다는 점을 잊기 쉽다.

다음 단계는 이 골든셋을 저장소에 커밋하고 평가 스크립트를 CI 잡으로 거는 것이다. 멀티턴 대화나 에이전트 경로의 품질은 여기서 다룬 단일 턴 지표로는 다 못 잡으니, 그건 별도 지표로 따로 봐야 한다.

자주 묻는 질문

ContextPrecision에서 reference를 빼도 되나

안 된다. 0.4 collections의 기본 ContextPrecision은 reference(정답 답변)를 필수로 요구하는 변형이라 빼면 실행이 깨진다. reference 없이 검색 정밀도를 재려면 별도 클래스인 ContextUtilization(=ContextPrecisionWithoutReference)을 쓴다. 이쪽은 reference 대신 생성된 response를 기준으로 청크 관련성을 판단한다.

점수가 실행마다 조금씩 달라지는 이유는

faithfulness, answer relevancy, context precision/recall 모두 LLM을 저지로 쓰기 때문에 비결정적이다. 같은 입력도 실행마다 소수점 아래가 흔들린다. 절대값으로 임계를 걸기보다 baseline 대비 하락 폭으로 비교하는 편이 안정적이다.

생성기와 검색기 중 어디가 문제인지 어떻게 구분하나

faithfulness와 answer relevancy는 생성기를, context precision과 recall은 검색기를 가리킨다. faithfulness는 높은데 답이 틀렸다면 recall을 봐서 검색 누락을 의심하고, 문맥은 멀쩡한데 faithfulness가 낮으면 생성기 환각이다. recall은 높고 precision이 낮으면 순위 문제라 리랭커를 손본다.

answer relevancy에 임베딩이 꼭 필요한가

필요하다. 이 지표는 답으로부터 역질문을 만들고 원 질문과의 코사인 유사도를 재므로 임베딩 모델이 있어야 한다. AnswerRelevancy(llm=llm, embeddings=emb)처럼 임베딩을 함께 넘긴다. faithfulness나 context precision 계열은 임베딩 없이 LLM만으로 돈다.

v0.3 코드와 0.4는 뭐가 다른가

0.4는 지표를 ragas.metrics.collections에서 가져오고, SingleTurnSample 대신 ascore(user_input=..., response=...)처럼 키워드 인자로 호출한다. 저지와 임베딩은 llm_factory, embedding_factory로 감싸며, 반환값은 float이 아니라 MetricResult라서 .value로 점수를 꺼낸다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.