포스트

2026-04-08 강의 정리

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

2026-04-08 강의 정리

검색 문서를 줄이는 작업은 토큰 수와 답변 근거를 동시에 다룬다. 필터링은 문서를 제외하고, 압축은 내용을 줄이며, MMR은 후보의 중복을 조정한다. 결과가 짧아졌다는 사실보다 질문에 필요한 정보가 남았는지를 기준으로 각 단계를 살펴본다.

목차

  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. [보충] 압축 기법 비교
  13. 컨텍스트를 줄일 때 남겨야 할 근거

1. ScoreFilter 완성 (score_gap, AdaptiveFilter)

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

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 압축에서는 원문과 압축문을 함께 보관해 무엇이 삭제되거나 바뀌었는지 확인한다. 숫자·부정 표현·예외 조건이 빠지면 짧고 자연스러운 요약이어도 답변 근거로 쓰기 어려울 수 있다.

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 (다양성)

13. 컨텍스트를 줄일 때 남겨야 할 근거

필터와 압축을 적용하기 전의 검색 결과를 비교 기준으로 남긴다. 답변이 나빠졌다면 문서 자체가 제외됐는지, 남은 문서에서 중요한 부분이 잘렸는지 구분할 수 있어야 한다.

평가에는 토큰 감소량, 근거 보존 여부, 답변 결과를 함께 둔다. 중복을 줄이는 효과와 관련성이 낮아지는 손실을 같은 질문에서 확인한 뒤 필터 기준이나 MMR 설정을 조정한다.

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

인기 태그