포스트

2026-04-10 강의 정리

LLM을 활용한 성능 평가 (LLM-as-Judge) - LangChain Criteria Evaluation

2026-04-10 강의 정리

목차

  1. RAG 파이프라인 개요
  2. Reranking 기법
  3. Score Filtering
  4. Context Compression
  5. MMR (Maximum Marginal Relevance)
  6. RAG 평가 지표 (AP, MAP, ILS, A/B test)
  7. LLM-as-a-Judge 평가 전략
  8. 자주 나오는 실수 / 주의사항
  9. [보충] 평가 프레임워크 설계 원칙

1. RAG 파이프라인 개요

1
2
3
4
5
6
7
[사용자 쿼리]
     ↓
[Retriever] → 후보 문서 5~20개
     ↓
[Reranker] → 상위 k개 선별
     ↓
[Generator (LLM)] → 최종 답변 생성

Bi-encoder vs Cross-encoder

방식구조특징
Bi-encoderquery → emb / doc → emb / 코사인 유사도빠름, 사전 인덱싱 가능
Cross-encoder(query, doc) → 점수정확, 쌍 단위 처리로 느림

Bi-encoder로 1차 검색 → Cross-encoder/LLM으로 2차 리랭킹하는 게 실무 표준


2. Reranking 기법

2-1. Keyword Reranking

가장 단순한 방식. 쿼리 단어가 문서에 몇 개 등장하는지 카운트해 점수 보정.

1
2
3
4
5
6
7
8
9
10
def keyword_rerank(query, search_results):
    query_terms = set(query.lower().split())
    reranked = []
    for doc, orig_score in search_results:
        doc_terms = doc.page_content.lower().split()
        keyword_hits = sum(1 for t in doc_terms if t in query_terms)
        new_score = orig_score + 0.1 * keyword_hits
        reranked.append((doc, new_score, orig_score))
    reranked.sort(key=lambda x: x[1], reverse=True)
    return reranked

2-2. BM25 Reranker (TF-IDF 기반)

TF-IDF를 개선한 BM25 알고리즘으로 문서를 재정렬.

1
2
3
4
5
6
7
8
9
10
11
class BM25Reranker:
    def __init__(self, k1=1.5, b=0.75):
        self.k1 = k1   # 단어 빈도 포화 파라미터
        self.b = b     # 문서 길이 정규화 파라미터

    def rerank(self, query, search_results):
        docs = [doc for doc, _ in search_results]
        tokenized = [doc.page_content.lower().split() for doc in docs]
        avg_dl = np.mean([len(t) for t in tokenized])
        # BM25 점수 계산 후 정렬
        ...
  • k1: 단어 빈도가 많아도 점수가 포화되는 정도 조절 (1.2~2.0)
  • b: 문서 길이에 따라 정규화 (0~1, 클수록 길이 영향 강함)

2-3. LLM Reranker (Pointwise)

각 문서에 대해 LLM이 0.0~1.0 점수 부여.

1
2
3
4
5
6
7
8
9
10
11
def llm_rerank(query, search_results, top_k=3):
    docs_text = '\n'.join(
        f"[{doc.metadata['id']}] {doc.page_content}"
        for doc, _ in search_results
    )
    # LLM에 JSON 형태로 점수 요청
    scoring_chain = ChatPromptTemplate.from_messages([
        ('system', '당신은 scoring 시스템입니다. 항상 json 형태로 출력하세요'),
        ('human', '쿼리: {query}\n\n문서들:\n{docs}'),
    ]) | llm | StrOutputParser()
    ...

2-4. Hybrid Reranker

BM25 점수 + LLM 점수를 가중 합산.

1
2
3
4
5
6
def hybrid_rerank(query, search_results, bm25_weight=0.3):
    # BM25 점수 (정규화)
    bm25_scored = BM25Reranker().rerank(query, search_results)
    # LLM 점수 (정규화)
    llm_scored = llm_rerank(query, search_results)
    # 가중 합산: final = bm25_weight * bm25 + (1-bm25_weight) * llm

2-5. Listwise vs Pointwise 비교

1
2
3
4
5
6
7
Pointwise (1개씩)
  장점: 정확, 이유 명확, 병렬 처리 가능, 빠름
  단점: 비용, 문서 간 상대 비교 불가

Listwise (N개씩 한꺼번에)
  장점: 문서 간 비교 가능
  단점: 할루시네이션 위험, 컨텍스트 길어짐

3. Score Filtering

검색 결과에서 낮은 품질의 문서를 제거하는 전략.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ScoreFilter:
    @staticmethod
    def fixed_threshold(scored_docs, threshold=0.5):
        # threshold 이하 문서 제거
        return [(doc, s) for doc, s in scored_docs if s >= threshold]

    @staticmethod
    def dynamic_threshold(scored_docs, std_factor=1.0):
        # mean - std_factor * std 이상인 문서만 유지
        scores = [s for _, s in scored_docs]
        cutoff = np.mean(scores) - std_factor * np.std(scores)
        return [(doc, s) for doc, s in scored_docs if s >= cutoff]

    @staticmethod
    def score_gap(scored_docs, min_docs=2):
        # 점수 급락 지점 이전까지만 유지
        ...

AdaptiveFilter: 점수 분포에 따라 전략을 자동 선택.


4. Context Compression

LLM 컨텍스트 길이(128K 토큰)는 제한이 있어, 문서를 압축해서 넣어야 효율적.

4-1. Extractive Compress

1
2
3
4
5
6
7
8
def extractive_compress(query, document, max_sentences=3):
    # 문장 분리 후 쿼리 단어와 겹치는 문장만 선택
    sentences = re.split(r'[.!?]\s*', document)
    query_terms = set(query.lower().split())
    scored = [(s, sum(1 for w in s.lower().split() if w in query_terms))
              for s in sentences if s.strip()]
    scored.sort(key=lambda x: x[1], reverse=True)
    return '. '.join([s for s, _ in scored[:max_sentences]])

4-2. LLM Compress

LLM에게 직접 문서 요약 요청.

1
2
3
4
compress_chain = ChatPromptTemplate.from_messages([
    ("system", "당신은 문서 요약 전문가입니다"),
    ("human", "쿼리: {query}\n\n문서: {document}\n\n쿼리에 관련된 내용만 압축 요약:"),
]) | llm | StrOutputParser()

4-3. MMR Compress

관련성은 유지하면서 중복 문장 제거.

1
2
3
def mmr_compress(query, document, max_sentences=3, param=0.5):
    # param: Relevance 가중치 (높을수록 쿼리 관련성 중시)
    # mmr = param * Relevance(s, query) - (1-param) * max(Similarity(s, selected))

4-4. 압축 품질 평가

1
2
3
4
def evaluate_compression(original, compressed, query):
    # 압축률: len(compressed) / len(original)
    # 키워드 보존율: 쿼리 단어가 얼마나 남았나
    # 의미 보존도: 원문 임베딩 vs 압축 임베딩 코사인 유사도

5. MMR (Maximum Marginal Relevance)

쿼리 관련성은 높이면서 선택된 문서들 간의 중복을 최소화.

1
2
3
4
MMR = P * Relevance(s, query) - (1-P) * max(Similarity(s, selected))

P = 0.1 → 다양성 중시
P = 0.9 → 관련성 중시
1
2
# MMR retriever 사용
mmr_results = vectorstore.max_marginal_relevance_search(query, k=5, fetch_k=20)

6. RAG 평가 지표

Average Precision (AP)

검색 결과에서 정답 문서가 얼마나 높은 순위에 있는지 측정.

1
2
3
4
5
6
7
8
9
def average_precision(retrieved_ids, relevant_ids):
    relevant_set = set(relevant_ids)
    hits = 0
    precision_sum = 0.0
    for i, doc_id in enumerate(retrieved_ids):
        if doc_id in relevant_set:
            hits += 1
            precision_sum += hits / (i + 1)
    return precision_sum / len(relevant_ids) if relevant_ids else 0.0

MAP (Mean Average Precision)

여러 쿼리에 대한 AP의 평균.

ILS (Intra-List Similarity)

검색 결과의 다양성 측정. 낮을수록 다양한 문서 검색.

1
2
3
4
5
def intra_list_similarity(doc_ids, doc_embeddings, top_k=5):
    # 선택된 문서들의 쌍별 코사인 유사도 평균
    ids = doc_ids[:top_k]
    embs = [doc_embeddings[id] for id in ids]
    ...

A/B Test

1
2
3
4
def ab_test(scores_a, scores_b, alpha=0.05):
    # t-test로 통계적 유의성 검정
    t_stat, p_value = stats.ttest_ind(scores_a, scores_b)
    return {'significant': p_value < alpha, 'p_value': p_value}

7. LLM-as-a-Judge 평가 전략

7-1. Basic Judge (Pointwise)

1
2
3
4
5
def basic_judge(question, answer, scale='1~5'):
    prompt = f"""다음 답변을 {scale}점으로 평가하세요.
    질문: {question}
    답변: {answer}"""
    return llm.invoke(prompt)

7-2. ROUGE F1 (참고용 키워드 기반)

1
2
3
4
def simple_rouge_f1(reference, candidate):
    ref_words = Counter(reference.split())
    cand_words = Counter(candidate.split())
    # precision, recall, F1 계산

7-3. Rubric-based Judge

1
2
3
4
5
6
7
RUBRIC = {
    5: "정확하고 완전하며, 예시와 설명이 풍부하다",
    4: "정확하고 핵심을 다루지만, 일부 세부사항이 부족하다",
    3: "대체로 정확하지만 불완전하거나 모호한 부분이 있다",
    2: "일부 정보가 맞지만 중요한 오류가 있다",
    1: "잘못된 정보이거나 질문과 무관하다",
}

7-4. Pairwise Judge

두 답변을 동시에 주고 더 나은 쪽 선택.

1
2
3
4
5
6
def pairwise_judge(question, answer_a, answer_b):
    prompt = """두 답변을 비교하여 더 나은 쪽을 선택하세요
    질문: {question}
    A: {answer_a}
    B: {answer_b}
    → {'winner': 'A' or 'B', 'reason': '...'}"""

위치 편향 방지: (A,B)와 (B,A) 순서 바꿔서 2회 평가 후 다수결

1
2
3
4
def pairwise_judge_with_swap(question, answer_a, answer_b):
    result1 = pairwise_judge(question, answer_a, answer_b)
    result2 = pairwise_judge(question, answer_b, answer_a)
    # 두 결과 종합

7-5. Reference-based Judge

정답 예시(Reference)를 함께 제공해 채점.

1
2
3
4
def reference_based_judge(question, reference, answer):
    prompt = """정답을 기준으로 답변을 평가하세요.
    정답: {reference}
    답변: {answer}"""

7-6. Chain-of-Thought (CoT) Judge

단계별로 분석 후 점수 부여 → 이유 추적 가능.

1
2
3
4
5
def cot_judge(question, answer):
    prompt = """다음 답변을 단계별로 분석하세요.
    1. 정확성 분석
    2. 완전성 분석
    3. 최종 점수"""

7-7. Few-shot Judge

평가 예시를 포함해 일관된 채점 기준 유지.

7-8. Multi-dimensional Judge

1
2
3
4
5
6
EVAL_DIMENSIONS = {
    "accuracy":     "사실적으로 정확한 정보만 포함하는가 (1-5)",
    "relevance":    "질문의 의도와 범위에 적합한 답변인가 (1-5)",
    "completeness": "질문의 모든 측면을 다루는가 (1-5)",
    "coherence":    "논리적 흐름과 구조가 일관적인가 (1-5)",
}

7-9. Ensemble Judge

여러 judge 결과를 가중평균.

1
2
3
4
5
6
def ensemble_judge(question, answer, judge_fns, weight=None):
    scores = []
    for name, fn in judge_fns:
        result = fn(question, answer)
        scores.append(result)
    return np.average(scores, weights=weight)

7-10. 재현성 & 분별력 테스트

1
2
3
4
5
6
7
8
9
10
11
# 재현성: 변동계수(std/mean) < 0.1 이면 안정적
def test_reproducibility(question, answer, n_trials=5):
    scores = [pointwise_judge(question, answer) for _ in range(n_trials)]
    cv = np.std(scores) / np.mean(scores)
    return {'mean': np.mean(scores), 'cv': cv}

# 분별력: 좋은 답변 vs 나쁜 답변 점수 차이가 충분한지
def test_discriminability(question, good_answer, bad_answer):
    good_scores = [pointwise_judge(question, good_answer) for _ in range(3)]
    bad_scores  = [pointwise_judge(question, bad_answer)  for _ in range(3)]
    return np.mean(good_scores) - np.mean(bad_scores)

자주 나오는 실수 / 주의사항

  • 위치 편향 (Position Bias): LLM은 첫 번째로 주어진 문서/답변에 더 높은 점수를 주는 경향 → 반드시 순서 바꿔서 2회 평가
  • 길이 편향 (Length Bias): 긴 답변에 점수를 후하게 주는 경향 → 길이가 다른 답변을 의도적으로 테스트
  • 자기 강화 편향: LLM이 자신과 유사한 스타일의 문장에 후하게 평가
  • BM25 파라미터 착각: k1, b 기본값(1.5, 0.75)이 항상 최적이 아님 → 도메인에 맞게 튜닝
  • threshold 고정값 위험: 데이터 분포가 바뀌면 fixed_threshold 성능 급락 → dynamic threshold 권장

[보충] 평가 프레임워크 설계 원칙

좋은 평가 기준(Criterion) 조건:

조건설명
관찰 가능예시가 포함되어 있는지, 채점자가 확인할 수 있어야 함
독립적다른 기준과 내용이 겹치지 않아야 함
측정 가능1~5 등 객관적으로 정량화할 수 있어야 함
평가 목적 부합실제 서비스 목적(정확성, 안전성 등)에 맞아야 함
1
2
3
4
5
6
7
8
9
10
11
12
class EvaluationCriterion:
    def __init__(self, name, description, scale=(1, 5)):
        self.name = name
        self.description = description
        self.scale = scale

STANDARD_CRITERIA = [
    EvaluationCriterion("accuracy",     "사실적으로 정확한 정보만 포함하는가"),
    EvaluationCriterion("relevance",    "질문의 의도에 적합한가"),
    EvaluationCriterion("completeness", "모든 측면을 다루는가"),
    EvaluationCriterion("coherence",    "논리적 흐름이 일관적인가"),
]

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그