포스트

2026-04-08 강의 정리

RAG 답변 평가 - 정량적 지표 활용

2026-04-08 강의 정리

목차

  1. ScoreFilter 완성 (score_gap, AdaptiveFilter)
  2. Context Compression 개념
  3. MMR (Maximum Marginal Relevance) 개념
  4. Extractive Compress
  5. LLM Compress
  6. LangChain ContextualCompressionRetriever
  7. 압축 품질 평가 (evaluate_compression)
  8. MMR 기반 압축 (mmr_compress)
  9. Average Precision (AP) / mAP
  10. Intra-List Similarity (ILS)
  11. 자주 나오는 실수 / 주의사항
  12. [보충] 압축 기법 비교

1. ScoreFilter 완성 (score_gap, AdaptiveFilter)

04_07에서 fixed_threshold, dynamic_threshold를 다뤘고, 이번 강의에서 score_gapAdaptiveFilter가 추가됩니다.

score_gap — 점수 급락 지점에서 자르기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ScoreFilter:

    @staticmethod
    def fixed_threshold(scored_docs, threshold=0.5):
        return [(doc, s) for doc, s in scored_docs if s >= threshold]

    @staticmethod
    def dynamic_threshold(scored_docs, std_factor=1):
        scores = [s for _, s in scored_docs]
        threshold = np.mean(scores) - std_factor * np.std(scores)
        return [(doc, s) for doc, s in scored_docs if s >= threshold]

    @staticmethod
    def score_gap(scored_docs, min_docs=2):
        """인접 문서 간 점수 차이가 가장 큰 지점에서 절단"""
        if len(scored_docs) <= min_docs:
            return scored_docs

        scores = [s for _, s in scored_docs]
        gaps = [(scores[i] - scores[i+1], i+1) for i in range(len(scores)-1)]
        max_gap_idx = max(gaps, key=lambda x: x[0])[1]
        cut = max(min_docs, max_gap_idx)
        return scored_docs[:cut]
1
2
3
4
5
score_gap 예시:
  점수: [0.9, 0.85, 0.7, 0.5, 0.2]
  갭:  [(0.05, 1), (0.15, 2), (0.2, 3), (0.3, 4)]
  최대 갭 위치: 4 → scored_docs[:4] 반환
  출력: [0.9, 0.85, 0.7, 0.5]

AdaptiveFilter — 분포에 따라 전략 자동 선택

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AdaptiveFilter:

    @staticmethod
    def adapt_filter(scored_docs):
        """점수 분포에 따라 필터링 전략을 자동으로 선택"""
        scores = [s for _, s in scored_docs]
        std = np.std(scores)
        score_range = max(scores) - min(scores)

        if std > 0.2:
            # 점수 분산이 크다 → 급락 지점에서 절단
            return ScoreFilter.score_gap(scored_docs, min_docs=2)
        elif score_range < 0.1:
            # 점수가 모두 비슷하다 → 상위 절반만
            return scored_docs[:len(scored_docs)//2]
        else:
            # 그 외 → 동적 임계값
            return ScoreFilter.dynamic_threshold(scored_docs, std_factor=1)
1
2
3
4
선택 기준:
  std > 0.2       → score_gap  (뚜렷한 점수 차이 있음)
  range < 0.1     → 상위 절반  (모두 비슷해서 구분 어려움)
  그 외            → dynamic_threshold

2. Context Compression 개념

왜 필요한가?

1
2
3
4
5
6
7
gpt-4o-mini 컨텍스트: 128K 토큰
  = system prompt + (context1, context2, ...) + user_prompt + 답변 공간

문서를 많이 넣을수록 → 토큰 소비 ↑ → 비용 ↑ + 속도 ↓
                     → 관련 없는 내용이 생성 방해 가능

해결책: 검색된 문서를 쿼리에 맞게 압축

압축 방법 3가지

1
2
3
1. Extractive Compress : 쿼리와 관련된 문장만 추출 (원문 문장 그대로)
2. LLM Compress        : LLM이 쿼리 중심으로 재작성/요약
3. MMR Compress        : 관련성 + 다양성을 동시에 고려하여 문장 선택

3. MMR (Maximum Marginal Relevance) 개념

MMR은 관련성(Relevance)과 다양성(Diversity) 사이의 균형을 잡는 기법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[문제 상황]
  쿼리: "트랜스포머 아키텍처"

  일반 벡터 검색 결과:
    1. 트랜스포머는 ~~~
    2. 트랜스포머는 ~~~  (거의 같은 내용)
    3. 트랜스포머는 ~~~  (또 비슷한 내용)

  → 다양성 없이 유사한 내용만 반복 → 생성 품질 저하

[MMR 공식]
  MMR_score(s) = λ × Relevance(s, query) - (1-λ) × max(Similarity(s, selected))

  - λ (lambda/param): 0~1 사이 (높을수록 관련성 중시, 낮을수록 다양성 중시)
  - Relevance(s, query): 문서s와 쿼리의 코사인 유사도
  - max(Similarity(s, selected)): 이미 선택된 문서들과의 최대 유사도
1
2
3
4
5
6
7
[MMR 알고리즘 동작]
  후보: [1, 2, 3, 4, 5]
  선택됨: []

  Step 1: 쿼리와 가장 유사한 문서 선택 → selected=[1]
  Step 2: 나머지 중 MMR 최대 → 관련 있지만 1과 다른 문서 선택 → selected=[1, 3]
  Step 3: 또 다른 관련+다양 문서 선택 → selected=[1, 3, 5]

4. Extractive Compress

문서를 문장 단위로 분리한 뒤, 쿼리 단어가 많이 포함된 문장만 선택합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def extractive_compress(query, document, max_sentences=3):
    sentences = re.split(r'[.!?]\s*', document)

    if not sentences:
        return document

    query_terms = set(query.lower().split())

    scored = []
    for sent in sentences:
        sent_terms = set(sent.lower().split())
        overlap = len(query_terms & sent_terms)
        scored.append((sent, overlap))

    scored.sort(key=lambda x: x[1], reverse=True)
    selected = [s for s, _ in scored[:max_sentences]]

    return ". ".join(selected) + "."
1
2
3
4
5
6
7
8
9
10
예시:
  쿼리   : "트랜스포머와 BERT의 관계"
  문서   : "트랜스포머는 2017년 구글이 발표한 아키텍처입니다. Self-Attention 메커니즘이 핵심입니다.
            이전의 RNN, LSTM과 달리 병렬 처리가 가능합니다. BERT와 GPT 모두 트랜스포머를 기반으로 합니다."

  점수:
    "트랜스포머는 2017년 ..." → 쿼리 단어 "트랜스포머" 1개 히트
    "BERT와 GPT 모두 ..."    → 쿼리 단어 "BERT", "트랜스포머" 2개 히트 ← 최고점

  출력: "BERT와 GPT 모두 트랜스포머를 기반으로 합니다. 트랜스포머는 2017년..."

5. LLM Compress

LLM이 쿼리에 맞게 문서를 재작성/압축합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
compress_chain = ChatPromptTemplate.from_messages([
    ("system", "당신은 문서 요약 전문가입니다"),
    ("human", """다음 문서에서 쿼리에 답하는데 필요한 핵심 정보만 추출하세요.
    불필요한 내용은 제거하고, {max_tokens}자 이내로 압축하세요.

    쿼리 : {query}
    문서 : {document}

    압축결과:""")
]) | llm | StrOutputParser()

def llm_compress(query, document, max_tokens=100):
    return compress_chain.invoke({
        'query': query,
        'document': document,
        'max_tokens': max_tokens
    })

6. LangChain ContextualCompressionRetriever

LangChain에서 제공하는 압축 리트리버: 검색 후 자동으로 LLM 압축을 적용합니다.

1
2
3
4
5
6
7
8
9
10
11
from langchain_classic.retrievers import ContextualCompressionRetriever
from langchain_classic.retrievers.document_compressors import LLMChainExtractor

compressor = LLMChainExtractor.from_llm(llm)
compressor_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=vectorstore.as_retriever(search_kwargs={'k': 3})
)

# 검색 + 압축 한 번에
compressed_docs = compressor_retriever.invoke(query)
1
2
3
4
5
6
7
8
동작 흐름:
  사용자 쿼리
    ↓
  base_retriever → 문서 k개 검색
    ↓
  LLMChainExtractor → 각 문서를 쿼리 기준으로 압축
    ↓
  압축된 문서들 반환

7. 압축 품질 평가 (evaluate_compression)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def evaluate_compression(original, compressed, query):
    # 1) 압축률
    ratio = len(compressed) / len(original)

    # 2) 키워드 보존율 (쿼리 단어가 얼마나 남았나)
    query_terms = set(query.lower().split())
    orig_terms  = set(original.lower().split())
    comp_terms  = set(compressed.lower().split())

    orig_query_terms = query_terms & orig_terms
    comp_query_terms = query_terms & comp_terms
    keyword_coverage = len(comp_query_terms) / len(orig_query_terms) if orig_query_terms else 1.0

    # 3) 의미 보존도 (코사인 유사도)
    original_emb   = np.array(embeddings_model.embed_query(original))
    compressed_emb = np.array(embeddings_model.embed_query(compressed))
    semantic_similarity = np.dot(original_emb, compressed_emb) / (
        np.linalg.norm(original_emb) * np.linalg.norm(compressed_emb)
    )

    return {
        'compression_ratio':    ratio,               # 낮을수록 많이 압축
        'keyword_coverage':     keyword_coverage,    # 1.0이면 키워드 전부 보존
        'semantic_similarity':  semantic_similarity  # 1.0이면 의미 완전 보존
    }
1
2
3
4
5
6
좋은 압축의 조건:
  compression_ratio   낮음   (많이 압축)
  keyword_coverage    높음   (쿼리 관련 단어 보존)
  semantic_similarity 높음   (의미 유지)

  셋 다 동시에 최적화하기 어려움 → 용도에 따라 우선순위 결정

8. MMR 기반 압축 (mmr_compress)

관련성과 다양성을 동시에 고려하여 문장을 선택합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def cosine_sim(emb_a, emb_b):
    return np.dot(emb_a, emb_b) / (np.linalg.norm(emb_a) * np.linalg.norm(emb_b))

def mmr_compress(query, document, max_sentences=3, param=0.5):
    sentences = re.split(r'[.!?]\s*', document)
    sentences = [s.strip() for s in sentences if len(s.strip()) > 10]

    if len(sentences) <= max_sentences:
        return ". ".join(sentences) + "."

    query_emb = get_embedding(query)
    sent_embs = [get_embedding(s) for s in sentences]

    selected  = []
    remaining = list(range(len(sentences)))

    for _ in range(max_sentences):
        best_score = -float('inf')
        best_idx = -1

        for idx in remaining:
            relevance = cosine_sim(query_emb, sent_embs[idx])

            if selected:
                max_sim = max(cosine_sim(sent_embs[idx], sent_embs[s]) for s in selected)
            else:
                max_sim = 0.0

            mmr_score = param * relevance - (1 - param) * max_sim

            if mmr_score > best_score:
                best_score = mmr_score
                best_idx = idx

        selected.append(best_idx)
        remaining.remove(best_idx)

    return ". ".join(sentences[i] for i in sorted(selected)) + "."
1
2
3
4
5
6
7
8
param 조정 효과:
  param=0.7 (관련성 70% 비중):
    → 쿼리와 가장 관련 있는 문장 위주로 선택
    → "BERT와 GPT 모두 트랜스포머를 기반으로 합니다. 트랜스포머는 2017년 구글이 발표한..."

  param=0.3 (다양성 70% 비중):
    → 관련성보다 서로 다른 내용의 문장 위주로 선택
    → "트랜스포머는 2017년 구글이 발표한 아키텍처입니다. 이전의 RNN, LSTM과 달리 병렬 처리가 가능합니다."

9. Average Precision (AP) / mAP

AP (Average Precision)

리랭킹 효과를 수치로 측정하는 지표입니다.

1
2
3
4
5
6
7
8
9
10
11
12
[AP 계산 방식]
  검색 결과: [관련O, 관련X, 관련O, 관련X, 관련O]  (총 관련 문서: 3개)

  1위: 관련O → Precision@1 = 1/1 = 1.0
  2위: 관련X → 건너뜀
  3위: 관련O → Precision@3 = 2/3 ≈ 0.667
  4위: 관련X → 건너뜀
  5위: 관련O → Precision@5 = 3/5 = 0.6

  AP = (1.0 + 0.667 + 0.6) / 3 = 0.756

  → 관련 문서가 상위에 몰릴수록 AP ↑
1
2
3
4
5
6
7
8
9
10
11
12
def average_precision(retrieved_ids, relevant_ids):
    relevant_set = set(relevant_ids)
    hits = 0
    sum_precision = 0.0

    for i, doc_id in enumerate(retrieved_ids):
        if doc_id in relevant_set:
            hits += 1
            precision_at_i = hits / (i + 1)
            sum_precision += precision_at_i

    return sum_precision / len(relevant_set) if relevant_set else 0.0

mAP (mean Average Precision)

여러 쿼리에 대한 AP의 평균 → 검색 시스템 전체 성능 측정

1
2
3
4
5
6
7
8
def mean_average_precision(query_results):
    # query_results: {query: (retrieved_ids, relevant_ids), ...}
    aps = []
    for query, (retrieved, relevant) in query_results.items():
        ap = average_precision(retrieved, relevant)
        aps.append(ap)

    return np.mean(aps)
1
2
3
4
mAP 해석:
  mAP = 1.0 → 모든 쿼리에서 관련 문서가 최상위에 정렬됨
  mAP = 0.5 → 평균적으로 관련 문서가 중간 정도 위치에 있음
  mAP = 0.0 → 관련 문서를 전혀 상위에 올리지 못함

10. Intra-List Similarity (ILS)

검색 결과의 다양성을 측정합니다. 높을수록 비슷한 문서들만 검색된 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def intra_list_similarity(doc_ids, doc_embeddings, top_k=5):
    """상위 top_k 문서들 간의 평균 코사인 유사도"""
    ids  = doc_ids[:top_k]
    embs = [doc_embeddings[did] for did in ids if did in doc_embeddings]

    if len(embs) < 2:
        return 0.0

    # 모든 쌍 조합 계산: (a,b), (a,c), (b,c), ...
    sims = []
    for i in range(len(embs)):
        for j in range(i+1, len(embs)):
            sims.append(cosine_sim(embs[i], embs[j]))

    return np.mean(sims)
1
2
3
4
5
6
7
8
9
10
[쌍 조합 수]
  문서 5개 [a, b, c, d, e]
  → (a,b), (a,c), (a,d), (a,e), (b,c), (b,d), (b,e), (c,d), (c,e), (d,e)
  → N*(N-1)/2 = 5*4/2 = 10쌍

ILS 해석:
  ILS ↑ → 검색 결과가 서로 비슷함 (다양성 낮음)
  ILS ↓ → 검색 결과가 서로 다양함 (다양성 높음)

관련 지표: alpha-NDCG (다양성 가중치 파라미터 alpha 포함)

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

  • AdaptiveFilter에서 test_scores 하드코딩: ScoreFilter.score_gap(test_scores, ...) 대신 ScoreFilter.score_gap(scored_docs, ...) 를 써야 함 — 강의 코드에 버그 있음
  • extractive_compress: re.split(r'[.!?]\s*', ...) 사용 시 빈 문자열이 섞일 수 있음 → [s for s in sentences if s.strip()] 필터 추가 권장
  • mmr_compress 임베딩 비용: 문장마다 임베딩 API 호출 → 문장 수 많으면 비용 급증. 배치 처리 고려
  • AP 분모: sum_precision / len(relevant_set) — relevant_set이 0이면 ZeroDivisionError
  • ILS와 다양성의 역관계: ILS가 높다 = 유사도 높다 = 다양성 낮다 — 헷갈리지 않도록 주의

[보충] 압축 기법 비교

기법방식장점단점
Extractive키워드 겹침으로 문장 선택빠름, LLM 비용 없음의미 파악 못함, 단편적
LLM CompressLLM이 재작성/요약자연스러운 압축, 의미 보존비용, 환각 위험
MMR Compress관련성 + 다양성 균형중복 없이 다양한 정보임베딩 비용, 속도 느림
LangChain ContextualLLMChainExtractor 자동화파이프라인 통합 간편내부 프롬프트 고정

전체 RAG + 압축 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
사용자 쿼리
    ↓
[검색] FAISS / BM25 / Hybrid (k=5~20개)
    ↓
[리랭킹] BM25 / LLM Pointwise / Listwise
    ↓
[ScoreFilter] 낮은 점수 문서 제거
    ↓
[Context Compression]
  ├─ 비용 최소화  → Extractive Compress
  ├─ 품질 최대화  → LLM Compress
  └─ 다양성 필요  → MMR Compress
    ↓
[Generator] 압축된 컨텍스트로 답변 생성
    ↓
[평가] AP / mAP (관련성), ILS (다양성)

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

인기 태그